Using the Eleventy Image plugin without a central image folder

Background

I don’t use a ton of images on my site, but a while ago I wrote about git cherry-picking and included a screenshot. At the time, I just dropped the image into the same folder as the blog post and kept going, but I knew there were a few problems with that:

  • The original image was huge: more than 2,600 pixels wide, far wider than the maximum width it would ever display at.
  • All users would download the same 98 kB image, regardless of whether they were on desktop or mobile.
Screenshot of Chrome DevTools showing the network tab. The 98 kB image takes 104 milliseconds to download.

I knew I wanted to eventually add some better image pre-processing with Eleventy Image, the framework’s official plugin. This week I finally got around to implementing it.

Overall it was pretty straightforward, but I ran into a few gotchas and wanted to write them up.

The goal

Here’s what I wanted the plugin to handle for me:

CriterionOutcome
Automatically resize and optimize images
Automatically support multiple modern image formats
Automatically output <picture> elements with srcset
No changes to my existing site file structure🤔

The first three were not a problem; all that stuff is supported by Eleventy Image out of the box. That last one, however, posed an issue.

My site directory structure

Supporting my existing file structure turned out to the be hardest part. Instead of keeping images in a single folder at the project root, I like keeping images adjacent to the relevant markdown files, like this:

src/
  ├─ blog/
  │   ├─ post-title/
  │   │   └─ index.md
  │   │   └─ image-1.png
  │   │   └─ image-2.png

Putting on my information architect hat, I prefer this structure for a couple reasons:

  1. I find it more intuitive to keep related material together.
  2. It doesn’t force me to come up with globally unique names for images; the directory structure itself ensures a unique path.
  3. When writing in markdown, no messing around with "../../../images/file.jpg". You don’t have to remember anything about your directory structure in the moment, you can just write "file.jpg".
  4. This is speculation on my part and not a personal priority, but theoretically, image SEO is probably better if there are contextual keywords in the image path.

Eleventy Image, however, basically assumes that you’ll always specify image file locations from the project root, and that you want to output the processed images to a single centralized directory, something like this:

src/
  ├─ blog/
  │   ├─ post-title.md
  ├─ img/
  │   └─ image-1.png
  │   └─ image-2.png

This is, for the record, a totally reasonable choice that works for many, many people. But I already have something different and I wanted to support it as-is, without tearing up the entire site structure.

The problem

I decided to go with with the Asynchronous shortcode method, and opted to implement the do-it-yourself <picture> variant.

With setup complete, I tested by updating an image file from the traditional markdown image syntax to the new Liquid shortcode equivalent:

- ![Image of a GitHub pull request with a bunch of post-rebase commits that shouldn’t be there](rebase-hell.png)
+ {% image 'rebase-hell.png', 'Image of a GitHub pull request with a bunch of post-rebase commits that shouldn’t be there' %}

But when the server reloaded, I got this error:

[11ty] 1. Having trouble rendering liquid template ./src/blog/git-rebase-using-git-cherry-pick/index.md (via TemplateContentRenderError)
[11ty] 2. ENOENT: no such file or directory, stat 'rebase-hell.png', file:./src/blog/git-rebase-using-git-cherry-pick/index.md, line:12, col:1 (via RenderError)

By default, Eleventy Image assumes that the shortcode image path starts relative to your Eleventy inputDir, so it was looking for a file at src/image.png, not src/blog/post-title/image.png. Since there’s no file there, it throws an error and dies.

The solution

I admit this took me quite a long time to troubleshoot, but eventually I figured out how to adjust the shortcode implementation so it would play nicely with my non-standard file directory setup.

This is a simplified version of the code in eleventy.js, with the key changes highlighted:

const Image = require("@11ty/eleventy-img");

async function imageShortcode(src, alt, sizes = "100vw") {

+ // Prepend the image src with the full directory `inputPath`:
+ let imageSrc = `${path.dirname(this.page.inputPath)}/${src}`;

- let metadata = await Image(src, {
+ let metadata = await Image(imageSrc, {
widths: [300, 600],
+ // Write processed images to the correct `outputPath`
+ outputDir: path.dirname(this.page.outputPath),
+ // Prepend the correct path to the image `src` value
+ urlPath: this.page.url,
});

return `<picture>
// picture markup goes here
</picture>`;
}

Now the image plugin can properly fetch image files from their correct location in the file system, and places the processed versions in the equivalent structure in the site build. I don’t have to change my entire image filing practice, and everyone gets properly-sized, optimized images served to their browser, without any extra work on my part.

I’m pretty happy with the results. Most people will get a smaller, faster image now, with no extra work on my part:

Screenshot of Chrome DevTools showing the network tab. The 18 kB image takes 18 milliseconds to download.

Notes and caveats

This is a win overall, but I still see some issues:

  • The solution here is very specific to my particular needs at this particular time. The changes as I’ve made them to the shortcode implementation are mutually exclusive to keeping a centralized image folder, and it shouldn’t necessarily be an either-or. I imagine there’s a more general solve for this issue; I took it only as far as I needed to and no further.
  • Without adding some very complicated additional logic, the automated processing is pretty one-size fits all, and you can see it in the visual quality. This is especially true for screenshots, where the original PNG is very high-fidelity, while the downsized and processed one shows some blurriness and artifacts.
  • Even worse, even though the processed PNG file has way smaller dimensions, it’s nearly 50% bigger in terms of kilobytes served. The .avif and .webp versions are far smaller and a majority of users will be served those versions (WebP is supported by ~94% of browsers, AVIF by ~69%) so this is a tradeoff I’m willing to make. But still kind of irksome.