Skip to content

Build-time og:image generation with eleventy-img

I’ve started incorporating sample PNGs to illustrate typefaces in the Atlas of Type, and secretly hoped they’d work as OpenGraph previews just as well. Turns out tightly cropped, black-on-transparent images like the one below were not on most platforms’ og:image bingo cards:

A two-line preview of the Betània Patmos typeface, rendered in two lines in black against a transparent background

The sample PNG for the recently-released Betània Patmos, emphasised with a 1px border.

The results were uniformly atrocious. To render well, the samples need a solid background and some padding:

A better og image card shows the typeface against a white background with some padding applied.

As the Atlas is a 100% static Eleventy website, these preview images need to be computed beforehand, ideally without manual involvement. A scan of the docs for the trusty eleventy-img plugin revealed that the 6.x release last week coincidentally added access to sharp, the underlying image processing library, so compositing the sample PNGs onto a white canvas was no big deal.

This is the .eleventy.js config to define an og_image shortcode that takes an image from static/img/typeface/ and generates a corresponding 1200×800 preview image in static/img/card/:

import path from 'node:path';
import ImagePlugin from '@11ty/eleventy-img';

const TARGET_IMG_WIDTH = 1200;
const TARGET_IMG_HEIGHT = 800;

config.addShortcode('og_image', async function(src) {
	const inputPath = path.join('static', src);
	const img = await ImagePlugin(inputPath, {
		formats: ['png'],
		urlPath: '/img/card',
		outputDir: 'static/img/card',
		transform: async sharp => {
			const metadata = await sharp.metadata();
			const pad_width = (TARGET_IMG_WIDTH - metadata.width) / 2;
			const pad_height = (TARGET_IMG_HEIGHT - metadata.height) / 2;
			sharp
				.flatten({ background: 'white' })
				.extend({
					top: Math.floor(pad_height),
					bottom: Math.ceil(pad_height),
					left: Math.floor(pad_width),
					right: Math.ceil(pad_width),
					background: 'white'
				});
		},
		// disable hashing, return original file name.
		filenameFormat: function (id, src) {
			return path.relative('static/img', src);
		}
	});
	return img.png[0].url;
});

Note: I’m probably losing some performance by disabling the built-in hashing, but I wanted predictable URLs for the og:images. If you don’t care about that, remove the custom filenameFormat() function.

Usage in a Nunjucks template, and the resulting markup:


<!-- template -->
<meta 
	property="og:image" 
	content="{{ site.url }}{% og_image '/img/typeface/my-sample.png' %}"
>

<!-- result -->
<meta 
	property="og:image" 
	content="https://type-atlas.xyz/img/card/typeface/my-sample.png"
>

Addendum to point out a small gotcha. Because the og:image cards are put back in the static/ folder, which itself gets duplicated via Passthrough File Copy into the output folder, the first build with new source images won’t see the freshly generated cards. Eleventy 2.x added the ability to skip the actual copying in serving mode, and opt into serving the files from their original locations. This seems like a generally good addition to your Eleventy config:

config.setServerPassthroughCopyBehavior("passthrough");

This is for local development. As for deployment, make sure at least one prior build has run so cards for new images have a chance to get generated.