Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions documentation/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
5 changes: 4 additions & 1 deletion documentation/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand All @@ -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" },
Expand Down
2 changes: 2 additions & 0 deletions documentation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
34 changes: 34 additions & 0 deletions documentation/plugins/social-cards/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
},
};
}
9 changes: 9 additions & 0 deletions documentation/plugins/social-cards/path.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
129 changes: 129 additions & 0 deletions documentation/plugins/social-cards/render.ts
Original file line number Diff line number Diff line change
@@ -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 =
"<filter id='card-grain'><feTurbulence type='turbulence' baseFrequency='0.55' numOctaves='2' seed='7' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/><feComponentTransfer><feFuncR type='linear' slope='-1.4' intercept='1.5'/><feFuncG type='linear' slope='-1.4' intercept='1.5'/><feFuncB type='linear' slope='-1.4' intercept='1.5'/><feFuncA type='linear' slope='0' intercept='1'/></feComponentTransfer><feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -0.6 0 0 0 0.6'/></filter>";

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<string, unknown> & { style?: Record<string, unknown> } };
const el = (type: string, style: Record<string, unknown>, 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<Buffer> {
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 = `<defs>${grain}</defs><rect width="${CARD_WIDTH}" height="${CARD_HEIGHT}" fill="${paper}"/><rect width="${CARD_WIDTH}" height="${CARD_HEIGHT}" filter="url(#card-grain)"/>`;
const layered = svg.replace(/^<svg[^>]*>/, (open) => `${open}${background}`);
return new Resvg(layered, { fitTo: { mode: "width", value: CARD_WIDTH } }).render().asPng();
}
38 changes: 38 additions & 0 deletions documentation/src/theme/BlogPostPage/Metadata/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { PageMetadata } from "@docusaurus/theme-common";
import { useBlogPost } from "@docusaurus/plugin-content-blog/client";
import { cardPath } from "@site/plugins/social-cards/path";

/**
* The theme's blog post metadata, with the post's generated card as the
* default link-preview image (plugins/social-cards). A post that sets `image`
* in front matter keeps its own image and the site's default alt text.
*/
export default function BlogPostPageMetadata(): React.JSX.Element {
const { assets, metadata } = useBlogPost();
const { siteConfig } = useDocusaurusContext();
const { title, description, date, tags, authors, frontMatter } = metadata;
const { keywords } = frontMatter;
const ownImage = assets.image ?? frontMatter.image;
const image = ownImage ?? cardPath(metadata.permalink, siteConfig.baseUrl);
const alt = `${title}, on the Governed Artifact Protocol blog`;
return (
<PageMetadata title={frontMatter.title_meta ?? title} description={description} keywords={keywords} image={image}>
<meta property="og:type" content="article" />
<meta property="article:published_time" content={date} />
{authors.some((author) => author.url) && (
<meta
property="article:author"
content={authors
.map((author) => author.url)
.filter(Boolean)
.join(",")}
/>
)}
{tags.length > 0 && <meta property="article:tag" content={tags.map((tag) => tag.label).join(",")} />}
{!ownImage && <meta property="og:image:alt" content={alt} />}
{!ownImage && <meta name="twitter:image:alt" content={alt} />}
</PageMetadata>
);
}
23 changes: 23 additions & 0 deletions documentation/test/social-cards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { cardPath } from "../plugins/social-cards/path";
import { CARD_HEIGHT, CARD_WIDTH, renderCard } from "../plugins/social-cards/render";

test("a post's card path drops the base URL so the page can add it back", () => {
assert.equal(cardPath("/blog/hello-world", "/"), "img/og/blog/hello-world.png");
assert.equal(
cardPath("/governed-artifact-protocol/blog/hello-world", "/governed-artifact-protocol/"),
"img/og/blog/hello-world.png",
);
assert.equal(cardPath("/blog/hello-world/", "/"), "img/og/blog/hello-world.png");
});

test("cards render as PNGs at the size the site's og:image tags declare", async () => {
for (const title of ["Hello World", "What should travel with an agent鈥檚 summary? ".repeat(4).trim()]) {
const png = await renderCard({ title, authors: ["A Person", "Another Person"], date: new Date("2026-10-01") });
assert.equal(png.subarray(1, 4).toString("ascii"), "PNG");
// IHDR: width and height are the first two fields after the 16-byte header.
assert.equal(png.readUInt32BE(16), CARD_WIDTH);
assert.equal(png.readUInt32BE(20), CARD_HEIGHT);
}
});
Loading
Loading