From 603a077f4179beff544a3aa59d3bb51087580687 Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Thu, 15 Oct 2026 07:40:16 -0400 Subject: [PATCH 1/2] Render a link-preview card for each blog post A local Docusaurus plugin draws a 1200x630 PNG per post with Satori and resvg: the post's title in Bungee on the site's paper and grain, the protocol's name above a 2px rule, and the authors and date below. Ink and gray only. Cards are written to static/img/og/ when content loads, so the dev server and the build both serve them; the directory is generated and ignored. Co-Authored-By: Claude Opus 5.5 --- documentation/.gitignore | 3 + documentation/package.json | 2 + documentation/plugins/social-cards/index.ts | 34 +++ documentation/plugins/social-cards/path.ts | 9 + documentation/plugins/social-cards/render.ts | 129 ++++++++ documentation/test/social-cards.test.ts | 23 ++ pnpm-lock.yaml | 294 +++++++++++++++++++ 7 files changed, 494 insertions(+) create mode 100644 documentation/plugins/social-cards/index.ts create mode 100644 documentation/plugins/social-cards/path.ts create mode 100644 documentation/plugins/social-cards/render.ts create mode 100644 documentation/test/social-cards.test.ts 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/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(/^]*>/, (open) => `${open}${background}`); + return new Resvg(layered, { fitTo: { mode: "width", value: CARD_WIDTH } }).render().asPng(); +} diff --git a/documentation/test/social-cards.test.ts b/documentation/test/social-cards.test.ts new file mode 100644 index 0000000..abb7977 --- /dev/null +++ b/documentation/test/social-cards.test.ts @@ -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’s 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); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb9fec7..a1f26bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ importers: .: configDependencies: {} packageManagerDependencies: + '@pnpm/exe': + specifier: 12.10.1 + version: 12.10.1 pnpm: specifier: 12.10.1 version: 12.10.1 @@ -89,6 +92,11 @@ packages: cpu: [x64] os: [win32] + '@pnpm/exe@12.10.1': + resolution: {integrity: sha512-q6RXJFGkDmECWt5gmvAozciaU9HU5FqCRsu3rRMQJX7z8EvtAsJ9BXpyVbkNrwe6mwjlM4avqm/XUyszTL1Htw==} + engines: {node: '>=18.*'} + hasBin: true + pnpm@12.10.1: resolution: {integrity: sha512-ukCjfrGjcNYP6oxM8dNkwTvMzvUY6YrSvO6VT1OdONo3RY5YJho/pV4GUjRQ6g//+fSXsTW+QfLBXBZCusrObA==} engines: {node: '>=18.*'} @@ -138,6 +146,23 @@ snapshots: '@pnpm/exe.win32-x64@12.10.1': optional: true + '@pnpm/exe@12.10.1': + optionalDependencies: + '@pnpm/exe.android-arm64': 12.10.1 + '@pnpm/exe.android-x64': 12.10.1 + '@pnpm/exe.darwin-arm64': 12.10.1 + '@pnpm/exe.darwin-x64': 12.10.1 + '@pnpm/exe.freebsd-x64': 12.10.1 + '@pnpm/exe.linux-arm64': 12.10.1 + '@pnpm/exe.linux-arm64-musl': 12.10.1 + '@pnpm/exe.linux-ppc64': 12.10.1 + '@pnpm/exe.linux-riscv64': 12.10.1 + '@pnpm/exe.linux-s390x': 12.10.1 + '@pnpm/exe.linux-x64': 12.10.1 + '@pnpm/exe.linux-x64-musl': 12.10.1 + '@pnpm/exe.win32-arm64': 12.10.1 + '@pnpm/exe.win32-x64': 12.10.1 + pnpm@12.10.1: optionalDependencies: '@pnpm/exe.android-arm64': 12.10.1 @@ -252,12 +277,18 @@ importers: '@docusaurus/types': specifier: ^3.10.2 version: 3.10.2(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.29))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.29)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@8.1.1)(svgo@3.3.5) + '@resvg/resvg-js': + specifier: ^2.6.2 + version: 2.6.2 '@types/react': specifier: ^19.2.0 version: 19.3.0 '@types/react-dom': specifier: ^19.2.0 version: 19.3.0(@types/react@19.3.0) + satori: + specifier: ^0.55.1 + version: 0.55.1 typescript: specifier: ^7.0.0 version: 7.0.2 @@ -1677,6 +1708,91 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@resvg/resvg-js-android-arm-eabi@2.6.2': + resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@resvg/resvg-js-android-arm64@2.6.2': + resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@resvg/resvg-js-darwin-arm64@2.6.2': + resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@resvg/resvg-js-darwin-x64@2.6.2': + resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js@2.6.2': + resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} + engines: {node: '>= 10'} + + '@shuding/opentype.js@1.4.0-beta.0': + resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} + engines: {node: '>= 8.0.0'} + hasBin: true + '@sideway/address@4.1.5': resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} @@ -2162,6 +2278,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + baseline-browser-mapping@2.11.27: resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==} engines: {node: '>=6.0.0'} @@ -2265,6 +2385,9 @@ packages: resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} engines: {node: '>=14.16'} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} @@ -2487,18 +2610,32 @@ packages: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} engines: {node: '>=12'} + css-background-parser@0.1.0: + resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==} + css-blank-pseudo@7.0.1: resolution: {integrity: sha512-jf+twWGDf6LDoXDUode+nc7ZlrqfaNphrBIBrcmeP3D8yw1uPaix1gCC8LUQUGQ6CycuK2opkbFFWFuq/a94ag==} engines: {node: '>=18'} peerDependencies: postcss: ^8.4 + css-box-shadow@1.0.0-3: + resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==} + + css-color-keywords@1.0.0: + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} + engines: {node: '>=4'} + css-declaration-sorter@7.4.0: resolution: {integrity: sha512-LTuzjPoyA2vMGKKcaOqKSp7Ub2eGrNfKiZH4LpezxpNrsICGCSFvsQOI29psISxNZtaXibkC2CXzrQ5enMeGGw==} engines: {node: ^14 || ^16 || >=18} peerDependencies: postcss: ^8.0.9 + css-gradient-parser@0.0.17: + resolution: {integrity: sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==} + engines: {node: '>=16'} + css-has-pseudo@7.0.3: resolution: {integrity: sha512-oG+vKuGyqe/xvEMoxAQrhi7uY16deJR3i7wwhBerVrGQKSqUC5GiOVxTpM9F9B9hw0J+eKeOWLH7E9gZ1Dr5rA==} engines: {node: '>=18'} @@ -2554,6 +2691,9 @@ packages: css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-to-react-native@3.2.0: + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + css-tree@2.2.1: resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} @@ -2748,6 +2888,10 @@ packages: electron-to-chromium@1.5.446: resolution: {integrity: sha512-kWlGMPHzuitCKx0O+MbY1ZGvQmP14JtckrnGjD4JsusTxh2cX9wu4bb4rOYZqLSvENjFFrHzlqY8kXhseucR/w==} + emoji-regex-xs@2.0.1: + resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} + engines: {node: '>=10.0.0'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2938,6 +3082,9 @@ packages: resolution: {integrity: sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==} engines: {node: '>=0.4.0'} + fflate@0.7.3: + resolution: {integrity: sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw==} + file-loader@6.2.0: resolution: {integrity: sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==} engines: {node: '>= 10.13.0'} @@ -3081,6 +3228,9 @@ packages: handle-thing@2.0.1: resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} + harfbuzzjs@0.10.0: + resolution: {integrity: sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -3128,6 +3278,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + hex-rgb@4.3.0: + resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} + engines: {node: '>=6'} + history@4.10.1: resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==} @@ -3512,6 +3666,9 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -4041,6 +4198,9 @@ packages: resolution: {integrity: sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==} engines: {node: '>=14.16'} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -4048,6 +4208,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-css-color@0.2.1: + resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -4792,6 +4955,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + satori@0.55.1: + resolution: {integrity: sha512-YieTyYI+LVOTOX+RYs1ufcf19ApxKKJ8pNHu0B0zJB4NnxCXIOXmIyti6X4zjfRJZna9XD7FjbXF+CyzhTu5FQ==} + engines: {node: '>=16'} + sax@1.6.1: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} @@ -4979,6 +5146,9 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string.prototype.codepointat@0.2.1: + resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -5106,6 +5276,9 @@ packages: thunky@1.1.0: resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -5202,6 +5375,9 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -7597,6 +7773,62 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@resvg/resvg-js-android-arm-eabi@2.6.2': + optional: true + + '@resvg/resvg-js-android-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-x64@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js@2.6.2': + optionalDependencies: + '@resvg/resvg-js-android-arm-eabi': 2.6.2 + '@resvg/resvg-js-android-arm64': 2.6.2 + '@resvg/resvg-js-darwin-arm64': 2.6.2 + '@resvg/resvg-js-darwin-x64': 2.6.2 + '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 + '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 + '@resvg/resvg-js-linux-arm64-musl': 2.6.2 + '@resvg/resvg-js-linux-x64-gnu': 2.6.2 + '@resvg/resvg-js-linux-x64-musl': 2.6.2 + '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 + '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 + '@resvg/resvg-js-win32-x64-msvc': 2.6.2 + + '@shuding/opentype.js@1.4.0-beta.0': + dependencies: + fflate: 0.7.3 + string.prototype.codepointat: 0.2.1 + '@sideway/address@4.1.5': dependencies: '@hapi/hoek': 9.3.0 @@ -8074,6 +8306,8 @@ snapshots: balanced-match@1.0.2: {} + base64-js@0.0.8: {} + baseline-browser-mapping@2.11.27: {} batch@0.6.1: {} @@ -8211,6 +8445,8 @@ snapshots: camelcase@7.0.1: {} + camelize@1.0.1: {} + caniuse-api@3.0.0: dependencies: browserslist: 4.29.3 @@ -8424,15 +8660,23 @@ snapshots: dependencies: type-fest: 1.4.0 + css-background-parser@0.1.0: {} + css-blank-pseudo@7.0.1(postcss@8.5.29): dependencies: postcss: 8.5.29 postcss-selector-parser: 7.1.6 + css-box-shadow@1.0.0-3: {} + + css-color-keywords@1.0.0: {} + css-declaration-sorter@7.4.0(postcss@8.5.29): dependencies: postcss: 8.5.29 + css-gradient-parser@0.0.17: {} + css-has-pseudo@7.0.3(postcss@8.5.29): dependencies: '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6) @@ -8487,6 +8731,12 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-to-react-native@3.2.0: + dependencies: + camelize: 1.0.1 + css-color-keywords: 1.0.0 + postcss-value-parser: 4.2.0 + css-tree@2.2.1: dependencies: mdn-data: 2.0.28 @@ -8700,6 +8950,8 @@ snapshots: electron-to-chromium@1.5.446: {} + emoji-regex-xs@2.0.1: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -8973,6 +9225,8 @@ snapshots: dependencies: xml-js: 1.6.11 + fflate@0.7.3: {} + file-loader@6.2.0(webpack@5.111.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.29))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.29)(svgo@3.3.5)): dependencies: loader-utils: 2.0.4 @@ -9130,6 +9384,8 @@ snapshots: handle-thing@2.0.1: {} + harfbuzzjs@0.10.0: {} + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -9240,6 +9496,8 @@ snapshots: he@1.2.0: {} + hex-rgb@4.3.0: {} + history@4.10.1: dependencies: '@babel/runtime': 7.29.7 @@ -9585,6 +9843,11 @@ snapshots: lilconfig@3.1.3: {} + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + lines-and-columns@1.2.4: {} loader-utils@2.0.4: @@ -10354,6 +10617,8 @@ snapshots: registry-url: 6.0.1 semver: 7.8.5 + pako@0.2.9: {} + param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -10363,6 +10628,11 @@ snapshots: dependencies: callsites: 3.1.0 + parse-css-color@0.2.1: + dependencies: + color-name: 1.1.4 + hex-rgb: 4.3.0 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -11241,6 +11511,21 @@ snapshots: safer-buffer@2.1.2: {} + satori@0.55.1: + dependencies: + '@shuding/opentype.js': 1.4.0-beta.0 + css-background-parser: 0.1.0 + css-box-shadow: 1.0.0-3 + css-gradient-parser: 0.0.17 + css-to-react-native: 3.2.0 + emoji-regex-xs: 2.0.1 + escape-html: 1.0.3 + fflate: 0.7.3 + harfbuzzjs: 0.10.0 + linebreak: 1.1.0 + parse-css-color: 0.2.1 + postcss-value-parser: 4.2.0 + sax@1.6.1: {} scheduler@0.28.0: {} @@ -11491,6 +11776,8 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.2.0 + string.prototype.codepointat@0.2.1: {} + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -11590,6 +11877,8 @@ snapshots: thunky@1.1.0: {} + tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} tiny-warning@1.0.3: {} @@ -11683,6 +11972,11 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unified@11.0.5: dependencies: '@types/unist': 3.0.3 From d90e788595a8ba582b75d6455a2aafb23a20d19b Mon Sep 17 00:00:00 2001 From: Andrew Harvard Date: Thu, 15 Oct 2026 07:40:19 -0400 Subject: [PATCH 2/2] Point blog posts' og:image at their generated card Override BlogPostPage/Metadata so a post without its own `image` front matter links its card, and replace the site-wide image alt text with the post's title. A post that sets `image` keeps it, as before. Co-Authored-By: Claude Opus 5.5 --- documentation/docusaurus.config.ts | 5 ++- .../src/theme/BlogPostPage/Metadata/index.tsx | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 documentation/src/theme/BlogPostPage/Metadata/index.tsx 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/src/theme/BlogPostPage/Metadata/index.tsx b/documentation/src/theme/BlogPostPage/Metadata/index.tsx new file mode 100644 index 0000000..27525a6 --- /dev/null +++ b/documentation/src/theme/BlogPostPage/Metadata/index.tsx @@ -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 ( + + + + {authors.some((author) => author.url) && ( + author.url) + .filter(Boolean) + .join(",")} + /> + )} + {tags.length > 0 && tag.label).join(",")} />} + {!ownImage && } + {!ownImage && } + + ); +}