Newshot
Account

Open Graph images with Astro and Typst

Original text(via Lobsters)

Open Graph images with Astro and Typst

Links to blog articles look better when they’re accompanied by an image. If the HTML head has a meta tag like this, it will happen automatically:


        <meta
  property="og:image"
  content="https://eaj.no/posts/graph-images-with-typst/og.png"
/>
      

My previous, tedious workflow for these kinds of images was to go to unspash.com, search for something like structure or archive, and then select an image I thought would fit the theme of the post. Being both boring and error-prone, I abandonded that process and set up a pipeline that generates image cards at build-time instead. They look like this:

The generated OpenGraph image for this blog post
The generated OpenGraph image for this blog post

Let me show you how I did it.

Typst

Typst is a tool that generates high-quality documents from a simple markup language. A lovechild of LaTeX and Markdown, it works great for math whilst also keeping the day-to-day text formatting syntax bearable. As a small taste, this snippet (adapted from the Typst README)


        #set page(width: 10cm, height: auto, margin: 1cm)
#set heading(numbering: "I.")

#let fib_1 = 1
#let fib_2 = 1

= The Fibonacci sequence
The Fibonacci sequence is defined through the
recurrence relation $F_n = F_(n-1) + F_(n-2)$,
with $F_1 = #fib_1$ and $F_2 = #fib_2$.
It can also be expressed in _closed form:_

$
  F_n = round(1 / sqrt(5) phi.alt^n), quad
  phi.alt = (1 + sqrt(5)) / 2.
$
      

produces this fine document:

The output of typst compile --format png --ppi 144 typst-example.typ
The output of typst compile --format png --ppi 144 typst-example.typ

By default the typst CLI will create a PDF, but it can also make PNGs. The above image was created with the command


        typst compile \
    --format png \
    --ppi 250 \
    typst-example.typ
      

But if you’d rather create something with a bit more pizzazz, by all means. How about


        #set page(width: 1200pt, height: 630pt, margin: 80pt, fill: rgb("#FF1493"))
#set text(font: "Comic Sans MS", fill: white, lang: "no")


#grid(
  rows: (1fr, auto),
  align(left + horizon, {
    text(size: 72pt, weight: 800, "Typst very nice")
  }),
  text(size: 24pt, "Would typst again"),
)
      

which yields

This is clearly much better
This is clearly much better

If all of my posts were titled “Typst very nice”, we’d be done, but, alas. Luckily, typst accepts inputs to a document with --input key=value, which is put in a sys.inputs dictionary for the template to read. So the template becomes


        #set page(width: 1200pt, height: 630pt, margin: 80pt, fill: rgb("#FF1493"))
#set text(font: "Comic Sans MS", fill: white, lang: "no")

#let title = sys.inputs.at("title", default: "You forgot the title :(")
// Reading the input here ☝️

#grid(
  rows: (1fr, auto),
  align(left + horizon, {
    text(
      size: if title.len() > 70 { 60pt } else { 72pt },
      weight: 800,
      title,
    )
  }),
  text(size: 24pt, "Would typst again"),
)
      

which, when rendered with


        typst compile \
    --format png \
    --ppi 144 \
    --input title="Look ma, from the CLI!" fancy-typst-with-input.typ
      
It’s … perfect.
It’s … perfect.

Let’s move on to generating the images at build-time.

Astro

This site is built with Astro, a static site generator. Articles are written in Markdown, which is then bundled into a Content Collection by Astro. The details are irrelevant, what matters is that this lets me define metadata which I can query through a Javascript API.

The source code for the page template is at src/pages/posts/[id].astro. There are a few notable things about this file path:

  • That it is in the src/pages/ directory tells Astro that this should be rendered as a page.
  • The remainder of the path, /posts/[id], tells Astro where to put the page.
  • [id] being in brackets means id is a dynamic parameter which must be defined at build time.

In other words, Astro needs to know every ID when building the site—it can’t generate infinitely many IDs. A function called getStaticPaths exported from the template defines the set:


        import type { GetStaticPathsResult } from "astro";
import { getCollection } from "astro:content";

export async function getStaticPaths(): Promise<GetStaticPathsResult> {
  const posts = await getCollection("posts");
  return posts.map((post) => ({
    params: { id: post.id },
    props: { post },
  }));
}
      

That is, fetch every article, and use its id as the id parameter. This is the object returned for the current page:


        {
  params: { id: "open-graph-images-with-typst" },
  props: { post: {
    /* Everything the template needs to render a post */
  }}
}
      

By default Astro will generate an HTML file, but it can also return other kinds of responses. Together with this blog post I also put the following snippet at src/pages/api/build-info.json.ts:


        import type { APIRoute } from "astro";

export const GET = (async () => {
  const data = await getLatestJjMetadata();
  return new Response(JSON.stringify(data), {
    headers: { "content-type": "application/json" },
  });
}) satisfies APIRoute;

async function getLatestJjMetadata(): Promise<{
  changeId: string;
  committedAt: Date;
}> {
  // Implementation cut for brevity
}
      

Note that there are no parameters in brackets, so this will create a single JSON file at build time:


        ❯ tree dist/api
dist/api
└── build-info.json
      

And just like that, this blog now has a JSON api.

Note that the file extension is included in the path, i.e. .json.ts. This is because when the dev server is running it sets the appropriate content-type header to let the browser know what to do with the response, but once the page is built the result is a file with .ts stripped off. The browser then interprets the remaining file extension to know what content to expect at the path.

You probably see where this is going: Create an API route which takes a post ID as a parameter and ends in .png.ts, gets the post title using the Astro Content Collection API, generates an image, and returns it in a response with the appropriate content-type header.

You got it. Here’s the open graph image endpoint in its entirety, defined at src/pages/posts/[id]/og.png.ts:


        import type { APIRoute, GetStaticPaths, InferGetStaticPropsType } from "astro";
import { getCollection } from "astro:content";
import { execFile } from "node:child_process";
import { promisify } from "node:util";

import { OPEN_GRAPH_TEMPLATE_PATH } from "@/utils";

const execFileAsync = promisify(execFile);

export const getStaticPaths = (async () => {
  const posts = await getCollection("posts");
  return posts.map((post) => ({
    params: { id: post.id },
    props: { title: post.data.title },
  }));
}) satisfies GetStaticPaths;

type Props = InferGetStaticPropsType<typeof getStaticPaths>;

export const GET = (async ({ props }) => {
  const { stdout } = await execFileAsync(
    "typst",
    [
      "compile",
      OPEN_GRAPH_TEMPLATE_PATH,
      "-",
      "--format",
      "png",
      "--ppi",
      "144",
      "--input",
      `title=${props.title}`,
    ],
    { encoding: "buffer" },
  );
  return new Response(new Uint8Array(stdout), {
    headers: { "content-type": "image/png" },
  });
}) satisfies APIRoute<Props>;
      

Now go to eaj.no/posts/open-graph-images-with-typst/og.png, et voilà:

Extra credit: Cache busting

When a sharing platform loads the image for an article, it might very well be cached for a long time. Even if you decide to completely redo your entire branding, LinkedIn might insist on displaying your previous hideous design unless you handle it. A simple trick is to use a throwaway search parameter that doesn’t do anything other than make the URL unique:


        /some-image.png?v=whatever
      

A straightforward (but silly) solution for creating this parameter is to generate a random string of characters every time the site is built, ensuring your image is always fetched anew. I landed on using a hash of the typst template and the title, so that the cache key only changes whenever the actual content does:


        import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";

export function openGraphImageCacheBuster(title: string): string {
  const template = readFileSync(OPEN_GRAPH_TEMPLATE_PATH);
  return createHash("sha256")
    .update(template)
    .update(title)
    .digest("hex")
    .slice(0, 10);
}
      

The text above was extracted automatically from the source page and may miss images, tables or code blocks. The original is authoritative.

Published by Lobsters
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗