Generating personalized OpenGraph embed images with typst

Let's spruce up those social embeds!
by Luna Nova

Generated OpenGraph image for this article

This article's generated OpenGraph image

OpenGraph meta tags allow customizing how a site's link previews display when embedded elsewhere. Originally popularized by facebook, OpenGraph data is now used by most social media, chat apps, and read-it-later services like Instapaper.

Let's take a look at how a recent tc39.es proposal's opengraph meta tags are implemented and see it in action:

<!-- https://tc39.es/proposal-type-annotations/ -->
<meta property="og:title" content="TC39 Proposal: Types as Comments"/>
<meta property="og:type" content="article"/>
<meta property="og:image:type" content="image/png"/>
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="630">
<meta property="og:image" content="https://confident-galileo-43471d.netlify.app/assets/og-image.png">

TC39: Types as Comments proposal embed

deer.social view of social media post by Jake Lazaroff (@jakelazaroff.com) saying "amazing! now we just need it in the browser 😈".
The post includes a preview card for TC39 Proposal "Types as Comments" which would allow TypeScript-like type annotations to be ignored inside JavaScript runtimes. The preview shows example code: const message: string = "Hello, types"; console.log(message)

I was inspired by crates.iocrates_inspiration's new OpenGraph embed images to use a https://typst.app/ template for lunnova.dev's preview cards.
We don't need a production grade crate like crates.io; a quick shell script will suffice.

A bash script extracts TOML frontmattertoml_parse from articles and then passes it to a typst template, and outputs a 1200×630og_size image.

#let data = json(bytes(sys.inputs.data))
#set page(width: 1200pt, height: 630pt, margin: 0pt)
#set text(font: "CaskaydiaCove NF", fill: white)

// Cropped background - each article gets unique viewport into 1515×2154 bg
#place(left + top, block(width: 1200pt, height: 630pt, clip: true, {
  place(left + top, dx: -data.crop_x * 1pt, dy: -data.crop_y * 1pt,
    image("bg-full.png", width: 1515pt, height: 2154pt))
}))

// Title and description
#place(left + top, dx: 45pt, dy: 45pt, rect(
  fill: rgb(0,0,0,65%), radius: 8pt, inset: 30pt, {
    text(size: 64pt, weight: "bold", data.title)
    v(20pt)
    if data.at("description", default: none) != none {
      text(size: 36pt, fill: rgb(220,220,220), data.description)
    }
  }
))

// Tags, date, branding
#place(bottom + left, dx: 45pt, dy: -45pt, stack(dir: ttb, spacing: 10pt, {
  for (i, tag) in data.tags.enumerate() {
    if i > 0 { h(8pt) }
    box(fill: rgb(180,180,220,30%), radius: 4pt, inset: (x: 8pt, y: 8pt),
      text(size: 36pt, fill: rgb(200,200,230), "#" + tag))
  }
}, {
  stack(dir: ltr,
    text(size: 48pt, data.date), h(1fr),
    text(size: 48pt, weight: "semibold", "lunnova.dev"))
}))

Full template: og-template/template.typ

The template I settled on renders title, description, tags and date over a cropped starfield background. Crop coordinates are deterministichash_crop based on the article path's hash, giving each post a unique view of the same background if I regenerate without storing per-article state. Honestly that's overengineering and it really doesn't matter if those move on regen but eh, it's fun.

# … eliding details of extracting front matter
# full impl is available at https://github.com/LunNova/Blog/

DATA_JSON=$(jq -n \
    --arg title "$TITLE" \
    --arg description "$DESCRIPTION" \
    --arg date "$DATE" \
    --argjson tags "$TAGS" \
    --argjson crop_x "$CROP_X" \
    --argjson crop_y "$CROP_Y" \
    '{title: $title, description: $description, date: $date, tags: $tags, crop_x: $crop_x, crop_y: $crop_y}')

echo "Generating: '$TITLE' → $OUTPUT_PATH"
set -x

pushd "$TEMPLATE_DIR" >/dev/null
TEMP_2X=$(mktemp --suffix=.png)
trap "rm -f $TEMP_2X" EXIT

typst compile --format png --input "data=$DATA_JSON" template.typ "$TEMP_2X"
popd >/dev/null
magick "$TEMP_2X" -resize 1200x630 -quality 95 "$OUTPUT_PATH"
optipng -quiet -strip all "$OUTPUT_PATH"
./generate-og-image.sh content/articles/typst-opengraph-embed/index.md
Generating: 'Generating personalized OpenGraph embed images with typst' → content/articles/typst-opengraph-embed/og-image.png
+ pushd /home/lun/sync/dev/lun/Blog/og-template
++ mktemp --suffix=.png
+ TEMP_2X=/tmp/nix-shell.ijbkuM/nix-shell-2799969-130597325/tmp.Wk2f9FYuzX.png
+ trap 'rm -f /tmp/nix-shell.ijbkuM/nix-shell-2799969-130597325/tmp.Wk2f9FYuzX.png' EXIT
+ typst compile --format png --input $'data={\n  "title": "Generating personalized OpenGraph embed images with typst",\n  "description": "Let\'s spruce up those social embeds!",\n  "date": "2025-11-09",\n  "tags": [\n    "lunnova.dev-meta"\n  ],\n  "crop_x": 2,\n  "crop_y": 406\n}' template.typ /tmp/nix-shell.ijbkuM/nix-shell-2799969-130597325/tmp.Wk2f9FYuzX.png
+ popd
+ magick /tmp/nix-shell.ijbkuM/nix-shell-2799969-130597325/tmp.Wk2f9FYuzX.png -resize 1200x630 -quality 95 content/articles/typst-opengraph-embed/og-image.png
+ optipng -quiet -strip all content/articles/typst-opengraph-embed/og-image.png
+ rm -f /tmp/nix-shell.ijbkuM/nix-shell-2799969-130597325/tmp.Wk2f9FYuzX.png
toml_parse

I ended up using a tiny python script to load with tomllib and dump as json because yj-go didn't like my use of the TOML date type in front matter

og_size

OpenGraph images are typically 1200×630px (1.91:1 ratio). Generated at 2x resolution (2400×1260) then downscaled with ImageMagick for better font rendering, optimized with optipng. Typically around 200KiB, YMMV based on how well your template compresses.

hash_crop

sha256sum of article path → extract hex digits → modulo by max offset. Same path always gives same crop position. Background is 1515×2154px, crops to 1200×630px, allowing 315px horizontal and 1524px vertical variance.


Cite as BibTeX
@misc{typst-opengraph-embed,
    author = {Luna Nova},
    title = {Generating personalized OpenGraph embed images with typst},
    year = {2025},
    url = {https://lunnova.dev/articles/typst-opengraph-embed/},
    urldate = {2025-11-09}
}

tagged