Fun with Eleventy images

I’m currently working on two sites that both require handling images in Markdown files. That probably makes you think: “Why write about an official Eleventy plugin?” The issue is that I want to use images in a different opinionated way than the opinionated way the plugin was changed to in 2021.

The problem is that until commit 31975ef, eleventy-img used the smallest generated image’s dimensions which worked perfectly for @2x images, but from that point on it used the biggest.

I’d already addressed this same problem with this site’s initial development, but back then I chose to use an image shortcode directly in the Markdown since it was the easiest solution at the time.

When I ran into this same problem on the two sites I’m working on now, I thought that there had to be a better way to address the issue. What I ended up with is a combination of the general logic in the old image shortcode, combined with a custom renderer for Markdown images.

Let’s jump right in.

I need to support both absolute and relative paths since I prefer to have images located in the same folder as their post, so the first thing I did was to find a solution for that. The below code allows me to specify either src/a/random/image.jpeg or just image.jpeg for an image referenced from src/a/random/post.md. This same logic can easily be added to eleventyComputed.js as well to deal with images specified in front matter.

let path = src.startsWith("src/")
  ? src
  : path.join(path.dirname(page.inputPath), src);

With consistent absolute paths sorted out, the next step was to configure markdown-it in .eleventy.js to make use of this logic and call the new picture shortcode. As you can see, I also make use of markdown-it-attrs to allow specifying additional attributes like width, height or class.

eleventyConfig.setLibrary("md", markdownIt({
  your: options,
}));
eleventyConfig.amendLibrary("md", (markdown) => {
  markdown.use(markdownItAttrs);
  markdown.renderer.rules.image = function (tokens, idx, opt, env) {
    const token = tokens[idx];
    const src = token.attrGet("src");

    return picture(
      src.startsWith("src/") ? src
        : path.join(path.dirname(env.page.inputPath), src),
      token.content,
      {
        width: token.attrGet("width") * 1,
        height: token.attrGet("height") * 1
      },
      token.attrGet("class"),
      path.dirname(env.page.outputPath),
      env.page.url
    );
  };
});

The meat and potatoes of this solution is the picture shortcode which can still be called directly from a template but is doing double duty by also being called from the custom markdown-it image renderer.

const eleventyImage = require("@11ty/eleventy-img");
const sizeOf = require("image-size");

module.exports = function (src, alt, dimensions, classes, outputDir, urlPath) {
  if (alt === undefined) {
    throw new Error(`Missing "alt" on myImage from: ${src}`);
  }

  const { metadata, sizes } = generateImages(src, dimensions, outputDir, urlPath);

  const sourcesHtmlTags = Object.values(metadata)
    .map((images) => {
      const sourceAttributes = stringifyAttributes({
        type: images[0].sourceType,
        srcset: images.map((image) => image.srcset).join(", "),
        width: sizes.width,
        height: sizes.height
      });

      return `<source ${sourceAttributes}>`;
    })
    .join("\n");

  const imgAttributes = stringifyAttributes({
    src: metadata["webp"].at(-1).url,
    width: sizes.width,
    height: sizes.height,
    alt,
    loading: "lazy",
    decoding: "async",
    class: classes ?? ""
  });

  return `<picture>${sourcesHtmlTags}<img ${imgAttributes}></picture>`;
};

function generateImages(src, dimensions, outputDir, urlPath) {
  const sizes = getSizes(sizeOf(src), dimensions);
  const imageOptions = {
    widths: [Math.ceil(sizes.width), Math.ceil(sizes.width * 2)],
    formats: ["webp", "auto"],
    outputDir: outputDir ?? "./public/images/",
    urlPath: urlPath ?? "/images/",
  };
  const metadata = eleventyImage.statsSync(src, imageOptions);
  eleventyImage(src, imageOptions);

  return { metadata, sizes };
}

function getSizes(input, output) {
  let maxWidth, maxHeight, sizes;

  if (typeof(output) == "number") {
    maxWidth = output;
  } else if (typeof(output) == "object") {
    if (output.width == 0 || output.height == 0) {
      maxWidth = input.width;
      maxHeight = input.height;
    } else if (output.width > 0 && output.height > 0) {
      maxWidth = output.width;
      maxHeight = output.height;
    } else if (output.width && !output.height) {
      maxWidth = output.width;
    } else if (output.height && !output.width) {
      maxHeight = output.height;
    }
  } else {
    maxWidth = input.width;
    maxHeight = input.height;
  }

  if (maxWidth && maxHeight) {
    sizes = aspectRatio(input.width, input.height, maxWidth, maxHeight);
  } else if (maxWidth) {
    sizes = aspectRatio(input.width, input.height, maxWidth, maxWidth);
  } else if (maxHeight) {
    sizes = aspectRatio(input.width, input.height, 608, maxHeight);
  }

  return sizes;
}

function aspectRatio(srcWidth, srcHeight, maxWidth, maxHeight) {
  let ratio = Math.min(maxWidth / srcWidth, maxHeight / srcHeight);

  return {
    width: Math.round(srcWidth * ratio),
    height: Math.round(srcHeight * ratio)
  };
}

function stringifyAttributes(attributeMap) {
  return Object.entries(attributeMap)
    .map(([attribute, value]) => {
      if (typeof value === "undefined") return "";
      return `${attribute}="${value}"`;
    })
    .join(" ");
}

Even though there’s support for specifying the width (and height) of an image, you’ll notice that there’s a fallback to the actual dimensions of the image when none are specified.

Similarly, you can specify outputDir and urlPath for images but they also fall back to general values if omitted. This was added so that the optimised images are output to the same folder as the processed page instead of all going into a central images folder.

The main function simply builds up a <picture> container populated with the <source> elements and an <image> tag — using the smallest dimensions, as intended by the universe.

Can it be improved? Undoubtedly. I’ll probably tinker with it sooner rather than later, with asynchronous support being a good first step.

Does it serve my needs for now? Definitely.