返回资讯中心
外部精选
软件工程
#开发者实时流

Open Graph Images with Astro and Typst

Links to blog articles look better when they’re accompanied by an image.

Hacker News 最新birdculture2026/11/10 · 今天18 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

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:

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:

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

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

becomes

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

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:

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à:

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:

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →