Part of the jedee wiki — pages written by an AI (Claude Code), supervised by Johan.
Open Graph images
When a link is shared, the receiving platform reads the page’s <head> for Open Graph tags and draws a card from them. og:image is the picture on that card. It is fetched by a crawler that runs no JavaScript and does not wait, so the image has to be a real file at a real URL by the time the page is published — which makes it a build-time problem, not a page-time one.
The two common ways to produce one are a rendering service (a serverless function that draws the image on demand, usually with a headless browser) and build-time generation (draw every image while the site builds, ship them as static files). The second is cheaper to run and has no cold start, at the cost of doing the work up front and needing a rebuild whenever a title changes.
Eleventy Excellent takes a third, lighter path within the build-time approach: the image is an SVG template rendered by the site’s own template engine, then converted to JPEG. No headless browser, no canvas library — the “renderer” is Nunjucks writing text nodes into a 1200×630 <svg>, and the only real dependency is an image library that can rasterize it.
The catch that shapes the whole design is fonts. An SVG that says font-family="Source Serif 4" needs that font installed on the machine doing the rasterizing. A build server does not have it. Rather than ship font files and a text-layout stack, EE generates the images on the author’s machine and commits the JPEGs to the repository, so the build server only copies files it was handed.
The pipeline
Three pieces, plus a reset script.
src/common/og-images.njk paginates one post per page and writes an SVG:
pagination:
data: collections.article
size: 1
alias: post
permalink: '/assets/og-images/{{ post.data.title | slugify }}-preview.svg'The template does its own line breaking — a splitlines(22) filter chops the title into 22-character lines, and a chain of {% if %}s picks a vertical starting point from the resulting line count, so a one-line title starts at y=340 and a five-line title at y=170. That is text layout by lookup table, which is what you get without a text engine, and it is why the character count is the tuning knob.
src/_config/events/svg-to-jpeg.js runs as an Eleventy event after the build, reads the SVGs out of dist/assets/og-images/, and converts each one with @11ty/eleventy-img:
if (filename.endsWith('.svg') & !existsSync(path.join(ogImagesDir, outputFilename))) {The !existsSync guard is the whole caching strategy: an image that already exists is never regenerated. Note the direction — it reads from dist/ and writes into src/assets/og-images/, so build output is written back into the source tree, where it gets committed.
src/_includes/head/meta-info.njk references it, with a fallback:
content="{%- if layout == 'post' -%}
{{- meta.url -}}/assets/og-images/{{ title | slugify }}-preview.jpeg
{%- else -%}
{{- meta.url -}}{{- meta.opengraph_default -}}
{%- endif -%}"npm run clean:og deletes src/assets/og-images/ so the next build regenerates everything. It is the only way to refresh an image, because of that existsSync guard.
“The next build” means npm start, not npm run build. The event is registered inside if (process.env.ELEVENTY_RUN_MODE === 'serve'), so a production build never converts anything — which is the point (the build server has no fonts) but is easy to miss when a clean:og is followed by a build that reports success and writes no images.
In jedee
The pipeline itself is Eleventy Excellent stock, unmodified — template, event and script. The fallback image is not, as of 2026-09-09; that and the content around the pipeline are where the findings are.
The fallback was Eleventy Excellent’s own promo card
Until 2026-09-09 meta.opengraph_default still pointed at the starter’s shipped opengraph-default.jpg — a yellow 11ty card reading “ELEVENTY-EXCELLENT / Opinionated Eleventy 3.0 starter with CUBE CSS…”. Given the scoping below, that was the image on every note, photo, jam, reading entry and response post this site has ever shared: the great majority of its shareable pages advertised the starter rather than the site. It is the same class of leftover as the orphaned JPEGs, but louder, and nothing in the build could have flagged it — a valid image at a valid URL is all the pipeline checks.
The replacement is generated by src/_config/setup/generate-og-default.js (npm run og:default), a sibling of generate-favicons.js: name in Source Serif at the per-post card’s own 80px/700/−1 treatment, tagline in Source Sans, the accent-orange logomark as both a ground and the bottom-right signature beside the domain. The palette is the per-post template’s, so a shared page and a shared article read as one set.

Three things the generator does differently from the per-post template, all of them consequences of the traps below.
Chrome rasterizes it, not an SVG library, so the fonts are this repo’s own woff2 files and no installation is assumed. That inverts the dependency described further down rather than living with it.
The two faces are inlined as data: URIs. A first version pointed @font-face at file:// paths and the card came out silently set in Georgia and Arial — a setContent document has no file origin, so the requests fail and FontFace.status comes back 'error'. The card looked plausible enough that it was only caught by a check.
So the generator asserts both faces loaded and throws if not. This is the one place the silent-fallback failure can be made loud, and it earned its four lines the first time it ran.
The per-post template was aligned to match on the same day: Eleventy Excellent’s star came out for the site’s own mark at the same size, position and 10%, and the signature logomark went from the base-dark gray #bbbfca to the accent orange — the same value, and the same fix, as the breadcrumb logomark. Measured over the watermark, the 80px title reads 6.20:1 against 6.97:1 on bare paper, so the overlap on a long title costs nothing.
An XML comment may not contain a double hyphen, so a CSS custom property name cannot be written inside one. <!-- … --color-base-dark … --> in the SVG template made every card fail to rasterize with Input file has corrupt header: XML parse error … Comment must not contain '--'. The template is Nunjucks but the output is XML, and the comment syntax belongs to the output.
Not meta.domain. That is derived from meta.url, which is http://localhost:8080 unless the environment sets URL — so the first card was stamped localhost. author.website is the hardcoded canonical and is what the signature reads. Anything generated on a laptop that wants the live domain has to reach past meta.url for it.
Right now the fallback is the card for the entire site. Every one of the seven files in src/posts/articles/ is draft: true, so collections.article holds two posts and neither is published. Zero pages on the live site use a generated card. That is a content accident rather than a design one, and it will unwind itself the moment an article ships — but it is worth knowing when weighing how much the fallback matters.
Only articles get a generated one. Both the generator (data: collections.article) and the reference (layout == 'post') are scoped to articles, and articles is the only post type whose data file sets layout: post — every other type has its own layout name (note, photo, audio, activity, bookmark, and so on). On vanilla EE, where articles and notes are nearly the whole site, that is a small gap; on a site with sixteen post types it means the generated-image feature covers a small minority of the pages people actually share. Widening it by generating more cards is not a template edit — it needs a second pagination source and a different condition in the head. See Anatomy of a post type for the other places a type has to be wired, and One JSON-LD envelope for sixteen types for the sibling problem solved the other way round.
Artwork beats a generated card, where a type has artwork
Photo, jam, watching and reading were widened on 2026-09-09: a shared photo previews as the photograph, and a shared record, film or book as a card built around its cover. That is 140 pages, and the head now picks from four sources, most specific first — the composited card, then the post’s own artwork, then Eleventy Excellent’s generated title card, then the site default.
For photo the artwork is the whole card, and nothing is generated at all. That is the cheap end of this and worth preferring wherever it works: no committed JPEG, nothing to strand on a retitle, no existsSync guard to clear, no font dependency. src/_config/utils/og-image.js takes either shape of source — a photo’s local ./src/… path or a remote cover URL — and returns {url, width, height} for a 1200px JPEG, or null to fall through. It is computed data, not a filter: Nunjucks cannot await an async filter inside the {% if %} that picks the tag’s value, and a <meta> needs a plain string. It is deliberately not shared with coverZoom, which computes the same artwork for the lightbox — that one returns null below 448px, a rule about whether zooming is worth it that has nothing to say about a social card.
For jam, watching and reading the bare cover was correct but small — a square album cover or a portrait poster is a stamp in a feed built for a wide card. src/_config/setup/generate-og-cards.js (npm run og:cards [type] [slug…]) draws the composited version instead — art on the left, an orange eyebrow naming the kind, title and credit on the right, the signature mark bottom-right. This one does commit a file per post: 138 of them, ~12 MB, generated on the laptop and carrying every cost in the list. That was a deliberate trade for how much better a wide card reads, made after looking at both side by side, not a default.
The three share one generator rather than three copies, because only two things differ: the eyebrow, and which field becomes the line under the title (artist / director and year / author). A TYPES table holds both.
The art is sized by HEIGHT, not width. An album cover is square but a film poster and a book cover are portrait, so the fixed 438×438 box the first version used letterboxed both. height: 438px; width: auto puts a square at 438×438 and a 2:3 poster at 292×438, and every type sits in the same band with the text column simply gaining room on the portrait ones.
The output folder keys off the post’s category, not its posts folder — og-images/jam/, not jams/. The two differ for exactly this type (category: jam, src/posts/jams/), which is the same asymmetry the URL has.
src/_config/utils/og-image.js takes either shape of source — a photo’s local ./src/… path or a jam’s remote cover URL — and returns {url, width, height} for a 1200px JPEG, or null when there is nothing usable, in which case the head falls through to the generated card and then to the default. It is computed data, not a filter: Nunjucks cannot await an async filter inside the {% if %} that picks the tag’s value, and a <meta> needs a plain string. It is deliberately not shared with coverZoom, which computes the same artwork for the lightbox — that one returns null below 448px, a rule about whether zooming is worth it that has nothing to say about a social card.
og:image:width and og:image:height were hardcoded to 1200×630 for every page on the site, generated card or not. That was already wrong for anything non-landscape, and it mattered more on this theme than most, because for a platform reading no twitter:card the declared ratio is the only thing saying whether to draw a wide card or a small square one. They now follow the actual file.
The one Twitter tag that had to come back
Eleventy Excellent removed the Twitter-specific tags in v2, and mostly that is right: X reads the og: tags for title, description and image, so duplicating them buys nothing. But it does not read them for two things, and a social-preview debugger flags the first as a missing required tag.
twitter:card picks the layout. Without it X draws the small square summary card — the exact outcome the 1200×630 cards exist to avoid, so the whole composited-card effort was invisible on that one platform. It is emitted as summary_large_image, dropping to summary only for a post with artwork but no composited card whose artwork is square or portrait.
The threshold for that is 1.2, and the first guess of 1.6 was wrong. X crops a large card to 2:1, so the question is not “is this image wide” but “would that crop cut the subject away”. At 1.6 the 4:3 Asturian valley photo fell to the small card — a photograph, on a post where the photograph is the content, shown as a thumbnail beside text. A 4:3 frame cropped to 2:1 still reads; a square album cover cropped to 2:1 loses its top and bottom. 1.2 puts the photograph on the large card and the bare cover on the small one. Worth catching by counting the rendered output rather than reasoning about it — one page in 498 was on the wrong card, and nothing would have reported it.
twitter:image:alt is the other. X does not read og:image:alt, so without it a screen-reader user on X gets nothing. The alt is hoisted into an ogImageAlt variable — the same idiom the file already uses for metaDescription — rather than repeating its four branches in two tags.
An Apple Music cover URL ending 1200x630bb is not a 1200×630 image. That segment is a bounding box, and the art is square, so what comes back is 630×630 — which is exactly the case the hardcoded dimensions were lying about. Read the file, don’t read the URL.
The composited cards are generated but clean:og does not regenerate them. npm run clean:og rimrafs all of src/assets/og-images/, which now includes jam/, watching/ and reading/ — and the npm start that follows only rebuilds the article SVG cards. After a clean:og, run npm run og:cards too or all 138 silently drop back to their bare cover.
⚠️ A composited card goes stale in silence. Nothing re-runs the generator, so changing a cover URL or a title leaves the old card in place — and a retitled post is worse, because the slug moves and ogCard stops finding the file, dropping it to the bare cover with no error. Re-run npm run og:cards after either.
The generator uses the site’s own slugifyString, not a copy of it. The filename is the key on both sides — the head builds the same slug the permalink does — and a hand-rolled mirror drifts the moment a title contains a colon or an ampersand, which several jam titles do. This is the same class of coupling as the article cards’ {{ title | slugify }}, just with the failure made visible: a mismatch here falls back rather than 404s.
The cover is inlined as a data: URI, like the fonts. A setContent document has no origin, so a file:// or remote src fails silently and ships a card with a blank square where the art should be. The run asserts both faces and the artwork actually loaded before it shoots, and skips a jam whose cover is unreachable rather than aborting the other 119.
Chrome doing the rendering buys real line-breaking. The title is clamped to three lines by CSS, so the longest jam title in the set — 53 characters — wraps and fits, where the SVG template would have needed another entry in its splitlines(22) lookup table.
eleventy-img’s reported height floors where sharp rounds, so a source with a non-integer ratio declares one pixel short of the file it serves — the panorama reports 1200×428 for a 1200×429 JPEG. Harmless on a preview card, but the same numbers would be wrong for layout, and the metadata is the only thing available at computed-data time because a deferred image is not on disk yet to measure.
og:description was the other half of the fix. It fell through to the site-wide “Personal site of Johan Edlund” — true, and useless beside a picture of a record. autoDescription (in src/_config/utils/og-card.js) composes the line the card itself cannot show, per type, from fields the posts already carry, and sits below an authored description: in the head’s chain so it never overrides one:
- jam — artist, year, genres.
Solacebecomes “Jakob (2006) · rock, post-rock, instrumental”. Fires often: only 6 of 120 carry a description. The album is dropped when it merely repeats the title, which is the usual case. - watching — the
plotfield, which all ten already have and which beats anything composable. - reading — “A book by Selma Lagerlöf”. The author’s name on its own read as a caption rather than a sentence, which is why the prefix is there.
Worth knowing before adding a fourth: the fallback fires rarely outside jams. 7 of 8 reading posts and 8 of 10 watching posts already carry an authored description. Any type can opt in by computing an autoDescription.
Coverage after this: 120 jams, 10 films, 8 books and 2 photos carry their own card, /jams/genres/ correctly still takes the default, and nothing falls through — every post of all four types has a cover. What is left, in the order the artwork makes easy: notes (22), then video (3) on the self-hosted YouTube poster, and recipe (1). Activities (180) and the five response types (60) are deliberately left on the default — nobody shares an activity, and for a response the platform’s preview of the target page is the more useful card. Notes need the generated card, not artwork, and are straightforward: every post of every type carries a title: in front matter, all sixteen types and 416 posts, so a note card is the article card with a second pagination source.
Nothing ever deletes an image. The existsSync guard only ever adds, so a retitled or deleted article leaves its JPEG behind — committed, shipped in the build, referenced by nothing. When this page was written the folder held 17 JPEGs against 13 article files, seven of them belonging to Eleventy Excellent demo articles long since deleted. A second clean:og on 2026-09-09, forced by the template change below, took it to 2 JPEGs against a 2-post collections.article — no orphans at all, the four that went being two Eleventy Excellent demo articles, a-draft, and the what-is-web-accessibility orphan this page had been carrying. The mechanism is unchanged; only the backlog was cleared, and it will accumulate again.
The filename is derived from the title, so retitling strands the old image. {{ title | slugify }} is the key on both sides. Change a published title and the next build writes a new JPEG under the new slug while the old one stays committed forever; the page itself is fine, because the head is computed from the same title. This is the same shape as the deleted-article orphans, and clean:og is the answer to both.
The font dependency is silent. The SVG names Source Serif 4 and Source Sans 3, and the front matter says so in a comment. A machine without them rasterizes the title in whatever the fallback is, producing a valid JPEG that is simply wrong — and once written, the existsSync guard means it is never retried. Worth an eye on the output after any font change; the same “generated set that is committed rather than rebuilt” arrangement as Favicons, with the same consequence that a bad generation persists until deliberately cleared.
One code observation, EE stock and not currently causing trouble: the conversion loop is files.forEach(async function …), so the await inside it does not hold up the enclosing svgToJpeg(), which resolves before the images are written. It works because the Eleventy process outlives the event, but it is not a loop that can be relied on to have finished when it returns.
Raw source: src/_raw/dev-notes/How the Open Graph images are generated.md