Bilder für Eleventy mit Typst generieren
Social Images, Podcast-Cover, Chapter Images: Das alles habe ich auf dieser Seite und bei meinem Podcast Ready for Review bisher beim Build mit Satori generieren lassen. Bei Ready for Review waren das zuletzt 784 Bilder, und allein dafür brauchte der Build rund 140 Sekunden. Das dauerte mir zu lange, und auch sonst gefiel mir das aus zwei Gründen nicht: Satori macht aus HTML über ein SVG ein PNG, und dabei gab es immer wieder kleine Bugs, zuletzt beim aktuellen Teil meiner NixOS-Reihe, in dessen Social Image plötzlich Let's Encrypt stand. Mit Typst dauert das jetzt nur noch ein Sechstel der Zeit, und daraus ist ein Eleventy-Plugin geworden.
Die Idee kam mir über Philipps Blog, wo ich wieder auf Typst gestoßen bin und auf den Use Case, damit Social Images zu generieren. Typst ist ein Textsatzsystem ähnlich wie LaTeX und kümmert sich selbst um Zeilenumbrüche und Silbentrennung, also um genau das, was bei einem langen Titel auf einer festen Fläche schwierig ist. Statt HTML in ein Bild zu übersetzen, setze ich damit ein Werkzeug ein, das für Satz gemacht ist. Ausprobiert habe ich es zuerst bei Ready for Review, wo die 784 Bilder jetzt nach gut 23 Sekunden fertig sind. Optisch gibt es (fast) keinen Unterschied.
Dafür brauchte ich weniger Code und weniger Schritte als mit Satori. Den Code habe ich dann als eleventy-plugin-typst-images auf npm veröffentlicht, damit ich ihn auch hier im Blog einsetzen kann. Zuerst ging es nur darum, dass ich damit arbeiten kann. Weil das Plugin aber auch für andere interessant sein könnte, habe ich es in den letzten Tagen so umgebaut, dass es sich in andere Setups einfügt, und Eleventy Image angebunden. Ehrlicherweise ist ein Teil davon erst beim Schreiben dieses Posts entstanden: Ihr könnt jetzt auch in Markdown Typst-Dateien referenzieren oder Typst-Code direkt als Bild einbinden.
Social Images einbauen
Ein möglicher Einsatzzweck sind Social Images. Wenn ihr hier ein ähnliches Setup haben wollt wie ich, braucht ihr Eleventy in Version 3 und Node in Version 22 oder später. Typst liefert das Plugin mit. Die Installation läuft über den Package Manager eurer Wahl (natürlich pnpm, was sonst):
pnpm add eleventy-plugin-typst-imagesIn der Eleventy-Config registriert ihr die Bildgenerierung und das Template für die 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 sind standardmäßig 1200 × 630 Pixel groß. Das Layout erstellt ihr in _typst/og.typ:
#set page(width: 1200pt, height: 630pt, margin: 60pt,
fill: rgb("#faf8f5"))
#set text(font: "Source Serif 4", weight: "bold", lang: "de")
#align(horizon, text(size: 64pt, sys.inputs.at("title")))
#align(bottom, text(size: 28pt)[zenzes.me])Ein paar Hinweise zu der Datei: Die Schriftdateien für „Source Serif 4“ gehören in den Ordner fonts (siehe Config). Im Template entspricht 1 pt einem Pixel, lang: "de" schaltet die deutsche Silbentrennung ein, und über sys.inputs.at("title") kommt der Seitentitel an.
Im <head> eures Layouts reicht dann ein:
{% ogImage %}Eine Zeile im Layout, und jeder Beitrag bekommt beim Build sein eigenes Social Image. Die Meta-Tags für Open Graph und Twitter samt absoluter Bild-URL schreibt der Shortcode gleich mit. Für einen Beispieltitel sieht das so aus:

Weitere Werte übergebt ihr als Objekt, etwa {% ogImage { title: title, teaser: teaser } %}, und lest sie dann im Template beispielsweise mit sys.inputs.at("teaser", default: "") aus.
Podcast-Cover und andere Bilder
Die Cover meines anderen Podcasts „Herr Zenzes wills wissen“ entstehen ebenfalls aus einem Typst-Template, nur ohne Shortcode: Vor dem Build geht ein kleines Skript die Episoden durch und rendert für jede ohne Cover eines. Dafür exportiert das Plugin createRenderer:
import { createRenderer } from 'eleventy-plugin-typst-images';
const { render } = createRenderer(typstOptions);
const png = await render({
format: 'episode',
data: { title, number }
});episode ist ein Format aus der Config mit eigenem Template und fester Größe. Das Ergebnis landet als cover.png neben der Episode. Das Template wählt die Schriftgröße selbst, damit auch lange Titel hineinpassen. Dafür hatte ich vorher rund 100 Zeilen JavaScript geschrieben:

Einzelne Bilder gehen auch direkt im Beitrag: Mit typstFile bindet ihr eine Typst-Datei ein und übergebt dabei die Daten. So ist das Cover oben in diesen Artikel gekommen:
{% typstFile "/_typst/cover.typ", "Cover zu " + title, {
data: { title: title, number: number }
} %}Ein Pfad mit / beginnt am Projektroot, ./cover.typ liegt neben dem Beitrag. Der Shortcode erzeugt das <img> samt Alt-Text und Abmessungen.
Für ein kleines Bild lohnt sich keine eigene Datei. Der typst-Shortcode rendert den Code zwischen Anfang und Ende direkt, zum Beispiel ein Diagramm mit den Zahlen von oben:
{% typst "Satori 140 Sekunden, Typst 23 Sekunden" %}
#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 %}
Responsive Bilder mit Eleventy Image
Hier im Blog verkleinert Eleventy Image (@11ty/eleventy-img) alle Bilder und liefert passende Größen aus, auch die Typst-Bilder in diesem Artikel. Dafür bekommt das Typst-Plugin in der Konfiguration eleventyImage: true, das Image-Plugin wird wie gewohnt registriert:
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 liefert dann das Ausgangsbild, und Eleventy Image baut daraus ein <picture> mit srcset. Die Social Images in den Meta-Tags bleiben davon unberührt. Attribute wie sizes gebt ihr beim Aufruf mit, bei einem Bild über die volle Breite etwa so:
{% typstFile "/_typst/cover.typ", "Cover zu " + title, {
data: { title: title, number: number },
attributes: { sizes: "100vw" }
} %}Neu gerendert wird nur, was sich geändert hat. Das Plugin legt die Bilder in .cache/typst-images/ ab und kopiert sie von dort ins Ausgabeverzeichnis. Ändern sich Titel oder Template, ändert sich auch der Dateiname, sonst fasst Typst das Bild nicht an. Cacht die CI den Ordner zwischen den Builds, spart sie sich das Rendern ebenfalls.
Bei mir erzeugt das Plugin inzwischen sehr viele Bilder, und vielleicht hilft es auch anderen. Optionen und Beispiele stehen in der Doku auf Codeberg, das Paket gibt es auf npm. Über Feedback auf Mastodon oder als Issue auf Codeberg freue ich mich.