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:
widths
: the widths in pixels of the images you want to generate. You’ll want a balance between enough to have beneficial size reductions, but not too many that it’ll take forever to generate. Honestly, I probably have too much. I’m also multiplying each size by 2 to get higher resolution images for high-dpi displays.formats
: the output image formats. JPEG is widely supported, but WebP and AVIF offer better file sizes. AVIF has very little browser support, so you might not want to generate those to save time.sizes
: thesizes
attribute on the<img>
tag (inside<picture>
). This hints to the browser what size the image will be. It doesn’t need to be precise since it (most likely) won’t affect the actual image size, but it should ideally be pretty close. On my site, the max-width is 768px but spans the full width on smaller screens.
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.