Tomi Chen

Responsive Images in Markdown with Eleventy Image

By Tomi Chen April 16, 2022

I use markdown on this very blog to author content, and I love its simplicity and convenience. By default, images in markdown render to a standard <img> tag, which is sometimes all you need. However, for this blog, I wanted to generate responsive images, which serve different-sized images depending on screen size. Can we the authoring experience of markdown with the performance benefits of responsive images? Let’s find out!

TL;DR: Skip to the final code.

What are responsive images?

In essence, responsive images are a way to let browsers pick the best image for the situation, depending on different conditions. MDN has a great article on responsive images, which goes into a lot more detail, but to sum it up, we can use the <picture> tag to list different image formats and sizes that will respond to the size of the screen.

<picture>
  <source
    type="image/avif"
    srcset="
      /images/cat-340.avif   340w,
      /images/cat-768.avif   768w,
      /images/cat-1536.avif 1536w
    " />
  <source
    type="image/webp"
    srcset="
      /images/cat-340.webp   340w,
      /images/cat-768.webp   768w,
      /images/cat-1536.webp 1536w
    " />
  <img alt="a very cute cat" src="/images/cat.png" />
</picture>

The <picture> tag is very powerful, with many different ways of using it. It can enable art direction (where the image content changes based on width, for example, to crop into a subject), new image formats (like AVIF or WebP), and resolution switching (where the same image content is served, just a different size).

In our case, we’ll be adding new image formats and enabling resolution switching.

Configuring a custom markdown-it renderer

Since I use Eleventy as the static site generator for this website, the markdown renderer is markdown-it, and that’s what this post will use. If you’re using another renderer such as Remark, some concepts might still apply and can be adapted to your needs.

Anyway, we need to replace the default image renderer with a custom one that will output our <picture> tag. Luckily, this isn’t too difficult.

const markdown = require('markdown-it')({
  /* options */
})

markdown.renderer.rules.image = function (tokens, idx, options, env, self) {
  // ...
  return html
}

We can now access the markdown content and attributes, too. The architecture docs can be helpful, as well as the API docs.

const markdown = require('markdown-it')()

markdown.renderer.rules.image = function (tokens, idx, options, env, self) {
  const token = tokens[idx]
  let imgSrc = token.attrGet('src')
  const imgAlt = token.content
  const imgTitle = token.attrGet('title')

  // ...
  return html
}

Generating image sizes and formats

Now we need to generate all the images we’re using. I’m using eleventy-image, but despite the name, it doesn’t need to be used with Eleventy.

The main problem with using eleventy-image is that image generation is asynchronous, but markdown-it doesn’t support asynchronous renderers. Luckily, since we don’t care if the images have finished generating, we can use eleventy-image’s synchronous usage.

eleventy-image also provides a handy generateHTML function that builds our <picture> tag for us.

const markdown = require('markdown-it')()

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

markdown.renderer.rules.image = function (tokens, idx, options, env, self) {
  const token = tokens[idx]
  let imgSrc = token.attrGet('src')
  const imgAlt = token.content
  const imgTitle = token.attrGet('title')

  const htmlOpts = {
    title: imgTitle,
    alt: imgAlt,
    loading: 'lazy',
    decoding: 'async'
  }

  const widths = [250, 316, 426, 460, 580, 768]
  const imgOpts = {
    widths: widths
      .concat(widths.map((w) => w * 2)) // generate 2x sizes
      .filter((v, i, s) => s.indexOf(v) === i), // dedupe
    formats: ['avif', 'webp', 'jpeg'],
    urlPath: '/assets/img/',
    outputDir: './_site/assets/img/'
  }

  Image(imgSrc, imgOpts)
  const metadata = Image.statsSync(imgSrc, imgOpts)

  const generated = Image.generateHTML(metadata, {
    sizes: '(max-width: 768px) 100vw, 768px',
    ...htmlOpts
  })

  return generated
}

There are a couple of things you can configure here:

We’re also setting loading=lazy and decoding=async. Learn more about lazy loading and the decoding attribute.

Adding a caption

Sometimes, you may want a caption to go with your image. The best way to represent this semantically is with a <figcaption> element. We can hijack the title string to use as a caption instead.

const markdown = require('markdown-it')()

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

markdown.renderer.rules.image = function (tokens, idx, options, env, self) {
  function figure(html, caption) {
    return `<figure>${html}<figcaption>${caption}</figcaption></figure>`
  }

  const token = tokens[idx]
  let imgSrc = token.attrGet('src')
  const imgAlt = token.content
  const imgTitle = token.attrGet('title')

  const htmlOpts = {
    alt: imgAlt,
    loading: 'lazy',
    decoding: 'async'
  }

  const widths = [250, 316, 426, 460, 580, 768]
  const imgOpts = {
    widths: widths
      .concat(widths.map((w) => w * 2)) // generate 2x sizes
      .filter((v, i, s) => s.indexOf(v) === i), // dedupe
    formats: ['avif', 'webp', 'jpeg'],
    urlPath: '/assets/img/',
    outputDir: './_site/assets/img/'
  }

  Image(imgSrc, imgOpts)
  const metadata = Image.statsSync(imgSrc, imgOpts)
  const generated = Image.generateHTML(metadata, {
    sizes: '(max-width: 768px) 100vw, 768px',
    ...htmlOpts
  })

  if (imgTitle) {
    return figure(generatd, imgTitle)
  }
  return generated
}

Minor tweaks

Finally, we can add some minor tweaks to improve the experience.

Fixing image paths

When writing markdown, you may want to use the image source relative to the final URL, which might not be where the image exists on the filesystem. This makes sense when using the default renderer since the path is interpreted by the browser, but won’t work at build time, which is when the images are generated. We can fix this by patching the imgSrc before passing it to eleventy-image.

// ...
if (imgSrc.startsWith('/assets')) {
  imgSrc = 'src' + imgSrc
}
// ...

Passing more options

There might be certain situations where you want to pass a custom sizes attribute or even skip image processing altogether. We can define a custom title string syntax for passing in some additional data and then parse it with a regular expression. This is what I came up with:

@skip[widthxheight] ?[sizes] caption

This can be parsed with:

/^(?<skip>@skip(?:\[(?<width>\d+)x(?<height>\d+)\])? ?)?(?:\?\[(?<sizes>.*?)\] ?)?(?<caption>.*)/

Don’t worry if that’s confusing, regexes aren’t very readable 💀. At least we have tools like RegExr!

The code to handle these cases is below. When skipping image processing, you can manually pass in the image dimensions to prevent layout shift. I also decided to skip image processing when linking to an external image.

// ...

const parsed = (imgTitle || '').match(
  /^(?<skip>@skip(?:\[(?<width>\d+)x(?<height>\d+)\])? ?)?(?:\?\[(?<sizes>.*?)\] ?)?(?<caption>.*)/
).groups

if (parsed.skip || imgSrc.startsWith('http')) {
  const options = { ...htmlOpts }
  if (parsed.sizes) {
    options.sizes = parsed.sizes
  }

  const metadata = { jpeg: [{ url: imgSrc }] }
  if (parsed.width && parsed.height) {
    metadata.jpeg[0].width = parsed.width
    metadata.jpeg[0].height = parsed.height
  }

  const generated = Image.generateHTML(metadata, options)

  if (parsed.caption) {
    return figure(generated, parsed.caption)
  }
  return generated
}

// ...

const generated = Image.generateHTML(metadata, {
  sizes: parsed.sizes || '(max-width: 768px) 100vw, 768px',
  ...htmlOpts
})

// ...

Using with Eleventy

Now that we have a custom markdown renderer and markdown-it instance, we need to tell Eleventy to use it. If you’re not using Eleventy, you can use the markdown-it instance as a drop-in replacement.

// in .eleventy.js
module.exports = function (eleventyConfig) {
  // ...

  // either put the code here, or factor it out into another file
  const markdown = require('./utils/markdown')
  eleventyConfig.setLibrary('md', markdown)

  // ...
}

Final Code

// responsive images with 11ty image
// this overrides the default image renderer
// titles are also used for size setting
// ![alt text](/assets/img/image.jpg "title text")
// title text format:
// @skip[widthxheight] ?[sizes] caption
const Image = require('@11ty/eleventy-img')
markdown.renderer.rules.image = function (tokens, idx, options, env, self) {
  function figure(html, caption) {
    return `<figure>${html}<figcaption>${caption}</figcaption></figure>`
  }

  const token = tokens[idx]
  let imgSrc = token.attrGet('src')
  const imgAlt = token.content
  const imgTitle = token.attrGet('title')

  const htmlOpts = { alt: imgAlt, loading: 'lazy', decoding: 'async' }

  if (imgSrc.startsWith('/assets')) {
    imgSrc = 'src' + imgSrc
  }

  const parsed = (imgTitle || '').match(
    /^(?<skip>@skip(?:\[(?<width>\d+)x(?<height>\d+)\])? ?)?(?:\?\[(?<sizes>.*?)\] ?)?(?<caption>.*)/
  ).groups

  if (parsed.skip || imgSrc.startsWith('http')) {
    const options = { ...htmlOpts }
    if (parsed.sizes) {
      options.sizes = parsed.sizes
    }

    const metadata = { jpeg: [{ url: imgSrc }] }
    if (parsed.width && parsed.height) {
      metadata.jpeg[0].width = parsed.width
      metadata.jpeg[0].height = parsed.height
    }

    const generated = Image.generateHTML(metadata, options)

    if (parsed.caption) {
      return figure(generated, parsed.caption)
    }
    return generated
  }

  const widths = [250, 316, 426, 460, 580, 768]
  const imgOpts = {
    widths: widths
      .concat(widths.map((w) => w * 2)) // generate 2x sizes
      .filter((v, i, s) => s.indexOf(v) === i), // dedupe
    formats: ['webp', 'jpeg'], // TODO: add avif when support is good enough
    urlPath: '/assets/img/',
    outputDir: './_site/assets/img/'
  }

  Image(imgSrc, imgOpts)

  const metadata = Image.statsSync(imgSrc, imgOpts)

  const generated = Image.generateHTML(metadata, {
    sizes: parsed.sizes || '(max-width: 768px) 100vw, 768px',
    ...htmlOpts
  })

  if (parsed.caption) {
    return figure(generated, parsed.caption)
  }
  return generated
}

Acknowledgments

I posted a shortened version of this post as a StackOverflow answer last year, but haven’t gotten around to writing this post until now.

This Twitter thread was the original inspiration for this method.

PREV POST