Putting a class on external links in eleventy

Posted:

This morning, I took inspiration from Rach Smith's Digital Garden , and decided to see if I could also style just my external links - with a catch. I'd like to try to do it on my own and not with an eleventy plugin.

My first thought was how the image short code processor checks for local vs external images. But, that code relies on a shortcode, and I'm too lazy to be consistent with such things. Also, I'm trying to move toward having my content in Obsidian be attractive and readable in Obsidian. After all, a lot of this writing can be used to remind me how I did a particular thing.

My next thought was a string replacement that I use in pre-processing my markdown for the home page. Rather than have a shortcode in Obsidian for the Recent Content section, I use a placeholder in the markdown that looks like:

@@@RECENT_CONTENT@@@

This is a contender. The magic happens in an eleventy event handler for the eleventy.before event.

My existing code looks like: (I had to replace the shortcode in the code snippet because nunjucks was blowing up with it in there)

eleventyConfig.on("eleventy.before", async ({dir, runMode, outputMode}) => {      
    const indexFile = dir.input + '/index.md';      
    let homeContent = fs.readFileSync(indexFile).toString();      
homeContent = homeContent.replace('@@@RECENT_CONTENT@@@', 'SHORTCODE GOES HERE');     
    fs.writeFile(indexFile, homeContent, 'utf8', function (err) {        if (err) return console.log(err);      
});    });

Another possibility is a transformer function that I use to minify my html.

eleventyConfig.addTransform("transformer", function (content) {    if ((this.page.outputPath || "").endsWith(".html")) {  
  
            return htmlmin.minify(content, {                useShortDoctype: true,      
                removeComments: true,      
                collapseWhitespace: true,      
            });      
        }      
          
        // If not an HTML output, return content as-is      
        return content;      
});

I think either of these would work, and my first instinct was to go with the transform. BUT, as I thought about it, it may be a bit easier to do in the event listener because I would be looking for text like this:

(http(s)://example.com)

and just adding some attributes for my markdown parser to further convert. I definitely want the links to open in a new tab or window for users, so I'd like to add a target on the links as well as the class. So, I think I would just need to add

{target="_blank"}{.external}

after the link code - again, ignoring stuff in fence blocks.

To find external links, I'll need to use regular expressions. Oh joy. A quick visit to Stack Overflow gives me the following;
regex:

const regexPattern ='/(?:ht|f)tps?:\/\/[-a-zA-Z0-9.]+\.[a-zA-Z]{2,3}(\/[^"<]*)?/g';
const array = [...content.matchAll(regexp)];

The nice thing is, this regex only finds external links which are formatted as:

https://xxx.com/

and not internal links, which are just text in brackets.

The pre-build step worked like a charm, EXCEPT, it added the target and class modifiers every time it ran, so I ended up with a gazillion repeats. Plus, it was processing every file, whether it had changed or not. That wouldn't do.

First, I changed my regex to only find external links that had not already been processed.

const regexp = '/\(((?:ht|f)tps?:\/\/[-a-zA-Z0-9.]+\.[a-zA-Z]{2,3}\/[^"<]*?\))(\{target)*/g';

Then, I created a file in my build directory called "last_modified", and I put this in it: Sat, 18 May 2024 23:11:33 GMT, and I added a new event hander to run after each build:

eleventyConfig.on("eleventy.after", async (eleventyConfig) => {      
   fs.writeFile(eleventyConfig.dir.base + '/last_modified', new Date().toUTCString(), 'utf8', function (err) {      
        if (err) return console.log(err);      
});    });

That function updates my last_modifed file with the current date and time.

In my eleventy.before handler, I ended up with:

// at the top of the file with the other imports  const fs = require('fs');
const {glob} = require('glob');    
    
    
eleventyConfig.on("eleventy.before", async ({dir, runMode, outputMode}) => {    
    // external links    
    const regexp = /\(((?:ht|f)tps?:\/\/[-a-zA-Z0-9.]+\.[a-zA-Z]{2,3}\/[^"<]*?\))(\{target)*/g;      
    const mdfiles = await glob(dir.input + '**/*.md');      
    const lastBuilt = fs.readFileSync(dir.base + '/last_modified').toString();      
    let buildDate = new Date();      
    if (lastBuilt) {      
        buildDate = new Date(lastBuilt);      
    }      
    mdfiles.forEach(file => {      
        fs.stat(file, function (err, stats) {      
            if (err) {      
                return console.error(err);      
            }      
            if (stats.ctime > buildDate) {       
                let content = fs.readFileSync(file).toString();      
                let matches = [...content.matchAll(regexp)];      
                if (matches) {       
                    matches.forEach(match => {      
                        if (!match[2]) {      
                            let replacement = match[1] + '{target="_blank"}{.external}';      
                            content = content.replace(match[1], replacement);      
                        }      
                    });      
                    fs.writeFile(file, content, 'utf8', function (err) {      
                        if (err) return console.log(err);      
                    });      
                }      
            }      
        });      
    });      
    
    // Most recent content    
    const indexFile = dir.input + '/index.md';      
    let homeContent = fs.readFileSync(indexFile).toString();      
      
    homeContent = homeContent.replace('@@@RECENT_CONTENT@@@', 'SHORTCODE GOES HERE');      
    fs.writeFile(indexFile, homeContent, 'utf8', function (err) {      
        if (err) return console.log(err);      
});    });    
    

Style-wise, I wanted something a bit simple. The text is bold, and there is a little animated effect. My minimalist self hates underlined links, but I keep them now for accessibility reasons.

(Current eleventy version: @11ty/eleventy@2.0.1)