This article's generated OpenGraph image
[OpenGraph](https://ogp.me/) 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:
```html
```

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.io[^crates_inspiration]'s new OpenGraph embed images to use a [https://typst.app/](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 frontmatter[^toml_parse] from articles and then passes it to a typst template, and outputs a 1200×630[^og_size] image.
```typ
#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](https://github.com/LunNova/Blog/blob/main/og-template/template.typ)
The template I settled on renders title, description, tags and date over a cropped starfield background. Crop coordinates are deterministic[^hash_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.
```bash
# … 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"
```
```console
./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.
[^crates_inspiration]: [Add OpenGraph image generation crate](https://github.com/rust-lang/crates.io/pull/11436) by Tobias Bieniek ([@Turbo87](https://github.com/Turbo87))