From 9c67e6dc07a83f61374840025242f7a9a9cbcd40 Mon Sep 17 00:00:00 2001 From: arjunpredev <141591528+arjunpredev@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:02:50 -0400 Subject: [PATCH 1/2] feat(site): pre.dev share card on every docs page Every page's og:image and twitter:image is now the one pre.dev card design (site/lib/share-card.mjs, an exact copy of the card spec), filled with the page's section, title and description and the docs.pre.dev/ address. - app/og/[...slug]/route.ts draws each card at build time with satori 0.33.4 and @resvg/resvg-js (fitCard measures with the same satori), replacing the next/og ImageResponse card. checkCard() failures stop the build. - Cards live at /og//.png. The hash is sha256 of CARD_DESIGN and the card's inputs, so a changed title, description, section or design gets a new URL. The Worker serves hashed cards as immutable; /og//image.png keeps answering with the current card. - Tags: og:image with type, width, height and alt (cardAlt), twitter:image with alt, twitter:card summary_large_image, og:site_name pre.dev, og:title the page title alone. - Section: the page's docs.json group, at most two levels below its tab. A page without a description uses its first paragraph. - check:out fails a page without its card, its 1200x630 PNG, alt text or the full tag set. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- site/app/(docs)/[...slug]/page.tsx | 15 +- site/app/og/[...slug]/route.ts | 44 +++ site/app/og/[...slug]/route.tsx | 52 ---- site/lib/groups.ts | 34 +- site/lib/og-card.ts | 69 +++++ site/lib/share-card.d.mts | 47 +++ site/lib/share-card.mjs | 479 +++++++++++++++++++++++++++++ site/next.config.mjs | 2 + site/package-lock.json | 475 ++++++++++++++++++++++++++++ site/package.json | 3 + site/scripts/check-out.mjs | 49 ++- site/scripts/postbuild.mjs | 14 +- site/worker/index.ts | 2 + 14 files changed, 1212 insertions(+), 78 deletions(-) create mode 100644 site/app/og/[...slug]/route.ts delete mode 100644 site/app/og/[...slug]/route.tsx create mode 100644 site/lib/og-card.ts create mode 100644 site/lib/share-card.d.mts create mode 100644 site/lib/share-card.mjs diff --git a/README.md b/README.md index f739268..13c58d9 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ npm run check:python cd site npm ci npm run dev # live preview at http://localhost:3100 (also `npm run dev` from the repo root) -npm run build # static build in site/out, Markdown twins, OG images, search data -npm run check:out # every nav page, internal link, anchor, redirect, image and agent file +npm run build # static build in site/out, Markdown twins, share cards, search data +npm run check:out # every nav page, share card, internal link, anchor, redirect, image and agent file npm run check:worker # type-check the Worker npm run preview # serve site/out through the Worker at http://localhost:8799 npm run test:mcp # exercise /mcp and /api/search against the preview @@ -38,6 +38,7 @@ How it reads the Mintlify-format content, and where: - Snippet imports are inlined at build time (`lib/remark-mintlify.ts`), so their text reaches the Markdown output too. - Heading anchors follow Mintlify's slug rule (`lib/slug.mjs`), so existing deep links keep working. - `openapi:` frontmatter renders the operation with an interactive playground (`fumadocs-openapi`). +- Every page has a share card, the pre.dev card design (`lib/share-card.mjs`, an exact copy of the card spec in `predev-app`; change the spec there first, then copy it here) filled with the page's section, title and description (`lib/og-card.ts`). The build draws each one with satori and resvg (`app/og/[...slug]/route.ts`) at `/og//.png`. The hash covers what the card shows and the design version, so a changed card gets a new URL and link previews fetch it again; `/og//image.png` serves the current card too. The section is the page's group in `docs.json`, at most two levels below its tab. - Every page has a Markdown twin at `/.md`, also served for `Accept: text/markdown` and to AI fetchers. `llms-full.txt` is generated; `llms.txt` and `skill.md` are served as written. - The Worker (`worker/`) answers `/api/search`, the docs MCP server at `/mcp` (`search_pre_dev`, `query_docs_filesystem_pre_dev`, `submit_feedback`), and Ask AI at `/api/chat`. Ask AI answers from these docs with two models: Jev, a decision model, turns away off-topic questions and picks the sections that answer the rest, and DeepSeek V4.1 Flash writes the answer from those sections. It has a per-IP rate limit and Turnstile. diff --git a/site/app/(docs)/[...slug]/page.tsx b/site/app/(docs)/[...slug]/page.tsx index dd53dca..5f008e7 100644 --- a/site/app/(docs)/[...slug]/page.tsx +++ b/site/app/(docs)/[...slug]/page.tsx @@ -15,6 +15,7 @@ import { placeOf } from '@/lib/groups'; import { lastModified } from '@/lib/last-modified'; import { PageFooter } from '@/components/page-footer'; import { ImageZoom } from '@/components/image-zoom'; +import { cardImage, cardText } from '@/lib/og-card'; /** schema.org data for the page: an article (or API reference) and its breadcrumb trail. */ function structuredData(page: { title: string; description?: string; slug: string; api: boolean; modified?: string }) { @@ -120,10 +121,12 @@ export async function generateMetadata(props: { params: Promise<{ slug: string[] const page = source.getPage(slug); if (!page) notFound(); const pathSlug = pageSlug(page); - const image = `/og/${pathSlug}/image.png`; + // the page's share card (lib/og-card.ts): its address changes whenever what it shows does + const image = cardImage(page); + const description = cardText(page); return { title: page.data.title, - description: page.data.description, + description, alternates: { canonical: `/${pathSlug}`, types: { 'text/markdown': `/${pathSlug}.md` }, @@ -134,16 +137,16 @@ export async function generateMetadata(props: { params: Promise<{ slug: string[] siteName: docsJson.name, url: `/${pathSlug}`, title: page.data.title, - description: page.data.description, - images: { url: image, width: 1200, height: 630 }, + description, + images: image, ...(lastModified(page.path) ? { modifiedTime: lastModified(page.path) } : {}), }, twitter: { card: 'summary_large_image', site: docsJson.seo?.metatags?.['twitter:site'], title: page.data.title, - description: page.data.description, - images: { url: image, width: 1200, height: 630 }, + description, + images: image, }, }; } diff --git a/site/app/og/[...slug]/route.ts b/site/app/og/[...slug]/route.ts new file mode 100644 index 0000000..b3d5f0c --- /dev/null +++ b/site/app/og/[...slug]/route.ts @@ -0,0 +1,44 @@ +// Each page's share card, drawn at build time from the pre.dev card spec +// (lib/share-card.mjs) and written to out/og//.png. satori lays out +// and measures the card (fitCard steps the title down and keeps whole sentences +// of the description), resvg turns its SVG into the PNG. +import fs from 'node:fs'; +import path from 'node:path'; +import { notFound } from 'next/navigation'; +import satori from 'satori'; +import { Resvg } from '@resvg/resvg-js'; +import { source } from '@/lib/source'; +import { cardFile, docsCard } from '@/lib/og-card'; +import { CARD_FONTS, CARD_HEIGHT, CARD_WIDTH, MARK_SVG, cardTree, checkCard, fitCard, skySvg, svgDataUrl } from '@/lib/share-card.mjs'; + +export const revalidate = false; +export const dynamicParams = false; + +// Inter and JetBrains Mono as WOFF (satori reads no WOFF2) +const fonts = CARD_FONTS.map((f) => ({ + name: f.name, + weight: f.weight, + style: 'normal' as const, + data: fs.readFileSync(path.join(process.cwd(), 'node_modules/@fontsource', f.file)), +})); +const art = { mark: svgDataUrl(MARK_SVG), sky: svgDataUrl(skySvg()) }; + +export async function GET(_req: Request, { params }: { params: Promise<{ slug: string[] }> }) { + const { slug } = await params; + const page = source.getPage(slug.slice(0, -1)); + if (!page) notFound(); + const card = docsCard(page); + // only the current card's address answers + if (slug.at(-1) !== cardFile(card)) notFound(); + const fit = await fitCard(card, satori, fonts); + const tree = cardTree(card, art, fit); + const problems = checkCard(tree); + if (problems.length) throw new Error(`share card for /${slug.slice(0, -1).join('/')}: ${problems.join('; ')}`); + const svg = await satori(tree as never, { width: CARD_WIDTH, height: CARD_HEIGHT, fonts }); + const png = new Resvg(svg, { fitTo: { mode: 'original' } }).render().asPng(); + return new Response(new Uint8Array(png), { headers: { 'Content-Type': 'image/png' } }); +} + +export function generateStaticParams() { + return source.getPages().map((page) => ({ slug: [...page.slugs, cardFile(docsCard(page))] })); +} diff --git a/site/app/og/[...slug]/route.tsx b/site/app/og/[...slug]/route.tsx deleted file mode 100644 index 5fa9443..0000000 --- a/site/app/og/[...slug]/route.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { notFound } from 'next/navigation'; -import { ImageResponse } from 'next/og'; -import { source, pageSlug } from '@/lib/source'; -import { groupOf } from '@/lib/groups'; -import { docsJson } from '@/lib/docs-config'; -import { BrandMark } from '@/components/brand-mark'; - -export const revalidate = false; - -// Inter, the site's font (the renderer reads WOFF, not WOFF2) -const inter = (weight: 400 | 600) => - fs.readFileSync(path.join(process.cwd(), `node_modules/@fontsource/inter/files/inter-latin-${weight}-normal.woff`)); -const fonts = [ - { name: 'Inter', data: inter(400), weight: 400 as const, style: 'normal' as const }, - { name: 'Inter', data: inter(600), weight: 600 as const, style: 'normal' as const }, -]; - -export async function GET(_req: Request, { params }: { params: Promise<{ slug: string[] }> }) { - const { slug } = await params; - const page = source.getPage(slug.slice(0, -1)); - if (!page) notFound(); - const group = groupOf(pageSlug(page)); - const title = page.data.title; - return new ImageResponse( - ( -
- {/* the pre.dev header lockup: brain mark and name */} -
- -
{docsJson.name}
-
-
- {group ?
{group}
: null} -
40 ? 64 : 76, fontWeight: 600, lineHeight: 1.1, letterSpacing: -2 }}>{title}
- {page.data.description ? ( -
- {page.data.description} -
- ) : null} -
-
docs.pre.dev
-
- ), - { width: 1200, height: 630, fonts }, - ); -} - -export function generateStaticParams() { - return source.getPages().map((page) => ({ slug: [...page.slugs, 'image.png'] })); -} diff --git a/site/lib/groups.ts b/site/lib/groups.ts index b507f3e..70741e8 100644 --- a/site/lib/groups.ts +++ b/site/lib/groups.ts @@ -5,6 +5,8 @@ export interface NavPlace { /** First page of the tab */ tabPage: string; group?: string; + /** Every group above the page, outermost first */ + groups: string[]; } let map: Map | undefined; @@ -13,27 +15,35 @@ function places(): Map { if (!map) { map = new Map(); for (const tab of docsJson.navigation.tabs) { - const pages: { slug: string; group?: string }[] = []; - const walk = (items: NavGroup['pages'], group?: string) => { + const pages: { slug: string; groups: string[] }[] = []; + const walk = (items: NavGroup['pages'], groups: string[]) => { for (const item of items) { - if (typeof item === 'string') pages.push({ slug: item, group }); - else walk(item.pages, item.group); + if (typeof item === 'string') pages.push({ slug: item, groups }); + else walk(item.pages, [...groups, item.group]); } }; - if (tab.pages) walk(tab.pages); - for (const group of tab.groups ?? []) walk(group.pages, group.group); - for (const page of pages) map.set(page.slug, { tab: tab.tab, tabPage: pages[0].slug, group: page.group }); + if (tab.pages) walk(tab.pages, []); + for (const group of tab.groups ?? []) walk(group.pages, [group.group]); + for (const page of pages) { + map.set(page.slug, { tab: tab.tab, tabPage: pages[0].slug, group: page.groups.at(-1), groups: page.groups }); + } } } return map; } -/** The navigation group a page sits in (shown above the title, like Mintlify). */ -export function groupOf(slug: string): string | undefined { - return places().get(slug)?.group; -} - /** The tab and group a page sits in. */ export function placeOf(slug: string): NavPlace | undefined { return places().get(slug); } + +/** + * The section a page's share card names: its group, looking at most two levels + * below the tab, so the AI Gateway's endpoint pages read "AI Gateway reference" + * and the payments pages read "Payments". + */ +export function sectionOf(slug: string): string | undefined { + const place = places().get(slug); + if (!place) return undefined; + return place.groups[Math.min(place.groups.length, 2) - 1] ?? place.tab; +} diff --git a/site/lib/og-card.ts b/site/lib/og-card.ts new file mode 100644 index 0000000..d0004be --- /dev/null +++ b/site/lib/og-card.ts @@ -0,0 +1,69 @@ +// Each docs page's share card (lib/share-card.mjs is the design) and the +// address it is published under. The page's og:image and twitter:image point at +// /og//.png, where the hash covers everything the card shows plus +// CARD_DESIGN: change a title, a description, a section or the design and the +// card gets a new URL, so link scrapers that cache images forever fetch it again. +import crypto from 'node:crypto'; +import { CARD_DESIGN, CARD_HEIGHT, CARD_WIDTH, cardAlt, type ShareCard } from './share-card.mjs'; +import { sectionOf } from './groups'; +import { pageSlug, type DocsPage } from './source'; + +const HOST = 'docs.pre.dev'; + +/** The page's description, or for a page without one, its first paragraph. */ +export function cardText(page: DocsPage): string | undefined { + if (page.data.description?.trim()) return page.data.description.trim(); + const first = page.data.structuredData?.contents?.find((c) => c.content.trim())?.content; + return first + ?.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/`/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export function docsCard(page: DocsPage): ShareCard { + const slug = pageSlug(page); + const section = sectionOf(slug); + const card: ShareCard = { + eyebrow: ['pre.dev', 'Docs', ...(section ? [section] : [])], + title: page.data.title, + text: cardText(page), + footer: { meta: ['pre.dev documentation', `${HOST}/${slug}`] }, + fit: true, + }; + checkEyebrow(card.eyebrow!, slug); + return card; +} + +// The eyebrow is set in JetBrains Mono capitals (0.6em advance, 0.2em apart) and is +// never fitted, so a section name too long for the line fails the build instead. +function checkEyebrow(parts: string[], slug: string) { + const ch = 28 * 0.6 + 5.6; + const words = parts.map((p) => p.split(/\s+/).filter(Boolean)); + const chars = words.flat().join('').length + (parts.length - 1); + const gaps = words.reduce((n, w) => n + w.length - 1, 0) + (parts.length - 1) * 4; + const room = CARD_WIDTH - 72 * 2 - Math.round(34 * (432 / 312)) - 18; + const width = (chars + gaps) * ch; + if (width > room) throw new Error(`share card for ${slug}: eyebrow "${parts.join(' / ')}" is ${Math.round(width)}px wide, room for ${room}px`); +} + +export function cardHash(card: ShareCard): string { + return crypto.createHash('sha256').update(JSON.stringify([CARD_DESIGN, card])).digest('hex').slice(0, 10); +} + +/** The card's file name under /og//. */ +export function cardFile(card: ShareCard): string { + return `${cardHash(card)}.png`; +} + +/** What a page's metadata needs for its og:image and twitter:image. */ +export function cardImage(page: DocsPage) { + const card = docsCard(page); + return { + url: `/og/${pageSlug(page)}/${cardFile(card)}`, + width: CARD_WIDTH, + height: CARD_HEIGHT, + alt: cardAlt(card), + type: 'image/png', + }; +} diff --git a/site/lib/share-card.d.mts b/site/lib/share-card.d.mts new file mode 100644 index 0000000..d1aa476 --- /dev/null +++ b/site/lib/share-card.d.mts @@ -0,0 +1,47 @@ +// Types for share-card.mjs, the pre.dev share card spec (kept identical to the original). +export interface ShareCard { + eyebrow?: string[]; + title: string; + dim?: string; + text?: string; + list?: [string, string][]; + footer?: { + meta?: [string, string]; + stats?: [string, string][]; + chips?: string[]; + bars?: unknown; + }; + avatar?: string; + portrait?: boolean; + fit?: boolean; +} + +export interface CardArt { + mark: string; + sky: string; + portrait?: string; +} + +export interface CardFit { + titleSize: number; + text?: string; + textSize?: number; + list?: [string, string][]; + footer?: ShareCard['footer']; +} + +export type CardNode = { type: string; props: Record }; + +export declare const CARD_WIDTH: 1200; +export declare const CARD_HEIGHT: 630; +export declare const CARD_DESIGN: string; +export declare const MIN_FONT: number; +export declare const CARD_FONTS: { name: string; weight: 500 | 600 | 700 | 800; file: string }[]; +export declare const MARK_SVG: string; +export declare function skySvg(): string; +export declare function svgDataUrl(svg: string): string; +export declare function plainText(text: string | undefined): string; +export declare function cardTree(card: ShareCard, art: CardArt, fit?: Partial): CardNode; +export declare function fitCard(card: ShareCard, satori: unknown, fonts: unknown[]): Promise; +export declare function checkCard(tree: CardNode): string[]; +export declare function cardAlt(card: ShareCard): string; diff --git a/site/lib/share-card.mjs b/site/lib/share-card.mjs new file mode 100644 index 0000000..63e3422 --- /dev/null +++ b/site/lib/share-card.mjs @@ -0,0 +1,479 @@ +/** + * The pre.dev share card: one 1200 x 630 design for every link pre.dev + * publishes (the site, shared plans, the blog, the docs). This file is the + * spec. It has no dependencies and builds trees of elements for satori; each + * surface keeps a copy and renders with its own satori and resvg: + * + * predev-app frontend/scripts/og/share-card.mjs (the original) + * backend/src/helpers/share_card.ts + * masthead packages/server/src/card-layout.ts (the "pre.dev" design) + * docs scripts/og/share-card.mjs + * + * Change the design here first, copy it to the others, and bump CARD_DESIGN + * so every card is drawn again under a new address. + * + * The rules every card follows: + * - It is read at ~500px wide in a feed, sometimes 320px: a 0.42x scale, so + * nothing is smaller than 28px (MIN_FONT), and checkCard() fails a card + * that breaks it. + * - The same frame: the brain mark and a mono eyebrow trail on top, the + * headline and one support line, then a footer band (stats, chips, a meta + * line or the RL chart) under a hairline, inside a 72px safe box. + * - Text is fitted, never cut mid-sentence: a long title steps down from 64px + * to 48px, and support copy that does not fit loses whole sentences. + */ + +export const CARD_WIDTH = 1200; +export const CARD_HEIGHT = 630; +/** Part of every card's address: change it with the design and every card is drawn again. */ +export const CARD_DESIGN = '2'; +export const MIN_FONT = 28; + +/** The font files a renderer loads (all WOFF from @fontsource; satori reads no WOFF2). */ +export const CARD_FONTS = [ + { name: 'Inter', weight: 500, file: 'inter/files/inter-latin-500-normal.woff' }, + { name: 'Inter', weight: 600, file: 'inter/files/inter-latin-600-normal.woff' }, + { name: 'Inter', weight: 800, file: 'inter/files/inter-latin-800-normal.woff' }, + { name: 'JetBrains Mono', weight: 500, file: 'jetbrains-mono/files/jetbrains-mono-latin-500-normal.woff' }, + { name: 'JetBrains Mono', weight: 700, file: 'jetbrains-mono/files/jetbrains-mono-latin-700-normal.woff' } +]; + +const PAD = 72; +const INNER = CARD_WIDTH - PAD * 2; +const SANS = 'Inter'; +const MONO = 'JetBrains Mono'; +const INK = '#ffffff'; +const DIM = '#7d7d7d'; +const SOFT = '#a5a5a5'; +const MUTE = '#8f8f8f'; +const STRONG = '#e8e8e8'; +const LABEL = '#c4c4c4'; +const RULE = 'rgba(255,255,255,0.09)'; + +const TITLE_SIZES = [64, 60, 56, 52, 48]; +const TEXT_SIZE = 31; +const TEXT_LINE = 1.42; +const TITLE_LINE = 1.06; +const MARK_HEIGHT = 34; + +// ------------------------------------------------------------------ art + +/** pre.dev's brain mark (favicon.svg), white, cropped to the glyph. */ +export const MARK_SVG = + ''; +const MARK_RATIO = 432 / 312; + +/** + * The sky every card sits on: 260 still stars from a fixed seed and a soft + * light from the top left, as one SVG so every renderer draws the same one. + */ +export function skySvg() { + let seed = 0x5eed2026; + const rnd = () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + const radii = [0.6, 0.7, 0.8, 0.9, 1.0, 1.2, 1.4]; + let stars = ''; + for (let i = 0; i < 260; i++) { + const x = (rnd() * CARD_WIDTH).toFixed(1); + const y = (rnd() * CARD_HEIGHT).toFixed(1); + const r = radii[Math.floor(rnd() * radii.length)]; + stars += ``; + } + return ( + `` + + '' + + '' + + `${stars}` + ); +} + +/** An SVG string as a data: URL satori embeds. */ +export function svgDataUrl(svg) { + const bytes = new TextEncoder().encode(svg); + let bin = ''; + for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return `data:image/svg+xml;base64,${btoa(bin)}`; +} + +// ------------------------------------------------------------------ elements + +function el(type, style, ...children) { + const kept = children.flat().filter(c => c !== null && c !== undefined && c !== false && c !== ''); + return { type, props: { style: { display: 'flex', ...style }, children: kept.length === 1 ? kept[0] : kept } }; +} + +function img(src, width, height, style = {}) { + return { type: 'img', props: { src, width, height, style: { width, height, ...style } } }; +} + +/** "**bold**" marks the words support copy stresses; everything else is plain. */ +export function plainText(text) { + return String(text ?? '').replace(/\*\*/g, ''); +} + +/** + * Support copy as satori lays out mixed weights: one element per word in a + * wrapping row (satori does not flow spans inline), stressed words brighter. + */ +function richText(text, size, color) { + // Words in order; `glued` marks one that follows the last with no space ("**code**."). + const words = []; + let spaced = true; + for (const part of String(text).split(/(\*\*[^*]+\*\*)/)) { + if (!part) continue; + const strong = part.startsWith('**') && part.endsWith('**'); + const body = strong ? part.slice(2, -2) : part; + body.split(/(\s+)/).forEach((w, i) => { + if (!w) return; + if (/^\s+$/.test(w)) { + spaced = true; + return; + } + words.push({ w, strong, glued: !spaced && words.length > 0 }); + spaced = false; + }); + } + const groups = []; + for (const word of words) { + if (word.glued) groups.at(-1).push(word); + else groups.push([word]); + } + const space = size * 0.27; + const span = ({ w, strong }) => el('div', strong ? { color: STRONG, fontWeight: 600 } : { color }, w); + return groups.map((g, i) => el('div', { marginRight: i < groups.length - 1 ? space : 0 }, g.map(span))); +} + +/** + * The trail after the mark, set the way the site's mono eyebrows are: capitals + * 0.2em apart, one space between words and two either side of each dot. Each + * word is its own element, since satori spaces a run of mono capitals unevenly. + */ +function eyebrow(parts) { + const space = 28 * 0.6 + 5.6; + const items = []; + parts.filter(Boolean).forEach((part, p) => { + if (p) items.push({ t: '\u00b7', gap: space * 2 }); + const words = String(part).toUpperCase().split(/\s+/).filter(Boolean); + words.forEach((w, i) => items.push({ t: w, gap: i < words.length - 1 ? space : space * 2 })); + }); + return el( + 'div', + { alignItems: 'center', fontFamily: MONO, fontSize: 28, fontWeight: 500, letterSpacing: 5.6, color: MUTE, lineHeight: 1 }, + items.map((it, i) => el('div', { marginRight: i < items.length - 1 ? it.gap : 0 }, it.t)) + ); +} + +function lockup(card, art) { + const markWidth = Math.round(MARK_HEIGHT * MARK_RATIO * 100) / 100; + return el( + 'div', + { alignItems: 'center', height: MARK_HEIGHT }, + img(art.mark, markWidth, MARK_HEIGHT, { marginRight: 18 }), + eyebrow(card.eyebrow ?? ['pre.dev']) + ); +} + +function titleBlock(card, size, balance) { + const line = (text, color) => + el( + 'div', + { + width: card.avatar || card.portrait ? INNER - 170 : INNER, + fontSize: size, + fontWeight: 800, + lineHeight: TITLE_LINE, + letterSpacing: -0.03 * size, + color, + ...(balance ? { textWrap: 'balance' } : {}) + }, + text + ); + return el('div', { flexDirection: 'column', marginTop: 30 }, line(card.title, INK), card.dim ? line(card.dim, DIM) : null); +} + +function textBlock(text, size) { + return el( + 'div', + { flexWrap: 'wrap', marginTop: 24, maxWidth: 1040, fontSize: size, fontWeight: 500, lineHeight: TEXT_LINE, letterSpacing: -0.01 * size, color: SOFT }, + richText(text, size, SOFT) + ); +} + +function listBlock(rows) { + return el( + 'div', + { flexDirection: 'column', marginTop: 24 }, + rows.map(([lead, text], i) => + el( + 'div', + { alignItems: 'baseline', marginTop: i ? 12 : 0, fontSize: TEXT_SIZE, fontWeight: 500, color: SOFT, lineHeight: 1.3, whiteSpace: 'nowrap' }, + el('div', { fontFamily: MONO, fontSize: 28, color: MUTE, marginRight: 22 }, lead), + el('div', {}, text) + ) + ) + ); +} + +const band = { borderTop: `1px solid ${RULE}`, paddingTop: 30, width: INNER }; + +function stats(items) { + const five = items.length >= 5; + return el( + 'div', + { ...band, ...(five ? { justifyContent: 'space-between' } : {}) }, + items.map(([value, label], i) => + el( + 'div', + { flexDirection: 'column', marginLeft: !five && i ? 72 : 0 }, + el('div', { fontFamily: MONO, fontSize: 58, fontWeight: 700, color: INK, letterSpacing: -1.16, lineHeight: 1, whiteSpace: 'nowrap' }, value), + el('div', { marginTop: 12, fontSize: 28, fontWeight: 500, color: MUTE, lineHeight: 1.3 }, label) + ) + ) + ); +} + +function chips(items) { + return el( + 'div', + band, + items.map((c, i) => + el( + 'div', + { marginLeft: i ? 20 : 0, fontSize: 28, fontWeight: 500, color: LABEL, border: '1px solid rgba(255,255,255,0.16)', borderRadius: 999, padding: '12px 24px', whiteSpace: 'nowrap' }, + c + ) + ) + ); +} + +function meta([left, right]) { + return el( + 'div', + { ...band, justifyContent: 'space-between', alignItems: 'baseline' }, + el('div', { fontSize: 28, fontWeight: 500, color: LABEL, whiteSpace: 'nowrap' }, left || ''), + el('div', { fontFamily: MONO, fontSize: 28, fontWeight: 500, color: MUTE, whiteSpace: 'nowrap' }, right || '') + ); +} + +/** + * Two bars against a reference line, the /rl-tasks result: { ref: { label, pct }, + * rows: [{ label, value, pct, strong }] }. Shapes are one SVG; text is satori's. + */ +function bars({ ref, rows }) { + const W = INNER; + const H = 182; + const x0 = 300; + const scale = 460 / Math.max(ref?.pct ?? 0, ...rows.map(r => r.pct)); + const refX = ref ? Math.round(x0 + ref.pct * scale) : 0; + const tops = [48, 128]; + let shapes = ref ? `` : ''; + rows.forEach((r, i) => { + shapes += ``; + }); + const svg = `${shapes}`; + return el( + 'div', + { borderTop: `1px solid ${RULE}`, paddingTop: 26, width: INNER }, + el( + 'div', + { position: 'relative', width: W, height: H }, + img(svgDataUrl(svg), W, H, { position: 'absolute', top: 0, left: 0 }), + ref + ? el( + 'div', + { position: 'absolute', top: -6, left: refX - 400, width: 800, justifyContent: 'center', fontFamily: MONO, fontSize: 28, fontWeight: 500, color: MUTE }, + ref.label + ) + : null, + rows.map((r, i) => + el( + 'div', + { position: 'absolute', top: tops[i], left: 0, width: W, height: 40, alignItems: 'center', justifyContent: 'space-between' }, + el('div', { fontSize: 30, fontWeight: 500, color: LABEL }, r.label), + el('div', { fontFamily: MONO, fontSize: 40, fontWeight: 700, color: r.strong ? INK : '#9e9e9e' }, r.value) + ) + ) + ) + ); +} + +function footer(f) { + if (!f) return null; + if (f.stats) return stats(f.stats); + if (f.chips) return chips(f.chips); + if (f.meta) return meta(f.meta); + if (f.bars) return bars(f.bars); + return null; +} + +function avatar(card, art) { + const box = { position: 'absolute', top: PAD, right: PAD, width: 132, height: 132, borderRadius: 132 }; + if (art.portrait) return img(art.portrait, 132, 132, { ...box, objectFit: 'cover', border: '1px solid rgba(255,255,255,0.16)' }); + if (!card.avatar) return null; + return el( + 'div', + { ...box, border: '1px solid rgba(255,255,255,0.16)', backgroundColor: '#1a1a1a', color: STRONG, alignItems: 'center', justifyContent: 'center', fontSize: 52, fontWeight: 600, letterSpacing: -1 }, + card.avatar + ); +} + +/** + * One card's tree. `art` holds data: URLs: `mark` (MARK_SVG), `sky` (skySvg()) and, + * for an author, `portrait`. `fit` is what fitCard() chose; without it the card + * is drawn at the design sizes. + */ +export function cardTree(card, art, fit = {}) { + const titleSize = fit.titleSize ?? TITLE_SIZES[0]; + const text = fit.text ?? card.text; + return el( + 'div', + { position: 'relative', width: CARD_WIDTH, height: CARD_HEIGHT, backgroundColor: '#000', fontFamily: SANS, color: INK, overflow: 'hidden' }, + img(art.sky, CARD_WIDTH, CARD_HEIGHT, { position: 'absolute', top: 0, left: 0 }), + el( + 'div', + { position: 'absolute', top: 0, left: 0, width: CARD_WIDTH, height: CARD_HEIGHT, padding: PAD, flexDirection: 'column' }, + lockup(card, art), + titleBlock(card, titleSize, !!card.fit), + text ? textBlock(text, fit.textSize ?? TEXT_SIZE) : null, + card.list ? listBlock(fit.list ?? card.list) : null, + el('div', { flexGrow: 1 }), + footer(fit.footer ?? card.footer) + ), + avatar(card, art) + ); +} + +// ------------------------------------------------------------------ fitting + +/** How tall the footer band is, so the body knows its room. */ +function footerHeight(f) { + if (!f) return 0; + if (f.stats) return 31 + 58 + 12 + 37; + if (f.chips) return 31 + 60; + if (f.meta) return 31 + 34; + if (f.bars) return 27 + 182; + return 0; +} + +function sentences(text) { + // A sentence ends at . ! or ? before a space, so "pre.dev" and "3.0" stay whole. + return String(text) + .split(/(?<=[.!?](?:\*\*)?)\s+/) + .map(s => s.trim()) + .filter(Boolean); +} + +/** Height satori gives `node` laid out `width` wide. */ +async function heightOf(satori, fonts, node) { + const svg = await satori(el('div', { width: INNER, flexDirection: 'column', fontFamily: SANS }, node), { width: INNER, fonts }); + return Number(svg.match(/height="([\d.]+)"/)?.[1] ?? 0); +} + +async function widthOf(satori, fonts, node) { + const svg = await satori(el('div', { flexDirection: 'row', fontFamily: SANS }, node), { height: 200, fonts }); + return Number(svg.match(/width="([\d.]+)"/)?.[1] ?? 0); +} + +/** + * Fits a card's text to the frame with satori's own measurements. The title + * steps down from 64px until it takes two lines (three at 48px). The support + * copy keeps as many whole sentences as fit in three lines (two under a + * three-line title) at 31px; a single sentence too long for that goes to 28px, + * then ends at a clause or a word with an ellipsis. A list row or meta line + * that would run off the card is shortened the same way. + */ +export async function fitCard(card, satori, fonts) { + const room = CARD_HEIGHT - PAD * 2 - MARK_HEIGHT - footerHeight(card.footer) - 8; + let titleSize = TITLE_SIZES[0]; + let titleHeight = 0; + let titleLines = 0; + for (const size of TITLE_SIZES) { + titleSize = size; + titleHeight = await heightOf(satori, fonts, titleBlock(card, size, !!card.fit)); + titleLines = Math.round((titleHeight - 30) / (size * TITLE_LINE)); + if (titleLines <= (card.dim ? 1 : 0) + (size === TITLE_SIZES.at(-1) ? 3 : 2)) break; + } + const fit = { titleSize }; + if (card.text) { + const maxLines = titleLines >= 3 ? 2 : 3; + const left = room - titleHeight; + const fits = async (text, size) => { + const h = await heightOf(satori, fonts, textBlock(text, size)); + return h <= left && Math.round((h - 24) / (size * TEXT_LINE)) <= maxLines; + }; + const parts = sentences(card.text); + for (let n = parts.length; n > 0 && !fit.text; n--) if (await fits(parts.slice(0, n).join(' '), TEXT_SIZE)) fit.text = parts.slice(0, n).join(' '); + fit.textSize = TEXT_SIZE; + if (!fit.text && (await fits(parts[0], MIN_FONT))) { + fit.text = parts[0]; + fit.textSize = MIN_FONT; + } + if (!fit.text) { + // One sentence too long for the card: end it at the last clause that fits, else at a word. + fit.textSize = MIN_FONT; + const words = parts[0].split(' '); + let cut = null; + for (let n = words.length - 1; n > 3 && !cut; n--) { + const head = words.slice(0, n).join(' '); + if (/[,;:]$/.test(head) && (await fits(`${head.slice(0, -1)}\u2026`, MIN_FONT))) cut = `${head.slice(0, -1)}\u2026`; + } + for (let n = words.length - 1; n > 3 && !cut; n--) { + const text = `${words.slice(0, n).join(' ').replace(/[,;:\u2013-]+$/, '')}\u2026`; + if (await fits(text, MIN_FONT)) cut = text; + } + fit.text = cut ?? `${words.slice(0, 4).join(' ')}\u2026`; + } + } + const shorten = async (text, width, style) => { + let t = text; + while (t.includes(' ') && (await widthOf(satori, fonts, el('div', style, t))) > width) t = `${t.slice(0, t.lastIndexOf(' ')).replace(/[\u2026,;:]+$/, '')}\u2026`; + return t; + }; + if (card.list) { + fit.list = []; + for (const [lead, text] of card.list) fit.list.push([lead, await shorten(text, INNER - (28 * 0.6 * [...lead].length + 22), { fontSize: TEXT_SIZE, fontWeight: 500 })]); + } + if (card.footer?.meta) { + // The address on the right stays whole; the line on the left gives way to it when both don't fit. + const [l, r] = card.footer.meta; + const right = r ? await widthOf(satori, fonts, el('div', { fontFamily: MONO, fontSize: 28, fontWeight: 500 }, r)) : 0; + const fitsLeft = l && (await widthOf(satori, fonts, el('div', { fontSize: 28, fontWeight: 500 }, l))) <= INNER - right - 40; + fit.footer = { meta: [fitsLeft ? l : '', r] }; + } + return fit; +} + +// ------------------------------------------------------------------ checks + +/** Every problem with a tree: any text under MIN_FONT. Empty when the card is fine. */ +export function checkCard(tree) { + const errors = []; + const walk = (node, size) => { + if (!node || typeof node !== 'object') return; + const s = node.props?.style?.fontSize ?? size; + const kids = node.props?.children; + const list = Array.isArray(kids) ? kids : [kids]; + if (list.some(k => typeof k === 'string' && k.trim()) && s < MIN_FONT) errors.push(`"${list.filter(k => typeof k === 'string').join(' ').slice(0, 40)}" is ${s}px (min ${MIN_FONT}px)`); + for (const k of list) walk(k, s); + }; + walk(tree, 16); + return errors; +} + +/** The card's alt text: its headline, its support line, then what the footer shows. At most 420 characters (X's limit). */ +export function cardAlt(card) { + const sentence = t => (/[.!?,:]$/.test(t) ? t : `${t}.`); + const f = card.footer ?? {}; + const parts = [sentence([card.title, card.dim].filter(Boolean).join(' ')), plainText(card.text ?? '')]; + if (card.list) parts.push(sentence(`Latest: ${card.list.map(([, t]) => t).join('; ')}`)); + if (f.stats) parts.push(sentence(f.stats.map(([v, l]) => `${v} ${l}`).join(', '))); + if (f.bars) parts.push(sentence(f.bars.rows.map(r => `${r.label} ${r.value}`).join(', '))); + if (f.chips) parts.push(sentence(f.chips.join(', '))); + let alt = parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); + if (alt.length > 420) alt = `${alt.slice(0, 419).replace(/\s+\S*$/, '')}\u2026`; + return alt; +} diff --git a/site/next.config.mjs b/site/next.config.mjs index 5a961c1..1ae5250 100644 --- a/site/next.config.mjs +++ b/site/next.config.mjs @@ -7,6 +7,8 @@ const config = { output: 'export', reactStrictMode: true, images: { unoptimized: true }, + // the share card renderer (app/og) runs from node_modules at build time: resvg is a native addon + serverExternalPackages: ['satori', '@resvg/resvg-js'], // MDX sources and snippets live in the repo root outputFileTracingRoot: new URL('..', import.meta.url).pathname, turbopack: { root: new URL('..', import.meta.url).pathname }, diff --git a/site/package-lock.json b/site/package-lock.json index 1734a69..be3ad93 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -31,6 +31,8 @@ }, "devDependencies": { "@fontsource/inter": "5.3.0", + "@fontsource/jetbrains-mono": "5.3.0", + "@resvg/resvg-js": "2.6.2", "@tailwindcss/postcss": "4.3.3", "@types/hast": "3.0.5", "@types/mdast": "4.0.4", @@ -39,6 +41,7 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "postcss": "8.5.28", + "satori": "0.33.4", "tailwindcss": "4.3.3", "typescript": "6.0.3", "wrangler": "4.144.0" @@ -745,6 +748,16 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fuma-translate/react": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@fuma-translate/react/-/react-1.0.2.tgz", @@ -2429,6 +2442,234 @@ "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "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" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@scalar/helpers": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.12.0.tgz", @@ -2575,6 +2816,30 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@shuding/opentype.js": { + "version": "1.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", + "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fflate": "^0.7.3", + "string.prototype.codepointat": "^0.2.1" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@shuding/opentype.js/node_modules/fflate": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.5.tgz", + "integrity": "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g==", + "dev": true, + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -3343,6 +3608,16 @@ "ieee754": "^1.1.13" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001816", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", @@ -3484,6 +3759,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -3537,6 +3819,52 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/css-background-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", + "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-box-shadow": { + "version": "1.0.0-3", + "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", + "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-gradient-parser": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz", + "integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3656,6 +3984,16 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/emoji-regex-xs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz", + "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -3775,6 +4113,13 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -4349,6 +4694,13 @@ "dev": true, "license": "ISC" }, + "node_modules/harfbuzzjs": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-0.10.0.tgz", + "integrity": "sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw==", + "dev": true, + "license": "MIT" + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -4534,6 +4886,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hex-rgb": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", + "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -4979,6 +5344,27 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -6967,12 +7353,30 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "dev": true, + "license": "MIT" + }, "node_modules/papaparse": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz", "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==", "license": "MIT" }, + "node_modules/parse-css-color": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", + "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^1.1.4", + "hex-rgb": "^4.1.0" + } + }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -7085,6 +7489,13 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/posthog-js": { "version": "1.435.1", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.435.1.tgz", @@ -7604,6 +8015,38 @@ "license": "MIT", "optional": true }, + "node_modules/satori": { + "version": "0.33.4", + "resolved": "https://registry.npmjs.org/satori/-/satori-0.33.4.tgz", + "integrity": "sha512-dCnaW6D/tkCJxG5UvpZPWnnvfTdGTVVSAq1tzH6xC5MeOhAeeswRcSq5zT7C6lRCdTGow9PRGus7PCzdp5JJYw==", + "dev": true, + "license": "MPL-2.0", + "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.0.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", + "yoga-layout": "^3.2.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/satori/node_modules/fflate": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.3.tgz", + "integrity": "sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw==", + "dev": true, + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -7823,6 +8266,13 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -7983,6 +8433,13 @@ "node": ">=6" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", @@ -8130,6 +8587,17 @@ "pathe": "^2.0.3" } }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -8968,6 +9436,13 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "dev": true, + "license": "MIT" + }, "node_modules/youch": { "version": "4.1.0-beta.10", "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", diff --git a/site/package.json b/site/package.json index 1e2ea7a..88e87f7 100644 --- a/site/package.json +++ b/site/package.json @@ -39,6 +39,8 @@ }, "devDependencies": { "@fontsource/inter": "5.3.0", + "@fontsource/jetbrains-mono": "5.3.0", + "@resvg/resvg-js": "2.6.2", "@tailwindcss/postcss": "4.3.3", "@types/hast": "3.0.5", "@types/mdast": "4.0.4", @@ -47,6 +49,7 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "postcss": "8.5.28", + "satori": "0.33.4", "tailwindcss": "4.3.3", "typescript": "6.0.3", "wrangler": "4.144.0" diff --git a/site/scripts/check-out.mjs b/site/scripts/check-out.mjs index 4184bf3..eba4252 100644 --- a/site/scripts/check-out.mjs +++ b/site/scripts/check-out.mjs @@ -1,5 +1,5 @@ -// Checks the static build in out/: every docs.json page has HTML and Markdown, -// every internal link and anchor resolves, every image exists and has alt text, +// Checks the static build in out/: every docs.json page has HTML, Markdown and +// its own share card with alt text, every internal link and anchor resolves, every image exists and has alt text, // every redirect lands on a page, llms.txt and API catalog links resolve, and // the agent discovery files exist. import fs from 'node:fs'; @@ -39,7 +39,48 @@ function resolve(urlPath) { for (const slug of navPages) { if (!fs.existsSync(path.join(out, `${slug}.html`))) errors.push(`missing HTML for nav page ${slug}`); if (!fs.existsSync(path.join(out, `${slug}.md`))) errors.push(`missing Markdown for nav page ${slug}`); - if (!fs.existsSync(path.join(out, 'og', slug, 'image.png'))) errors.push(`missing OG image for ${slug}`); +} + +// 1b. Share cards: each page's og:image and twitter:image are its own card at +// /og//.png, a 1200x630 PNG in the build, with alt text and the full tag set +const metaTag = (html, key) => html.match(new RegExp(` + text.replace(/&(?:#x([0-9a-f]+)|#(\d+)|(amp|lt|gt|quot|apos));/gi, (_, hex, dec, name) => + hex ? String.fromCodePoint(parseInt(hex, 16)) : dec ? String.fromCodePoint(Number(dec)) : { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }[name.toLowerCase()], + ); +let cardCount = 0; +for (const slug of navPages) { + const file = path.join(out, `${slug}.html`); + if (!fs.existsSync(file)) continue; + const html = fs.readFileSync(file, 'utf8'); + const tag = (key) => metaTag(html, key); + const problem = (text) => errors.push(`${slug}: ${text}`); + const title = tag('og:title'); + if (!title) problem('no og:title'); + else if (html.match(/([^<]*)<\/title>/)?.[1] !== `${title} - ${docs.name}`) problem(`og:title "${title}" is not the page title alone`); + if (tag('og:site_name') !== docs.name) problem(`og:site_name should be ${docs.name}`); + const image = tag('og:image'); + const expected = new RegExp(`^https://docs\\.pre\\.dev/og/${slug}/[0-9a-f]{10}\\.png$`); + if (!image || !expected.test(image)) { + problem(`og:image ${image ?? '(none)'} is not the page's card at /og/${slug}/<hash>.png`); + continue; + } + const png = path.join(out, new URL(image).pathname); + if (!fs.existsSync(png)) problem(`card missing from the build: ${new URL(image).pathname}`); + else { + const head = fs.readFileSync(png).subarray(0, 24); + const size = head.toString('latin1', 12, 16) === 'IHDR' ? `${head.readUInt32BE(16)}x${head.readUInt32BE(20)}` : 'not a PNG'; + if (size !== '1200x630') problem(`card ${new URL(image).pathname} is ${size}, should be 1200x630`); + else cardCount++; + } + if (tag('og:image:width') !== '1200' || tag('og:image:height') !== '630') problem('og:image:width/height should be 1200/630'); + const alt = tag('og:image:alt'); + if (!alt?.trim()) problem('no og:image:alt'); + else if (!alt.startsWith(title ?? '') || decodeHtml(alt).length > 420) problem(`og:image:alt should start with the title and stay under 420 characters: ${alt}`); + if (tag('twitter:card') !== 'summary_large_image') problem('twitter:card should be summary_large_image'); + if (tag('twitter:image') !== image) problem(`twitter:image ${tag('twitter:image') ?? '(none)'} should be ${image}`); + if (tag('twitter:image:alt') !== alt) problem('twitter:image:alt should match og:image:alt'); + if (!fs.existsSync(path.join(out, 'og', slug, 'image.png'))) problem(`missing /og/${slug}/image.png (the card's old address)`); } // 2. Redirects land on real pages and never hide one @@ -137,4 +178,4 @@ if (errors.length) { for (const e of errors) console.error(` ${e}`); process.exit(1); } -console.log(`check-out: ${navPages.length} pages, ${redirects.size} redirects, ${linkCount} internal links OK`); +console.log(`check-out: ${navPages.length} pages, ${cardCount} share cards, ${redirects.size} redirects, ${linkCount} internal links OK`); diff --git a/site/scripts/postbuild.mjs b/site/scripts/postbuild.mjs index 3ab4990..ea2201f 100644 --- a/site/scripts/postbuild.mjs +++ b/site/scripts/postbuild.mjs @@ -1,4 +1,5 @@ -// Moves the build-time Markdown routes to /<page>.md and checks the output. +// Moves the build-time Markdown routes to /<page>.md, gives each share card its +// old address too, and checks the output. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -18,6 +19,15 @@ for (const entry of fs.readdirSync(mdDir, { recursive: true, withFileTypes: true } fs.rmSync(mdDir, { recursive: true, force: true }); +// Share cards live at /og/<page>/<hash>.png. The address before hashed cards, +// /og/<page>/image.png, keeps answering with the page's current card. +let cards = 0; +for (const entry of fs.readdirSync(path.join(out, 'og'), { recursive: true, withFileTypes: true })) { + if (!entry.isFile() || !/^[0-9a-f]{10}\.png$/.test(entry.name)) continue; + fs.copyFileSync(path.join(entry.parentPath, entry.name), path.join(entry.parentPath, 'image.png')); + cards++; +} + // Every HTML page needs its Markdown twin const missing = []; for (const entry of fs.readdirSync(out, { recursive: true, withFileTypes: true })) { @@ -30,4 +40,4 @@ if (missing.length) { console.error(`postbuild: pages without Markdown: ${missing.join(', ')}`); process.exit(1); } -console.log(`postbuild: ${moved} Markdown pages in place`); +console.log(`postbuild: ${moved} Markdown pages in place, ${cards} share cards`); diff --git a/site/worker/index.ts b/site/worker/index.ts index 1134bdb..7a71eeb 100644 --- a/site/worker/index.ts +++ b/site/worker/index.ts @@ -95,6 +95,8 @@ const CSP = [ /** Browser cache lifetime by path; everything else revalidates on each visit. */ function cacheControl(pathname: string): string | undefined { if (pathname.startsWith('/_next/static/')) return 'public, max-age=31536000, immutable'; + // share cards: the hash in the name changes whenever the card does + if (/^\/og\/.+\/[0-9a-f]{10}\.png$/.test(pathname)) return 'public, max-age=31536000, immutable'; if (pathname.startsWith('/og/') || pathname.startsWith('/images/') || pathname === '/favicon.ico') { return 'public, max-age=86400, stale-while-revalidate=604800'; } From 8929fcd07930d5d04ece7d20ec85fb4e053b725d Mon Sep 17 00:00:00 2001 From: arjunpredev <141591528+arjunpredev@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:07:44 -0400 Subject: [PATCH 2/2] Share card spec names its docs path; rewrap the check-out header Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- site/lib/share-card.mjs | 2 +- site/scripts/check-out.mjs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/site/lib/share-card.mjs b/site/lib/share-card.mjs index 63e3422..6499f67 100644 --- a/site/lib/share-card.mjs +++ b/site/lib/share-card.mjs @@ -7,7 +7,7 @@ * predev-app frontend/scripts/og/share-card.mjs (the original) * backend/src/helpers/share_card.ts * masthead packages/server/src/card-layout.ts (the "pre.dev" design) - * docs scripts/og/share-card.mjs + * docs site/lib/share-card.mjs * * Change the design here first, copy it to the others, and bump CARD_DESIGN * so every card is drawn again under a new address. diff --git a/site/scripts/check-out.mjs b/site/scripts/check-out.mjs index eba4252..d46c2bd 100644 --- a/site/scripts/check-out.mjs +++ b/site/scripts/check-out.mjs @@ -1,7 +1,7 @@ // Checks the static build in out/: every docs.json page has HTML, Markdown and -// its own share card with alt text, every internal link and anchor resolves, every image exists and has alt text, -// every redirect lands on a page, llms.txt and API catalog links resolve, and -// the agent discovery files exist. +// its own share card with alt text, every internal link and anchor resolves, +// every image exists and has alt text, every redirect lands on a page, llms.txt +// and API catalog links resolve, and the agent discovery files exist. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url';