Share images without a browser
How the build draws a share image for every note as SVG and turns it into a PNG, without a browser.
When I first shared this site on X, the preview was plain. The pages had no share image at all, so X had nothing to show. Now every page has one, and every note gets its own, with its title on it.
A first version with a browser
The quickest way to get an image was a headless browser. I wrote the card as a small HTML page, took a screenshot at 1200 × 630 and committed the PNG. That worked for one image, but it meant running a script by hand for every new note. The build on Vercel has no browser, so it couldn't do it for me.
The card as SVG
So the card is an SVG now, a string that the build puts together in TypeScript. It is just text and a few shapes, which suits the terminal look of the site: a dark window, a prompt and the title in large type. Each command line in that window comes from one small function:
const prompt = (y: number, command: string) =>
`<text x="${TEXT_X}" y="${y}" font-family="${MONO}" font-size="26" fill="#dfd7d4"><tspan fill="#c29bd3">~ $</tspan> ${esc(command)}</text>`;
It draws the purple ~ $ and then the command, at the height y. TEXT_X is the left edge of the text, MONO is JetBrains Mono, and esc escapes characters like & and <, so a title can't break the SVG. The site card uses it for whoami and ls, and a note card for cat notes/<slug>.md, with the title below it.
From SVG to PNG
The pages link to a PNG, so the build turns the SVG into one with resvg, an SVG renderer compiled to WebAssembly. That runs anywhere Bun runs, including Vercel's build, and it takes well under 100 milliseconds per image:
const resvg = new Resvg(svg, { font: { fontBuffers, loadSystemFonts: false, defaultFontFamily: SANS } });
const image = resvg.render();
fontBuffers are the two font files, loaded once for all cards, and loadSystemFonts: false means resvg uses only those. render() draws the pixels, and one more call turns them into PNG bytes that the build writes to dist/. The build renders the site card and one card per note, and the dev server renders the same cards on request. So a new note gets its image the moment it is merged, and there is nothing to remember.
Fonts and long titles
Two things about the title needed extra work: how thick it is and how long it is.
The site uses variable fonts, which keep every weight in one file and pick one with a number on the weight axis. resvg ignores that axis, so the title came out thin, not bold. The fix was the static bold cut of Space Grotesk, a separate file with only that weight, used only for these images.
Long titles are the other problem, and there is no browser to measure the text. So the card estimates. Space Grotesk bold is on average about 0.45 times the font size wide per character, and the code counts with 0.5 to keep some room. From that it knows how many characters fit in the 900 pixels of the title area and wraps the words. It tries 84 and 72 pixels with at most two lines, then 60 and 52 pixels with at most three. If the title still doesn't fit at 52, it keeps three lines and the last one ends with an ellipsis.
A new URL for a new title
X caches preview images. If I fix a typo in a title, the old image could stay around. That's why the file name of each note card contains a hash of the title, the date and the type:
const hash = Bun.hash(JSON.stringify([note.title, note.date, note.type])).toString(36).padStart(8, "0").slice(-8);
return `/og/notes/${note.slug}-${hash}.png`;
The build calls this function twice for each note, once for the og:image tag in the page and once for the file it writes, so the two always match. A changed title gives a new URL, and X has to fetch the new image. And because a URL never changes its content, Vercel sends these images with a cache time of one year.
All of this runs in the normal build. The whole site build takes about a second now, half a second more than before the share images.