From 59bbca65081b9b6c1c0b7b6f9dce272168e3a114 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:15:51 +0800 Subject: [PATCH] feat(seo): social preview images for the GitHub repositories GitHub has no API for a repository's social preview, so the image is uploaded by hand. Build it with the site's share cards rather than by hand elsewhere: the same card at the 1280x640 GitHub recommends, one each for ThinkWatch-Lite, ThinkWatch-Core, ThinkWatch and the organization's .github profile, published at /og/github/.png. The name and the labels are the site card's; the line under the name follows each repository's description on GitHub. The site's own cards come out byte for byte the same. Co-Authored-By: Claude Opus 5.5 --- src/lib/og.ts | 42 +++++++++++++++++++++++++------ src/pages/og/github/[card].png.ts | 12 +++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 src/pages/og/github/[card].png.ts diff --git a/src/lib/og.ts b/src/lib/og.ts index 91b830d..8529575 100644 --- a/src/lib/og.ts +++ b/src/lib/og.ts @@ -1,5 +1,7 @@ // Open Graph images: a 1200×630 card for the site and one for each product, -// rendered at build time by src/pages/og/[card].png.ts. +// rendered at build time by src/pages/og/[card].png.ts. The same cards at +// GitHub's size are the social previews of the repositories (at the end of +// this file). // // A card carries the product name, the headline of the product's page and the // platforms the product runs on, and nothing else. The text comes from the page @@ -158,17 +160,18 @@ function logo(x: number, y: number, size: number): string { return svg.replace(" { - const { name, line, labels } = cardText(card, "en"); - const W = OG_WIDTH; - const H = OG_HEIGHT; +export const renderOgImage = (card: OgCard) => renderCard(cardText(card, "en"), OG_WIDTH, OG_HEIGHT, card); + +/** A card of `W`×`H` pixels. `card` names it in errors. */ +async function renderCard({ name, line, labels }: CardText, W: number, H: number, card: string): Promise { const X = 80; const width = W - 2 * X; - // Name: shrink only if a longer name ever needs it. + // Name: shrink only if a longer name ever needs it. Its baseline is a little + // above the middle: 304 on a 630-pixel card. let nameSize = 88; while (measure(sans(600), name, nameSize, -0.025 * nameSize) > width) nameSize -= 2; - const nameY = 304; + const nameY = Math.round(H * 0.4825); // Headline: at most two lines, smaller type before a third line. let lineSize = 38; @@ -232,3 +235,28 @@ export async function renderOgImage(card: OgCard): Promise { return sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer(); } + +// ---------- GitHub ---------- + +// Social preview images for the repositories on GitHub, rendered at build time +// by src/pages/og/github/[card].png.ts. GitHub has no API for them: each is +// uploaded by hand from /og/github/.png, under Settings → General → +// Social preview of its repository. They are the site's cards at the size +// GitHub recommends, with a line that says what the repository holds, after +// its description on GitHub (the organization's, for the profile repository). + +const GITHUB_WIDTH = 1280; +const GITHUB_HEIGHT = 640; + +/** The repository each card is uploaded to, and the line under the name */ +const githubCards: Record = { + home: { repo: ".github", line: "AI gateways for organizations and individual developers" }, + lite: { repo: "ThinkWatch-Lite", line: "Desktop app for a local AI API gateway" }, + core: { repo: "ThinkWatch-Core", line: "Rust crates and the twcore binary for an AI API gateway" }, + enterprise: { repo: "ThinkWatch", line: "AI bastion host for secure AI API and MCP access" }, +}; + +export function renderGithubPreview(card: OgCard): Promise { + const { name, labels } = cardText(card, "en"); + return renderCard({ name, line: githubCards[card].line, labels }, GITHUB_WIDTH, GITHUB_HEIGHT, `github/${card}`); +} diff --git a/src/pages/og/github/[card].png.ts b/src/pages/og/github/[card].png.ts new file mode 100644 index 0000000..d5bbda6 --- /dev/null +++ b/src/pages/og/github/[card].png.ts @@ -0,0 +1,12 @@ +// The social previews of the GitHub repositories, one PNG per card, rendered +// when the site is built. What goes on them, and which repository each one is +// for, is in src/lib/og.ts. +import type { APIRoute, GetStaticPaths } from "astro"; +import { ogCards, renderGithubPreview, type OgCard } from "~/lib/og"; + +export const getStaticPaths = (() => ogCards.map((card) => ({ params: { card } }))) satisfies GetStaticPaths; + +export const GET: APIRoute = async ({ params }) => { + const png = await renderGithubPreview(params.card as OgCard); + return new Response(new Uint8Array(png), { headers: { "Content-Type": "image/png" } }); +};