Home

Automatic pre-processing of post images in Eleventy

2023-12-29

Background

When I moved this blog from Hugo to Eleventy I wanted to set everything up from scratch to be in control. No more massive themes with mysterious features you're afraid to change because you don't know the potential side-effects. That being said, I did look around at different Eleventy starters, like the Eleventy base blog, and took note of a few features to set up myself.

Since I was in full control of the site, getting good performance was surprisingly easy, resulting in a perfect score from the Lighthouse analyzer in Chrome. While working on the site I would occationally run the analyzer again, to make sure the score hadn't deteriorated. One single post always failed, since it had a massive image in JPEG format that I just hadn't bothered to resize manually. Ideally I shouldn't have to, either. This was a perfect use case for some Eleventy automation!

Plugins

I was already aware of the official Image plugin for Eleventy, which can take a source image and generate copies in various sizes and formats. It's using the Sharp image processor for Node under the hood. I'm very familiar with this, so writing a similar feature from scratch would probably have been very doable. However, it's very comfortable to just use a proven implementation when available.

What did surprise me a little was that the Image plugin works exclusively with a custom shortcode, rather than regular Markdown images. I write all my posts in Markdown, and I wanted to have all images automatically pre-scaled in various formats, so that visitors could get the best quality and performance for their device. So, I had the Eleventy Image plugin, and I knew the Markdown processor used in Eleventy, markdown-it has good support for plugins, to alter the behavior of certain elements. I just had to tie it all together. Things were looking promising!

By pure chance I then stumbled upon the markdown-it-eleventy-img plugin for markdown-it, that does exactly what I was just about to do myself. The thought of something like this already existing hadn't even crossed my mind, and I got that familiar feeling of relief mixed with disappointment that arises when a challenge is taken away from you. Of course I could still have written my own implementation, but in this case the goal was more important to me than the journey itself.

Configuration

Time to get everything set up! First I installed the markdown-it-eleventy-img plugin using npm:

npm install markdown-it-eleventy-img --save-dev

Then I added it to my .eleventy.js configuration file:

const markdownItEleventyImg = require("markdown-it-eleventy-img");

module.exports = function (eleventyConfig) {
    eleventyConfig.amendLibrary('md', mdLib => mdLib.use(markdownItEleventyImg, {
        imgOptions: {
            widths: [368, 736, 900],
            formats: ['auto', 'webp', 'avif'], // Don't do this, see below
            outputDir: '_site/assets/images/scaled',        
            urlPath: '/assets/images/scaled'
        },
        globalAttributes: {
            sizes: '100vw'
        }
    })

    // Further configuration ...

    return eleventyConfig;
}

I'm using the amendLibrary function to add the plugin to the markdown-it instance already in use in Eleventy. In addition to the plugin itself, I also supply a tiny bit of configuration. Here I specify the image widths and formats I want to generate, and specify both where I want the resulting files to be saved and what URL path this corresponds to.

Lo and behold, it worked!

An image in a Markdown post like this:

![An image](/assets/images/image.png)

Used to be converted into a regular image tag:

<img src="/assets/images/image.png" alt="An image">

But now, it resulted in a whole buffet of images for the browser to choose from:

<picture>
    <source type="image/png" srcset="/assets/images/scaled/FR-0A22Z5T-368.png 368w, /assets/images/scaled/FR-0A22Z5T-736.png 736w, /assets/images/scaled/FR-0A22Z5T-900.png 900w" sizes="100vw">
    <source type="image/webp" srcset="/assets/images/scaled/FR-0A22Z5T-368.webp 368w, /assets/images/scaled/FR-0A22Z5T-736.webp 736w, /assets/images/scaled/FR-0A22Z5T-900.webp 900w" sizes="100vw">
    <source type="image/avif" srcset="/assets/images/scaled/FR-0A22Z5T-368.avif 368w, /assets/images/scaled/FR-0A22Z5T-736.avif 736w, /assets/images/scaled/FR-0A22Z5T-900.avif 900w" sizes="100vw">
    <img alt="An image" src="/assets/images/scaled/FR-0A22Z5T-368.png" width="900" height="900">
</picture>

Problems

Even though my first test was successful, I immediately noticed a few problems as I started clicking around the site:

Scaling proportionally

I'm still not entirely sure why the images wouldn't scale proportionally automatically, but I think I may have caused it myself. Before I had any image scaling in place, I was just using CSS to make sure the images fit within the page:

article img {
    max-width: 100%;
}

With the new setup, the img element automatically received width and height attributes. It seems like browsers set the image width according to the max-width CSS rule and the height based on the height property. The solution was to add a CSS rule for the height as well:

article img {
    max-width: 100%;
    height: auto;
}

It feels like I might be missing something here, but this workaround has consistently solved the problem in all browsers I've tested.

Post-relative images

As I prefer to keep post images in the same directory as the post itself, avoiding a large central collection of images with unclear usage, the issue with the markdown-it-eleventy-img plugin not finding theses was a showstopper. Fortunately, the solution was very straightforward, as the plugin offers full control over where to look for different images using the resolvePath option:

const path = require('path');

eleventyConfig.amendLibrary("md", mdLib => mdLib.use(markdownEleventyImg, {
    imgOptions: { ... },
    globalAttributes: { ... },
    resolvePath: (filepath, env) => {
        let isPostImage = filepath.startsWith('./');
        if (isPostImage) {
            // Resolve path to post-relative images
            return path.join(path.dirname(env.page.inputPath), filepath);
        } else {
            // Resolve path to global images
            return path.join('_site', filepath);
        }
    }
}));

I always refer to images relative to posts using paths beginning with ./, which makes them easy to identify here. While I don't currently use any "global" images in my posts, I didn't want to break that functionality. I'm using the Node path library to piece together the paths needed for the two different use cases. Additionally, I've noticed it's sometimes necessary to clean the previous build for the image plugin to work correctly. This is likely since it attempts to avoid regenerating images that already exist. If you encounter weird problems building your site after changing the image configuration, try cleaning the site by deleting the _site directory, and then rebuild it.

Modern file formats not used

With everything else working, it was perplexing to see that all browsers consistently favored the original PNG or JPEG format over of the significantly smaller modern alternatives. What gives! Like so many times before, an assumption turned out to be the source of the problem. My image format specification was set as ['auto', 'webp', 'avif'], with auto representing the original format of the source image. I had presumed that browsers would automatically select the best format they supported, but this feels somewhat naïve in retrospect. In reality, browsers simply pick the first format they support. While I had subconsiosly arranged the formats based on their likelyhood of support, I should in fact have used the exact opposite: ['avif', 'webp', 'auto']. With that little change, Safari, Firefox and Chrome all picked the AVIF file, and there was much rejoicing.

Further improvements

Finally, I was poised to bask in the accolades of the Lighthouse analyzer for my post featuring that large image ... right? Unfortunately, not quite. The post in question is pretty long, and the Lighthouse report complained I should defer loading of the images until the user scrolled down a bit. Fair enough. I skimmed through the official Lighthouse documentation of the issue, which was rather unhelpful. The linked codelab on lazy loading images quickly delved into JavaScript solutions, which I very much wanted to avoid (on the client side).

Fortunately, there are better options. Modern browsers support the loading="lazy" property for images, as well as decoding="async" to further optimize for rapid page rendering. To my delight, the image plugin allows for any arguments to the image tag to be specified in the globalAttributes configuration option:

eleventyConfig.amendLibrary("md", mdLib => mdLib.use(markdownEleventyImg, {
    imgOptions: { ... },
    globalAttributes: {
        sizes: '100vw',
        loading: 'lazy',
        decoding: 'async'
    },
    resolvePath: (filepath, env) => { ... }
}));

It's so satisfying to observe the network panel in the browser development tools and see the images load automatically as you scroll down the page. Finally that post got a perfect 100/100/100/100 score in Lighthouse, just like the rest of this site!

Conclusion

After all the modifications above, my final markdown-it-eleventy-img configuration in .eleventy.js looks like this:

eleventyConfig.amendLibrary('md', mdLib => mdLib.use(markdownEleventyImg, {
    imgOptions: {
        widths: [368, 736, 900],
        formats: ['avif', 'webp', 'auto'],
        urlPath: '/assets/images/scaled',
        outputDir: '_site/assets/images/scaled'
    },
    globalAttributes: {
        sizes: '100vw',
        loading: 'lazy',
        decoding: 'async'
    },
    resolvePath: (filepath, env) => {
        let isPostImage = filepath.startsWith('./');
        if (isPostImage) {
            // Resolve path to post-relative images
            return path.join(path.dirname(env.page.inputPath), filepath);
        } else {
            // Resolve path to global images
            return path.join('_site', filepath);
        }
    }
}));

Using this configuraion, everything has been functioning beautifully so far. Since I don't frequently use images in my posts, it remains to be seen if any issues will aries in the future. I'm very happy that I found the markdown-it-eleventy-img plugin, and that it's been so flexible to work with. Kudos to the author, Mathieu Huot, very impressive work!

For those who want to read more about Eleventy customization, I've created a page listing all my Eleventy posts.