skip to content
dz
4 min read

Generating images for Eleventy with Typst

Auf Deutsch lesen →

Social images, podcast covers, chapter images: on this site and for my podcast Ready for Review, I used to generate all of them at build time with Satori. For Ready for Review that added up to 784 images, and the build spent around 140 seconds on them alone. That was too slow for me, and I disliked it for two other reasons: Satori turns HTML into an SVG and then into a PNG, and small bugs kept creeping in along the way, most recently in the current part of my NixOS series, whose social image suddenly said Let's Encrypt. With Typst it now takes a sixth of the time, and the result is an Eleventy plugin.

I got the idea from Philipp's blog (in German), where I ran into Typst again, this time used to generate social images. Typst is a typesetting system similar to LaTeX. It handles line breaks and hyphenation on its own, which is exactly the hard part when a long title has to fit on a fixed canvas. Instead of translating HTML into an image, I now use a tool built for typesetting. I tried it on Ready for Review first, where the 784 images are now done after just over 23 seconds. Visually there is (almost) no difference.

It also took less code and fewer steps than Satori did. I published the code on npm as eleventy-plugin-typst-images so I could use it here on the blog too. At first it only had to work for me. Since it might be useful to others as well, I spent the last few days reworking it to fit into other setups and hooking it up to Eleventy Image. To be honest, part of that only came about while I was writing this post: you can now reference Typst files from Markdown or embed Typst code directly as an image.

Adding social images

Social images are one possible use. If you want a setup similar to mine, you need Eleventy 3 and Node 22 or later. The plugin ships with Typst. Install it with the package manager of your choice (pnpm, obviously):

pnpm add eleventy-plugin-typst-images

In your Eleventy config, register the image generation and the template for the social images:

import typst from 'eleventy-plugin-typst-images/core';
import socialImages from 'eleventy-plugin-typst-images/social';

export default function (eleventyConfig) {
	eleventyConfig.addPlugin(typst, { fonts: 'fonts' });
	eleventyConfig.addPlugin(socialImages, {
		template: '_typst/og.typ',
		siteUrl: 'https://example.org'
	});
}

Social images are 1200 × 630 pixels by default. You create the layout in _typst/og.typ:

#set page(width: 1200pt, height: 630pt, margin: 60pt,
  fill: rgb("#faf8f5"))
#set text(font: "Source Serif 4", weight: "bold", lang: "en")

#align(horizon, text(size: 64pt, sys.inputs.at("title")))
#align(bottom, text(size: 28pt)[zenzes.me])

A few notes on the file: the font files for "Source Serif 4" go into the fonts folder (see the config). In the template, 1 pt equals one pixel, lang: "en" turns on English hyphenation, and the page title arrives through sys.inputs.at("title").

In your layout's <head>, all you need is:

{% ogImage %}

One line in the layout, and every post gets its own social image at build time. The shortcode also writes the Open Graph and Twitter meta tags, including the absolute image URL. For an example title it looks like this:

Simple social image with the title My first social image with Typst

You pass more values as an object, for example {% ogImage { title: title, teaser: teaser } %}, and read them in the template with something like sys.inputs.at("teaser", default: "").

Podcast covers and other images

The covers of my other podcast, "Herr Zenzes wills wissen", also come from a Typst template, just without a shortcode: before the build, a small script goes through the episodes and renders a cover for each one that doesn't have one yet. The plugin exports createRenderer for this:

import { createRenderer } from 'eleventy-plugin-typst-images';

const { render } = createRenderer(typstOptions);
const png = await render({
	format: 'episode',
	data: { title, number }
});

episode is a format from the config with its own template and a fixed size. The result ends up as cover.png next to the episode. The template picks the font size itself so that long titles fit as well. That used to take me around 100 lines of JavaScript:

Podcast cover for HMww37: Was gibt es neben diesem Angular? Moderne Webentwicklung mit HTML und CSS

Single images also work directly in a post: typstFile embeds a Typst file and passes the data along. That's how the cover above got into this article:

{% typstFile "/_typst/cover.typ", "Cover for " + title, {
  data: { title: title, number: number }
} %}

A path starting with / resolves from the project root, ./cover.typ sits next to the post. The shortcode generates the <img> including alt text and dimensions.

A small image doesn't need its own file. The typst shortcode renders the code between its opening and closing tags directly, for example a chart with the numbers from above:

{% typst "Satori 140 seconds, Typst 23 seconds" %}
#set page(fill: rgb("#faf8f5"), margin: 48pt)
#set text(font: "Source Serif 4", size: 44pt)
#let bar(name, sec, color) = (
  name,
  box(width: sec * 6pt, height: 68pt, fill: color,
    radius: 8pt, inset: 16pt,
    align(right + horizon, text(fill: white)[#sec s])),
)
#grid(columns: 2, gutter: 28pt, align: horizon,
  ..bar("Satori", 140, rgb("#78716c")),
  ..bar("Typst", 23, rgb("#953fa6")),
)
{% endtypst %}
Bar chart: Satori takes 140 seconds for the 784 images, Typst 23 seconds

Responsive images with Eleventy Image

On this blog, Eleventy Image (@11ty/eleventy-img) resizes all images and serves suitable sizes, including the Typst images in this article. For that, the Typst plugin gets eleventyImage: true in its config, and the Image plugin is registered as usual:

import typst from 'eleventy-plugin-typst-images/core';
import { eleventyImageTransformPlugin } from '@11ty/eleventy-img';

export default function (eleventyConfig) {
	eleventyConfig.addPlugin(typst, {
		fonts: 'fonts',
		eleventyImage: true
	});
	eleventyConfig.addPlugin(eleventyImageTransformPlugin, {
		widths: [400, 800, 1400],
		formats: ['webp', 'png'],
		htmlOptions: {
			imgAttributes: { loading: 'lazy', decoding: 'async' }
		}
	});
}

Typst then provides the source image, and Eleventy Image turns it into a <picture> with srcset. The social images in the meta tags are not affected. You pass attributes like sizes in the call, for a full-width image like this:

{% typstFile "/_typst/cover.typ", "Cover for " + title, {
  data: { title: title, number: number },
  attributes: { sizes: "100vw" }
} %}

Only what changed gets rendered again. The plugin stores the images in .cache/typst-images/ and copies them into the output folder from there. If the title or the template changes, so does the file name; otherwise Typst leaves the image alone. If your CI caches the folder between builds, it skips the rendering as well.

The plugin now generates a lot of images for me, and maybe it helps others too. Options and examples are in the docs on Codeberg, the package is on npm. Feedback is welcome on Mastodon or as an issue on Codeberg.