diff --git a/documentation/.gitignore b/documentation/.gitignore
index 283df93..9a17a4d 100644
--- a/documentation/.gitignore
+++ b/documentation/.gitignore
@@ -7,3 +7,6 @@ build/
# All other docs/ pages are authored in this directory.
docs/specification/
src/data/
+
+# Blog link-preview cards, rendered by plugins/social-cards.
+static/img/og/
diff --git a/documentation/docusaurus.config.ts b/documentation/docusaurus.config.ts
index 122c6a5..59c0d3c 100644
--- a/documentation/docusaurus.config.ts
+++ b/documentation/docusaurus.config.ts
@@ -78,6 +78,8 @@ const config: Config = {
onUntruncatedBlogPosts: "warn",
} satisfies BlogOptions,
],
+ // Renders each blog post's link-preview card; see plugins/social-cards.
+ "./plugins/social-cards/index.ts",
[
"@docusaurus/plugin-content-pages",
{
@@ -104,7 +106,8 @@ const config: Config = {
themeConfig: {
// Link previews. Docusaurus adds og:image and twitter:image from `image`, as absolute URLs; the tags below are
- // ones it leaves out. Blog posts replace og:type with "article".
+ // ones it leaves out. Blog posts replace og:type with "article", and the image and its alt text with the post's
+ // generated card (plugins/social-cards), which has the same type and size.
image: "img/social-card.png",
metadata: [
{ property: "og:site_name", content: "Governed Artifact Protocol" },
diff --git a/documentation/package.json b/documentation/package.json
index 7bea1bd..2f6d0c4 100644
--- a/documentation/package.json
+++ b/documentation/package.json
@@ -31,8 +31,10 @@
"@docusaurus/module-type-aliases": "^3.10.2",
"@docusaurus/tsconfig": "^3.10.2",
"@docusaurus/types": "^3.10.2",
+ "@resvg/resvg-js": "^2.6.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
+ "satori": "^0.55.1",
"typescript": "^7.0.0"
},
"browserslist": {
diff --git a/documentation/plugins/social-cards/index.ts b/documentation/plugins/social-cards/index.ts
new file mode 100644
index 0000000..753d84a
--- /dev/null
+++ b/documentation/plugins/social-cards/index.ts
@@ -0,0 +1,34 @@
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import type { LoadContext, Plugin } from "@docusaurus/types";
+import type { BlogContent } from "@docusaurus/plugin-content-blog";
+import { cardPath } from "./path";
+import { renderCard } from "./render";
+
+/**
+ * Renders a link-preview card for every blog post into static/img/og/, so the
+ * dev server and the build both serve it. BlogPostPage/Metadata points each
+ * post's og:image at its card unless the post sets `image` in front matter.
+ * The directory is generated and ignored by git.
+ */
+export default function socialCards(context: LoadContext): Plugin {
+ const { siteDir, siteConfig } = context;
+ return {
+ name: "gap-social-cards",
+ async allContentLoaded({ allContent }) {
+ const blog = allContent["docusaurus-plugin-content-blog"]?.default as BlogContent | undefined;
+ const outDir = path.join(siteDir, "static", "img", "og");
+ await fs.rm(outDir, { recursive: true, force: true });
+ for (const { metadata } of blog?.blogPosts ?? []) {
+ const file = path.join(siteDir, "static", cardPath(metadata.permalink, siteConfig.baseUrl));
+ const png = await renderCard({
+ title: metadata.title,
+ authors: metadata.authors.map((author) => author.name).filter((name): name is string => Boolean(name)),
+ date: new Date(metadata.date),
+ });
+ await fs.mkdir(path.dirname(file), { recursive: true });
+ await fs.writeFile(file, png);
+ }
+ },
+ };
+}
diff --git a/documentation/plugins/social-cards/path.ts b/documentation/plugins/social-cards/path.ts
new file mode 100644
index 0000000..3ce01ad
--- /dev/null
+++ b/documentation/plugins/social-cards/path.ts
@@ -0,0 +1,9 @@
+/**
+ * Where a blog post's card lives, relative to the site root and without the
+ * base URL: /blog/hello-world becomes img/og/blog/hello-world.png. Shared by
+ * the plugin that writes the cards and the page metadata that links them.
+ */
+export function cardPath(permalink: string, baseUrl: string): string {
+ const route = permalink.startsWith(baseUrl) ? permalink.slice(baseUrl.length) : permalink.replace(/^\//, "");
+ return `img/og/${route.replace(/\/$/, "")}.png`;
+}
diff --git a/documentation/plugins/social-cards/render.ts b/documentation/plugins/social-cards/render.ts
new file mode 100644
index 0000000..d2b2ff7
--- /dev/null
+++ b/documentation/plugins/social-cards/render.ts
@@ -0,0 +1,129 @@
+import { readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { Resvg } from "@resvg/resvg-js";
+import satori from "satori";
+
+/**
+ * Renders a blog post's link-preview card: a 1200x630 PNG with the post's
+ * title in Bungee on the site's paper and grain, the protocol's name above it,
+ * and the authors and date below. Ink and gray only, like the site's diagrams.
+ */
+
+export const CARD_WIDTH = 1200;
+export const CARD_HEIGHT = 630;
+
+export type CardContent = {
+ title: string;
+ authors: string[];
+ date: Date;
+};
+
+// The light palette from src/css/custom.css. Link previews have no theme, so
+// cards always use the light one.
+const paper = "#f4f2ee";
+const ink = "#151412";
+const muted = "#74706a";
+
+// The light theme's --gap-grain filter, painted over the whole card at the
+// same scale the site uses at 1x.
+const grain =
+ "";
+
+const require = createRequire(import.meta.url);
+// Satori reads WOFF but not WOFF2.
+const font = (file: string) => readFileSync(require.resolve(`@fontsource/${file}`));
+const fonts = [
+ { name: "Bungee", data: font("bungee/files/bungee-latin-400-normal.woff"), weight: 400 as const, style: "normal" as const },
+ { name: "Karla", data: font("karla/files/karla-latin-400-normal.woff"), weight: 400 as const, style: "normal" as const },
+ { name: "Karla", data: font("karla/files/karla-latin-700-normal.woff"), weight: 700 as const, style: "normal" as const },
+];
+
+/** Bungee is wide, so long titles step down a size to stay within four lines. */
+export function titleSize(title: string): number {
+ if (title.length <= 24) return 96;
+ if (title.length <= 48) return 76;
+ if (title.length <= 80) return 60;
+ return 50;
+}
+
+function byline({ authors, date }: CardContent): string {
+ const names =
+ authors.length <= 2 ? authors.join(" and ") : `${authors.slice(0, -1).join(", ")}, and ${authors.at(-1)}`;
+ const formatted = new Intl.DateTimeFormat("en-US", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ timeZone: "UTC",
+ }).format(date);
+ return names ? `${names} · ${formatted}` : formatted;
+}
+
+// Satori takes React-shaped elements; building them as objects keeps this a
+// plain .ts module.
+type Node = { type: string; props: Record & { style?: Record } };
+const el = (type: string, style: Record, children?: unknown): Node => ({
+ type,
+ props: { style, children },
+});
+
+function layout(content: CardContent): Node {
+ const size = titleSize(content.title);
+ return el(
+ "div",
+ {
+ width: CARD_WIDTH,
+ height: CARD_HEIGHT,
+ boxSizing: "border-box",
+ display: "flex",
+ flexDirection: "column",
+ justifyContent: "space-between",
+ paddingTop: 72,
+ paddingRight: 80,
+ paddingBottom: 68,
+ paddingLeft: 80,
+ color: ink,
+ },
+ [
+ el(
+ "div",
+ {
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "baseline",
+ paddingBottom: 18,
+ borderBottom: `2px solid ${ink}`,
+ },
+ [
+ el("div", { fontFamily: "Bungee", fontSize: 26 }, "Governed Artifact Protocol"),
+ el(
+ "div",
+ { fontFamily: "Karla", fontWeight: 700, fontSize: 20, letterSpacing: 3, color: muted },
+ "BLOG",
+ ),
+ ],
+ ),
+ el(
+ "div",
+ {
+ display: "block",
+ fontFamily: "Bungee",
+ fontSize: size,
+ lineHeight: 1.08,
+ lineClamp: 4,
+ textOverflow: "ellipsis",
+ },
+ content.title,
+ ),
+ el("div", { fontFamily: "Karla", fontSize: 28, color: muted }, byline(content)),
+ ],
+ );
+}
+
+export async function renderCard(content: CardContent): Promise {
+ const svg = await satori(layout(content) as never, { width: CARD_WIDTH, height: CARD_HEIGHT, fonts });
+ // Satori cannot paint SVG filters, so lay the paper and grain under its
+ // output before rasterizing.
+ const background = `${grain}`;
+ const layered = svg.replace(/^