From 643339d1ec8e99424c21ce7ac86fcb7c30cf94f6 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 30 Jul 2026 11:15:24 +0200 Subject: [PATCH 1/6] Add dynamic README header image endpoint Library repo READMEs each open with a hand-made banner PNG committed to `media/header_*.png`. They are inconsistent in size (query is 3000x1704, table is 1728x874), too tall for a README, and updating the logo or a tagline means re-exporting images across a dozen repos. `GET /api/readme/.png` renders that banner on demand at 1800x450, reusing the takumi renderer, brand assets and per-category accent colors that already back the OG cards. Repos point an at the URL instead of committing a file. - `?framework=` names per-package READMEs ("TanStack React Start"), validated against the library's framework list so a typo is a 400 rather than a banner naming the wrong package - `?title=` / `?subtitle=` override the name and tagline - the render path (assets, fonts, takumi module) is now shared between the OG cards and the README headers instead of duplicated - `pnpm run readme:preview` renders every library and framework variant to `.readme-preview/` with a gallery at GitHub's 900px render width --- .gitignore | 1 + docs/readme-headers.md | 81 ++++++++++++ package.json | 1 + .../brand/tanstack-emblem-charcoal-256.png | Bin 0 -> 9117 bytes scripts/generate-brand-assets.mjs | 9 ++ scripts/readme-header-preview.ts | 110 ++++++++++++++++ src/routeTree.gen.ts | 22 ++++ src/routes/api/readme/{$}[.]png.ts | 85 ++++++++++++ src/server/og/assets.server.ts | 9 +- src/server/og/generate.server.ts | 123 ++++++++++++++---- src/server/og/readme-template.tsx | 84 ++++++++++++ src/server/og/template.tsx | 2 +- 12 files changed, 502 insertions(+), 25 deletions(-) create mode 100644 docs/readme-headers.md create mode 100644 public/images/brand/tanstack-emblem-charcoal-256.png create mode 100644 scripts/readme-header-preview.ts create mode 100644 src/routes/api/readme/{$}[.]png.ts create mode 100644 src/server/og/readme-template.tsx diff --git a/.gitignore b/.gitignore index e1626d4ff..b581a46e5 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ test-results .tsbuildinfo src/routeTree.gen.ts .og-preview/ +.readme-preview/ diff --git a/docs/readme-headers.md b/docs/readme-headers.md new file mode 100644 index 000000000..c19efe94a --- /dev/null +++ b/docs/readme-headers.md @@ -0,0 +1,81 @@ +# README header images + +`GET /api/readme/.png` renders the banner that goes at the top of a +library repo's README, replacing the hand-made PNGs previously committed to +`media/header_*.png` in each repo. It uses the same takumi renderer, brand +assets and per-category accent colors as the site's OG cards, so a branding +change ships to every README at once. + +Output is 1800×450 (4:1). At GitHub's ~896px README content width that renders +at 2x and takes up ~224px of height, leaving the badges and intro above the +fold. + +## Usage + +```html +TanStack Query +``` + +For a package README inside a multi-framework repo (e.g. +`packages/react-start/README.md`), add `?framework=`: + +```html +TanStack React Start +``` + +The framework label is inserted after the `TanStack` prefix: +`start` + `react` → **TanStack React Start**. + +## Parameters + +| Param | Required | Behavior | +| ----------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | +| path splat | yes | Library id (`query`, `router`, `start`, …). Unknown id → `404`. | +| `framework` | no | Must be one of the library's supported frameworks. Anything else → `400` listing the accepted values. | +| `title` | no | Replaces the rendered name entirely. Clamped to 80 chars. Takes precedence over `framework`, which is then not applied. | +| `subtitle` | no | Replaces the tagline. Clamped to 160 chars. | + +An invalid `framework` is rejected rather than ignored, so a typo in a README +shows up as a broken image during review instead of a banner naming the wrong +package. + +## Caching + +The endpoint sends `max-age=3600` plus a 24h Cloudflare CDN TTL with +`stale-while-revalidate`. GitHub additionally proxies README images through +camo, which caches on its own schedule — expect a banner change to take a while +to appear on GitHub even after the endpoint updates. That is fine for branding +assets; don't use this endpoint for anything time-sensitive. + +## Previewing changes locally + +```sh +pnpm run readme:preview +``` + +Renders every library's header — plus one per supported framework — to +`.readme-preview/`, along with an `index.html` gallery that displays them at +GitHub's 900px render width. Open `.readme-preview/index.html` to check that no +long name or tagline overflows. Run this after touching +`src/server/og/readme-template.tsx`. + +(`scripts/og-preview.ts` is the equivalent for the 1200×630 social cards.) + +## Implementation + +| File | Role | +| ------------------------------------ | ---------------------------------------------------------------- | +| `src/routes/api/readme/{$}[.]png.ts` | Route handler: param parsing, validation, cache headers | +| `src/server/og/generate.server.ts` | `generateReadmeHeaderResponse` + the render path shared with OG | +| `src/server/og/readme-template.tsx` | The 1800×450 layout | +| `src/server/og/assets.server.ts` | Loads fonts and the raster brand emblem | +| `scripts/generate-brand-assets.mjs` | Generates `public/images/brand/tanstack-emblem-charcoal-256.png` | +| `scripts/readme-header-preview.ts` | Local render + gallery for reviewing layout changes | diff --git a/package.json b/package.json index f1e57af14..fd8619eaf 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "cf:typegen": "wrangler types", "content:build": "node scripts/build-content-collections.mjs", "brand:generate": "node scripts/generate-brand-assets.mjs", + "readme:preview": "tsx scripts/readme-header-preview.ts", "icons:generate": "node scripts/generate-phosphor-icon-registry.mjs", "lint": "pnpm run content:build && pnpm run lint:code", "lint:code": "oxlint --type-aware --disable-nested-config", diff --git a/public/images/brand/tanstack-emblem-charcoal-256.png b/public/images/brand/tanstack-emblem-charcoal-256.png new file mode 100644 index 0000000000000000000000000000000000000000..168583c9711a664b598e17f3be77d50af3010226 GIT binary patch literal 9117 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[0], +) { + const result = await generateReadmeHeaderResponse(input) + if ('kind' in result) { + console.warn(`[skip] ${fileName}: ${result.kind}`) + return false + } + writeFileSync( + resolve(OUT_DIR, fileName), + Buffer.from(await result.arrayBuffer()), + ) + console.log(`[ok] ${fileName}`) + return true +} + +function buildGallery( + entries: Array<{ name: string; files: Array<{ file: string; url: string }> }>, +) { + return ` + +TanStack README header preview + +

GitHub README headers — local render

+

Shown at 900px, the width GitHub renders README images at. +Each caption is the endpoint URL a repo would put in its README.

+${entries + .map( + (entry) => `
+

${entry.name}

+ ${entry.files + .map( + ({ file, url }) => + `
${url}
`, + ) + .join('\n ')} +
`, + ) + .join('\n')} +` +} + +async function main() { + mkdirSync(OUT_DIR, { recursive: true }) + + const entries: Array<{ + name: string + files: Array<{ file: string; url: string }> + }> = [] + + for (const lib of libraries) { + if (!lib.to) continue // skip entries without a landing page (react-charts, create-tsrouter-app) + + const files: Array<{ file: string; url: string }> = [] + + const base = `${lib.id}-readme.png` + if (await renderToFile(base, { libraryId: lib.id })) { + files.push({ file: base, url: `/api/readme/${lib.id}.png` }) + } + + // Per-package variants, for repos whose framework packages ship their own + // READMEs (packages/react-start/README.md and friends). + for (const framework of lib.frameworks as Array) { + const file = `${lib.id}-readme-${framework}.png` + if (await renderToFile(file, { libraryId: lib.id, framework })) { + files.push({ + file, + url: `/api/readme/${lib.id}.png?framework=${framework}`, + }) + } + } + + entries.push({ name: lib.name, files }) + } + + writeFileSync(resolve(OUT_DIR, 'index.html'), buildGallery(entries)) + console.log(`[ok] index.html`) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 6ae4c6445..819759c15 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -121,6 +121,7 @@ import { Route as ShopCollectionsHandleRouteImport } from './routes/shop.collect import { Route as IntentRegistryPackageNameRouteImport } from './routes/intent/registry/$packageName' import { Route as ChartsCatalogCatalogDotjsonRouteImport } from './routes/charts.catalog_.catalog[.]json' import { Route as AuthProviderStartRouteImport } from './routes/auth/$provider/start' +import { Route as ApiReadmeChar123Char125DotpngRouteImport } from './routes/api/readme/{$}[.]png' import { Route as ApiOgChar123Char125DotpngRouteImport } from './routes/api/og/{$}[.]png' import { Route as ApiMcpSplatRouteImport } from './routes/api/mcp/$' import { Route as ApiGithubWebhookRouteImport } from './routes/api/github/webhook' @@ -759,6 +760,12 @@ const AuthProviderStartRoute = AuthProviderStartRouteImport.update({ path: '/auth/$provider/start', getParentRoute: () => rootRouteImport, } as any) +const ApiReadmeChar123Char125DotpngRoute = + ApiReadmeChar123Char125DotpngRouteImport.update({ + id: '/api/readme/{$}.png', + path: '/api/readme/{$}.png', + getParentRoute: () => rootRouteImport, + } as any) const ApiOgChar123Char125DotpngRoute = ApiOgChar123Char125DotpngRouteImport.update({ id: '/api/og/{$}.png', @@ -1292,6 +1299,7 @@ export interface FileRoutesByFullPath { '/api/github/webhook': typeof ApiGithubWebhookRoute '/api/mcp/$': typeof ApiMcpSplatRoute '/api/og/{$}.png': typeof ApiOgChar123Char125DotpngRoute + '/api/readme/{$}.png': typeof ApiReadmeChar123Char125DotpngRoute '/auth/$provider/start': typeof AuthProviderStartRoute '/charts/catalog/catalog.json': typeof ChartsCatalogCatalogDotjsonRoute '/intent/registry/$packageName': typeof IntentRegistryPackageNameRouteWithChildren @@ -1468,6 +1476,7 @@ export interface FileRoutesByTo { '/api/github/webhook': typeof ApiGithubWebhookRoute '/api/mcp/$': typeof ApiMcpSplatRoute '/api/og/{$}.png': typeof ApiOgChar123Char125DotpngRoute + '/api/readme/{$}.png': typeof ApiReadmeChar123Char125DotpngRoute '/auth/$provider/start': typeof AuthProviderStartRoute '/charts/catalog/catalog.json': typeof ChartsCatalogCatalogDotjsonRoute '/shop/collections/$handle': typeof ShopCollectionsHandleRoute @@ -1654,6 +1663,7 @@ export interface FileRoutesById { '/api/github/webhook': typeof ApiGithubWebhookRoute '/api/mcp/$': typeof ApiMcpSplatRoute '/api/og/{$}.png': typeof ApiOgChar123Char125DotpngRoute + '/api/readme/{$}.png': typeof ApiReadmeChar123Char125DotpngRoute '/auth/$provider/start': typeof AuthProviderStartRoute '/charts/catalog_/catalog.json': typeof ChartsCatalogCatalogDotjsonRoute '/intent/registry/$packageName': typeof IntentRegistryPackageNameRouteWithChildren @@ -1842,6 +1852,7 @@ export interface FileRouteTypes { | '/api/github/webhook' | '/api/mcp/$' | '/api/og/{$}.png' + | '/api/readme/{$}.png' | '/auth/$provider/start' | '/charts/catalog/catalog.json' | '/intent/registry/$packageName' @@ -2018,6 +2029,7 @@ export interface FileRouteTypes { | '/api/github/webhook' | '/api/mcp/$' | '/api/og/{$}.png' + | '/api/readme/{$}.png' | '/auth/$provider/start' | '/charts/catalog/catalog.json' | '/shop/collections/$handle' @@ -2203,6 +2215,7 @@ export interface FileRouteTypes { | '/api/github/webhook' | '/api/mcp/$' | '/api/og/{$}.png' + | '/api/readme/{$}.png' | '/auth/$provider/start' | '/charts/catalog_/catalog.json' | '/intent/registry/$packageName' @@ -2339,6 +2352,7 @@ export interface RootRouteChildren { ApiGithubWebhookRoute: typeof ApiGithubWebhookRoute ApiMcpSplatRoute: typeof ApiMcpSplatRoute ApiOgChar123Char125DotpngRoute: typeof ApiOgChar123Char125DotpngRoute + ApiReadmeChar123Char125DotpngRoute: typeof ApiReadmeChar123Char125DotpngRoute AuthProviderStartRoute: typeof AuthProviderStartRoute ChartsCatalogCatalogDotjsonRoute: typeof ChartsCatalogCatalogDotjsonRoute IntentRegistryPackageNameRoute: typeof IntentRegistryPackageNameRouteWithChildren @@ -3143,6 +3157,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthProviderStartRouteImport parentRoute: typeof rootRouteImport } + '/api/readme/{$}.png': { + id: '/api/readme/{$}.png' + path: '/api/readme/{$}.png' + fullPath: '/api/readme/{$}.png' + preLoaderRoute: typeof ApiReadmeChar123Char125DotpngRouteImport + parentRoute: typeof rootRouteImport + } '/api/og/{$}.png': { id: '/api/og/{$}.png' path: '/api/og/{$}.png' @@ -4084,6 +4105,7 @@ const rootRouteChildren: RootRouteChildren = { ApiGithubWebhookRoute: ApiGithubWebhookRoute, ApiMcpSplatRoute: ApiMcpSplatRoute, ApiOgChar123Char125DotpngRoute: ApiOgChar123Char125DotpngRoute, + ApiReadmeChar123Char125DotpngRoute: ApiReadmeChar123Char125DotpngRoute, AuthProviderStartRoute: AuthProviderStartRoute, ChartsCatalogCatalogDotjsonRoute: ChartsCatalogCatalogDotjsonRoute, IntentRegistryPackageNameRoute: IntentRegistryPackageNameRouteWithChildren, diff --git a/src/routes/api/readme/{$}[.]png.ts b/src/routes/api/readme/{$}[.]png.ts new file mode 100644 index 000000000..4c9e9b6fe --- /dev/null +++ b/src/routes/api/readme/{$}[.]png.ts @@ -0,0 +1,85 @@ +import { createFileRoute } from '@tanstack/react-router' +import { findLibrary } from '~/libraries' +import type { Framework } from '~/libraries/types' + +type GenerateReadmeHeaderResponse = typeof import( + '~/server/og/generate.server' +)['generateReadmeHeaderResponse'] + +const CACHE_HEADERS = { + 'Cache-Control': 'public, max-age=3600', + 'Cloudflare-CDN-Cache-Control': + 'public, max-age=86400, stale-while-revalidate=604800', +} as const + +export const Route = createFileRoute('/api/readme/{$}.png')({ + server: { + handlers: { + GET: async ({ + request, + params, + }: { + request: Request + params: { _splat: string } + }) => { + const libraryId = params._splat.replace(/\.png$/, '') + const library = findLibrary(libraryId) + if (!library) { + return new Response(`Unknown library: ${libraryId}`, { status: 404 }) + } + + const url = new URL(request.url) + + // Validated rather than ignored: a typo'd framework in a README should + // surface as a broken image, not as a banner naming the wrong package. + const framework = url.searchParams.get('framework') ?? undefined + if (framework && !library.frameworks.includes(framework as Framework)) { + return new Response( + `Unknown framework "${framework}" for ${library.name}. Expected one of: ${library.frameworks.join(', ')}`, + { status: 400 }, + ) + } + + let result: Awaited> + try { + const { generateReadmeHeaderResponse } = await import( + '~/server/og/generate.server' + ) + result = await generateReadmeHeaderResponse( + { + libraryId, + requestUrl: request.url, + framework: framework as Framework | undefined, + title: url.searchParams.get('title') ?? undefined, + subtitle: url.searchParams.get('subtitle') ?? undefined, + }, + { headers: CACHE_HEADERS }, + ) + } catch (error) { + console.error('Failed to construct README header response', error) + return new Response('Failed to generate README header', { + status: 500, + }) + } + + if ('kind' in result) { + return new Response(`Unknown library: ${libraryId}`, { status: 404 }) + } + + // ImageResponse builds the Response synchronously and renders inside + // a ReadableStream. Await the ready promise so render errors surface + // as 500s instead of an empty 200 cached at the edge. + try { + await result.ready + } catch (error) { + console.error('Failed to generate README header', error) + return new Response('Failed to generate README header', { + status: 500, + }) + } + + return result + }, + }, + }, +}) diff --git a/src/server/og/assets.server.ts b/src/server/og/assets.server.ts index 4f9e88909..7986cb813 100644 --- a/src/server/og/assets.server.ts +++ b/src/server/og/assets.server.ts @@ -6,6 +6,7 @@ import { fetchStaticAsset } from '~/server/runtime/host.server' const interRegularUrl = '/fonts/Inter-Regular.ttf' const bricolageBoldUrl = '/fonts/BricolageGrotesque-Bold.ttf' const brandLogoPngUrl = '/images/brand/tanstack-landscape-black-640.png' +const brandEmblemPngUrl = '/images/brand/tanstack-emblem-charcoal-256.png' function tryReadBinary(relPath: string): Buffer | null { // Resolve from the project root for local dev and tests. Workers normally @@ -31,6 +32,7 @@ let cached: { interRegular: Buffer bricolageBold: Buffer brandLogoPng: Buffer + brandEmblemPng: Buffer } | null = null export async function loadOgAssets(requestUrl?: string) { @@ -43,12 +45,16 @@ export async function loadOgAssets(requestUrl?: string) { const brandLogoPng = tryReadBinary( 'public/images/brand/tanstack-landscape-black-640.png', ) + const brandEmblemPng = tryReadBinary( + 'public/images/brand/tanstack-emblem-charcoal-256.png', + ) - if (interRegular && bricolageBold && brandLogoPng) { + if (interRegular && bricolageBold && brandLogoPng && brandEmblemPng) { cached = { interRegular, bricolageBold, brandLogoPng, + brandEmblemPng, } return cached } @@ -61,6 +67,7 @@ export async function loadOgAssets(requestUrl?: string) { interRegular: await readAssetUrl(interRegularUrl, requestUrl), bricolageBold: await readAssetUrl(bricolageBoldUrl, requestUrl), brandLogoPng: await readAssetUrl(brandLogoPngUrl, requestUrl), + brandEmblemPng: await readAssetUrl(brandEmblemPngUrl, requestUrl), } return cached } diff --git a/src/server/og/generate.server.ts b/src/server/og/generate.server.ts index d5931885d..cb6352388 100644 --- a/src/server/og/generate.server.ts +++ b/src/server/og/generate.server.ts @@ -3,11 +3,14 @@ import { type ImageResponseOptions, } from '@takumi-rs/image-response' import takumiWasmModule from '@takumi-rs/wasm/auto' +import type { ReactElement } from 'react' import { findLibrary } from '~/libraries' import type { LibraryId } from '~/libraries' +import type { Framework } from '~/libraries/types' import { loadOgAssets as loadNodeOgAssets } from './assets.server' import { getAccentColor } from './colors' import { buildOgTree } from './template' +import { buildReadmeHeaderTree } from './readme-template' import { MAX_OG_DESCRIPTION_LENGTH, MAX_OG_TITLE_LENGTH, @@ -15,6 +18,7 @@ import { } from '~/utils/og-limits' const BRAND_LOGO_KEY = 'brand-logo' +const BRAND_EMBLEM_KEY = 'brand-emblem' type GenerateInput = { libraryId: LibraryId | string @@ -23,11 +27,57 @@ type GenerateInput = { description?: string } +export type ReadmeHeaderInput = { + libraryId: LibraryId | string + requestUrl?: string + /** Already validated against the library's framework list by the route. */ + framework?: Framework + title?: string + subtitle?: string +} + export type OgLibraryNotFoundError = { kind: 'library-not-found' libraryId: string } +async function renderOgImage( + tree: ReactElement, + size: { width: number; height: number }, + requestUrl: string | undefined, + init?: ResponseInit, +): Promise { + const assets = await loadNodeOgAssets(requestUrl) + + const options: ImageResponseOptions = { + width: size.width, + height: size.height, + format: 'png', + fonts: [ + { + name: 'Inter', + data: assets.interRegular, + weight: 400, + style: 'normal', + }, + { + name: 'Bricolage Grotesque', + data: assets.bricolageBold, + weight: 700, + style: 'normal', + }, + ], + images: [ + { src: BRAND_LOGO_KEY, data: assets.brandLogoPng }, + { src: BRAND_EMBLEM_KEY, data: assets.brandEmblemPng }, + ], + module: takumiWasmModule, + ...init, + } + + return new ImageResponse(tree, options) +} + export async function generateOgImageResponse( input: GenerateInput, init?: ResponseInit, @@ -37,7 +87,6 @@ export async function generateOgImageResponse( return { kind: 'library-not-found', libraryId: input.libraryId } } - const assets = await loadNodeOgAssets(input.requestUrl) const tree = buildOgTree({ libraryName: library.name, accentColor: getAccentColor(library.id), @@ -51,28 +100,56 @@ export async function generateOgImageResponse( : undefined, }) - const options: ImageResponseOptions = { - width: 1200, - height: 630, - format: 'png', - fonts: [ - { - name: 'Inter', - data: assets.interRegular, - weight: 400, - style: 'normal', - }, - { - name: 'Bricolage Grotesque', - data: assets.bricolageBold, - weight: 700, - style: 'normal', - }, - ], - images: [{ src: BRAND_LOGO_KEY, data: assets.brandLogoPng }], - module: takumiWasmModule, - ...init, + return renderOgImage( + tree, + { width: 1200, height: 630 }, + input.requestUrl, + init, + ) +} + +// "TanStack Start" + react → "TanStack React Start" +// +// Capitalizing the framework id reproduces every label in `frameworkOptions` +// (react → React, vanilla → Vanilla, …). Importing that module here is not an +// option: it pulls in framework logo SVGs, which breaks server/script bundles. +function withFrameworkLabel(name: string, framework: Framework): string { + const label = framework.charAt(0).toUpperCase() + framework.slice(1) + return name.startsWith('TanStack ') + ? `TanStack ${label} ${name.slice('TanStack '.length)}` + : `${label} ${name}` +} + +export async function generateReadmeHeaderResponse( + input: ReadmeHeaderInput, + init?: ResponseInit, +): Promise { + const library = findLibrary(input.libraryId) + if (!library) { + return { kind: 'library-not-found', libraryId: input.libraryId } } - return new ImageResponse(tree, options) + // An explicit title replaces the whole name, so the framework label is not + // applied on top of it. + const name = input.title?.trim() + ? clampOgText(input.title, MAX_OG_TITLE_LENGTH) + : input.framework + ? withFrameworkLabel(library.name, input.framework) + : library.name + + const tagline = input.subtitle?.trim() ? input.subtitle : library.tagline + + const tree = buildReadmeHeaderTree({ + name, + tagline: clampOgText(tagline ?? '', MAX_OG_DESCRIPTION_LENGTH), + accentColor: getAccentColor(library.id), + emblemSrc: BRAND_EMBLEM_KEY, + }) + + return renderOgImage( + tree, + { width: 1800, height: 450 }, + input.requestUrl, + init, + ) } diff --git a/src/server/og/readme-template.tsx b/src/server/og/readme-template.tsx new file mode 100644 index 000000000..e5b9fcb1b --- /dev/null +++ b/src/server/og/readme-template.tsx @@ -0,0 +1,84 @@ +import type { ReactElement } from 'react' +import { splitName } from './template' + +type ReadmeHeaderProps = { + name: string + tagline: string + accentColor: string + emblemSrc: string +} + +const WIDTH = 1800 +const HEIGHT = 450 +const EMBLEM_SIZE = 200 +const PADDING_X = 96 +const ACCENT_BAR_HEIGHT = 18 + +export function buildReadmeHeaderTree(props: ReadmeHeaderProps): ReactElement { + const [nameLine1, nameLine2] = splitName(props.name) + + return ( +
+ + +
+
+ {nameLine2 ? {nameLine1} : null} + + {nameLine2 || nameLine1} + +
+
+ {props.tagline} +
+
+ +
+
+ ) +} diff --git a/src/server/og/template.tsx b/src/server/og/template.tsx index 4a9f5e2ad..671d9779d 100644 --- a/src/server/og/template.tsx +++ b/src/server/og/template.tsx @@ -15,7 +15,7 @@ const HEIGHT = 630 // "TanStack AI" → ["TanStack", "AI"] // "TanStack Router" → ["TanStack", "Router"] // "Create TS Router App" → ["Create TS Router", "App"] (fallback: last word) -function splitName(name: string): [string, string] { +export function splitName(name: string): [string, string] { const parts = name.split(' ') if (parts.length < 2) return [name, ''] const last = parts[parts.length - 1] From bb99c6b651c3aad55036ed94ccea5997c4b045d5 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 30 Jul 2026 11:15:24 +0200 Subject: [PATCH 2/6] Make local-repo-path test platform-agnostic The test hardcoded POSIX absolute paths, so on Windows `pathToFileURL` picked up the current drive letter and `getImportFallbackRepoDirs` returned backslash-separated paths that never matched. Derive both the inputs and the expectations through `node:path`. --- tests/local-repo-path.test.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/local-repo-path.test.ts b/tests/local-repo-path.test.ts index cfc1f3925..b52da2363 100644 --- a/tests/local-repo-path.test.ts +++ b/tests/local-repo-path.test.ts @@ -1,26 +1,39 @@ import assert from 'node:assert/strict' +import path from 'node:path' import { pathToFileURL } from 'node:url' import { getImportFallbackRepoDirs } from '../src/utils/local-repo-path.server' +// Build both the inputs and the expectations through `node:path` so the +// assertions hold on Windows, where a POSIX-style absolute path picks up the +// current drive letter and backslash separators. +const root = path.resolve('/workspace/GitHub') + assert.deepEqual( getImportFallbackRepoDirs( pathToFileURL( - '/workspace/GitHub/tanstack.com/src/utils/documents.server.ts', + path.join(root, 'tanstack.com', 'src', 'utils', 'documents.server.ts'), ).href, 'charts', ), - ['/workspace/GitHub/charts'], + [path.join(root, 'charts')], 'a normal checkout resolves a sibling repository', ) assert.deepEqual( getImportFallbackRepoDirs( pathToFileURL( - '/workspace/GitHub/charts/tanstack.com-charts-site/src/utils/documents.server.ts', + path.join( + root, + 'charts', + 'tanstack.com-charts-site', + 'src', + 'utils', + 'documents.server.ts', + ), ).href, 'charts', ), - ['/workspace/GitHub/charts', '/workspace/GitHub/charts/charts'], + [path.join(root, 'charts'), path.join(root, 'charts', 'charts')], 'a nested worktree resolves its enclosing repository before a sibling fallback', ) From 33ea33707d4339c712b7fe85d442421d8a0bb79e Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 30 Jul 2026 11:41:38 +0200 Subject: [PATCH 3/6] Bound README header text to the canvas; reject blank framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from review on #1076. `?framework=` (present but empty) yielded `''`, which is falsy and so slipped past the validation guard, returning a default banner with 200 instead of the documented 400. An omitted parameter still means "no framework"; a blank one is a malformed URL. The text column had no width bound, so a long name or tagline ran off the fixed canvas instead of wrapping — the 1200x630 template already caps its own copy this way. `maxWidth` fixes the wrapping case, but takumi supports neither `wordBreak` nor `overflowWrap`, so a single long token (`?title=` is capped by character count, not width) still had nowhere to break. Scale those lines down to fit, budgeting the whole string across the lines available to it and any single token across one. Adds boundary renders to the preview script for the inputs that drive this: a clamp-length spaced title/tagline, the same length as one unbreakable token, and the widest glyphs in the set. --- scripts/readme-header-preview.ts | 46 ++++++++++++++ src/routes/api/readme/{$}[.]png.ts | 7 ++- src/server/og/readme-template.tsx | 98 ++++++++++++++++++++++++++++-- 3 files changed, 146 insertions(+), 5 deletions(-) diff --git a/scripts/readme-header-preview.ts b/scripts/readme-header-preview.ts index d44c3df59..7cc6455a2 100644 --- a/scripts/readme-header-preview.ts +++ b/scripts/readme-header-preview.ts @@ -8,6 +8,10 @@ import { resolve } from 'node:path' import { libraries } from '../src/libraries/libraries' import { generateReadmeHeaderResponse } from '../src/server/og/generate.server' import type { Framework } from '../src/libraries/types' +import { + MAX_OG_DESCRIPTION_LENGTH, + MAX_OG_TITLE_LENGTH, +} from '../src/utils/og-limits' const OUT_DIR = resolve(process.cwd(), '.readme-preview') @@ -100,6 +104,48 @@ async function main() { entries.push({ name: lib.name, files }) } + // Boundary cases: `title`/`subtitle` are clamped by character count, not by + // rendered width, so the worst input is a single unbreakable token at the + // limit. These must stay inside the canvas rather than bleeding off the edge. + const boundaryFiles: Array<{ file: string; url: string }> = [] + const boundaries = [ + { + id: 'max-length-words', + title: 'TanStack Extremely Long Product Name That Fills The Whole Line', + subtitle: + 'A tagline long enough to reach the clamp limit, with ordinary spaces in it so the renderer has somewhere to wrap the text onto a second line', + }, + { + id: 'max-length-single-token', + title: 'A'.repeat(MAX_OG_TITLE_LENGTH), + subtitle: 'b'.repeat(MAX_OG_DESCRIPTION_LENGTH), + }, + { + // Widest glyphs in the set — the case most likely to defeat the + // average-width estimate in the template's font fitting. + id: 'max-length-widest-glyphs', + title: 'W'.repeat(MAX_OG_TITLE_LENGTH), + subtitle: 'W'.repeat(MAX_OG_DESCRIPTION_LENGTH), + }, + ] + + for (const boundary of boundaries) { + const file = `zz-boundary-${boundary.id}.png` + if ( + await renderToFile(file, { + libraryId: 'query', + title: boundary.title, + subtitle: boundary.subtitle, + }) + ) { + boundaryFiles.push({ + file, + url: `/api/readme/query.png?title=${encodeURIComponent(boundary.title).slice(0, 40)}…&subtitle=…`, + }) + } + } + entries.push({ name: 'Boundary cases (clamp limits)', files: boundaryFiles }) + writeFileSync(resolve(OUT_DIR, 'index.html'), buildGallery(entries)) console.log(`[ok] index.html`) } diff --git a/src/routes/api/readme/{$}[.]png.ts b/src/routes/api/readme/{$}[.]png.ts index 4c9e9b6fe..3be98b38f 100644 --- a/src/routes/api/readme/{$}[.]png.ts +++ b/src/routes/api/readme/{$}[.]png.ts @@ -32,8 +32,13 @@ export const Route = createFileRoute('/api/readme/{$}.png')({ // Validated rather than ignored: a typo'd framework in a README should // surface as a broken image, not as a banner naming the wrong package. + // An omitted param means "no framework"; a present-but-blank one + // (`?framework=`) is a malformed URL, not a request for the default. const framework = url.searchParams.get('framework') ?? undefined - if (framework && !library.frameworks.includes(framework as Framework)) { + if ( + framework !== undefined && + !library.frameworks.includes(framework as Framework) + ) { return new Response( `Unknown framework "${framework}" for ${library.name}. Expected one of: ${library.frameworks.join(', ')}`, { status: 400 }, diff --git a/src/server/og/readme-template.tsx b/src/server/og/readme-template.tsx index e5b9fcb1b..7f8c5a2d6 100644 --- a/src/server/og/readme-template.tsx +++ b/src/server/og/readme-template.tsx @@ -11,9 +11,61 @@ type ReadmeHeaderProps = { const WIDTH = 1800 const HEIGHT = 450 const EMBLEM_SIZE = 200 +const EMBLEM_GAP = 72 const PADDING_X = 96 const ACCENT_BAR_HEIGHT = 18 +// The canvas is a fixed size, so text with nowhere to wrap bleeds off the edge +// instead of reflowing. Bound the text column to the space actually left over +// beside the emblem, the same way the 1200x630 template caps its own copy. +const TEXT_MAX_WIDTH = WIDTH - PADDING_X * 2 - EMBLEM_SIZE - EMBLEM_GAP + +const NAME_FONT_SIZE = 96 +const PREFIX_FONT_SIZE = 44 +const TAGLINE_FONT_SIZE = 30 + +// `maxWidth` makes text wrap at spaces, but takumi supports no `wordBreak` or +// `overflowWrap`, so a single long token (`?title=` is capped by character +// count, not width) has nowhere to break and would run past the canvas edge. +// Scale such a line down until the estimate fits. +// +// ponytail: character-count estimate, not real text metrics — takumi exposes no +// measurement API. Ratios are eyeballed against the two fonts at their display +// sizes and only ever shrink text, so a bad guess costs a slightly small line, +// never an overflow. Swap in real advance widths if takumi ever exposes them. +function fitFontSize( + text: string, + baseSize: number, + avgGlyphRatio: number, + lines = 1, +): number { + if (!text) return baseSize + + // Two independent constraints: the whole string has `lines` worth of width to + // wrap into, but any single token has to fit on one line by itself, since + // there is nowhere inside a token to break. + const longestToken = text + .split(/\s+/) + .reduce((longest, token) => Math.max(longest, token.length), 0) + + const limit = (chars: number, budget: number) => + chars === 0 ? baseSize : budget / (chars * avgGlyphRatio) + + const fitted = Math.min( + baseSize, + limit(text.length, TEXT_MAX_WIDTH * lines), + limit(longestToken, TEXT_MAX_WIDTH), + ) + + return Math.max(16, Math.floor(fitted)) +} + +// Deliberately pessimistic — closer to the widest glyphs ('W', 'M') than to the +// average, so an all-caps worst case still lands inside the padding instead of +// exactly on it. Only text that already needs shrinking is affected. +const DISPLAY_GLYPH_RATIO = 0.78 +const BODY_GLYPH_RATIO = 0.66 + export function buildReadmeHeaderTree(props: ReadmeHeaderProps): ReactElement { const [nameLine1, nameLine2] = splitName(props.name) @@ -38,7 +90,7 @@ export function buildReadmeHeaderTree(props: ReadmeHeaderProps): ReactElement { alt="" width={EMBLEM_SIZE} height={EMBLEM_SIZE} - style={{ marginRight: 72 }} + style={{ marginRight: EMBLEM_GAP }} />
- {nameLine2 ? {nameLine1} : null} - + {nameLine2 ? ( + + {nameLine1} + + ) : null} + {nameLine2 || nameLine1}
-
+
{props.tagline}
From 8fd4c4fb67cb2828606192265a8d6ac546a45abd Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 30 Jul 2026 11:49:17 +0200 Subject: [PATCH 4/6] Let the width calculation, not the floor, size boundary text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second round of review on #1076. The 16px floor in `fitFontSize` was above what the clamped inputs can compute — 160 wide glyphs land near 12px — so the floor, not the width calculation, decided the size for exactly the inputs the calculation exists to handle. Lowered to 8px, which only guards against a zero or negative size. With the floor out of the way the ratios turned out to still be optimistic for the widest glyphs, so they now sit at genuine max-advance values rather than merely above average. Only text that would otherwise overflow is affected; no real library triggers the fitting. Boundary captions in the gallery now carry the full encoded query string, so one can be pasted to regenerate the exact image shown. --- scripts/readme-header-preview.ts | 11 +++++++---- src/server/og/readme-template.tsx | 9 ++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/scripts/readme-header-preview.ts b/scripts/readme-header-preview.ts index 7cc6455a2..0acf6f17a 100644 --- a/scripts/readme-header-preview.ts +++ b/scripts/readme-header-preview.ts @@ -48,7 +48,7 @@ function buildGallery( opacity: .55; margin: 0 0 12px; font-weight: 600; } figure { margin: 0 0 20px; } figcaption { font-size: 12px; opacity: .5; margin-bottom: 6px; - font-family: ui-monospace, monospace; } + font-family: ui-monospace, monospace; overflow-wrap: anywhere; } /* 900px matches how GitHub renders these inside a README. */ img { width: 900px; max-width: 100%; display: block; border: 1px solid #2e2e2e; } @@ -138,10 +138,13 @@ async function main() { subtitle: boundary.subtitle, }) ) { - boundaryFiles.push({ - file, - url: `/api/readme/query.png?title=${encodeURIComponent(boundary.title).slice(0, 40)}…&subtitle=…`, + // Full query string, so the caption can be pasted to regenerate the + // exact image shown. + const query = new URLSearchParams({ + title: boundary.title, + subtitle: boundary.subtitle, }) + boundaryFiles.push({ file, url: `/api/readme/query.png?${query}` }) } } entries.push({ name: 'Boundary cases (clamp limits)', files: boundaryFiles }) diff --git a/src/server/og/readme-template.tsx b/src/server/og/readme-template.tsx index 7f8c5a2d6..d085c2af7 100644 --- a/src/server/og/readme-template.tsx +++ b/src/server/og/readme-template.tsx @@ -57,14 +57,17 @@ function fitFontSize( limit(longestToken, TEXT_MAX_WIDTH), ) - return Math.max(16, Math.floor(fitted)) + // Floor only guards against a zero/negative size; it must stay below what + // the clamped inputs can compute (160 wide glyphs land around 12px) so the + // width calculation, not the floor, decides the final size. + return Math.max(8, Math.floor(fitted)) } // Deliberately pessimistic — closer to the widest glyphs ('W', 'M') than to the // average, so an all-caps worst case still lands inside the padding instead of // exactly on it. Only text that already needs shrinking is affected. -const DISPLAY_GLYPH_RATIO = 0.78 -const BODY_GLYPH_RATIO = 0.66 +const DISPLAY_GLYPH_RATIO = 0.95 +const BODY_GLYPH_RATIO = 0.82 export function buildReadmeHeaderTree(props: ReadmeHeaderProps): ReactElement { const [nameLine1, nameLine2] = splitName(props.name) From b8721ae183d64fc7eb4abed5facf5659f3192317 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 30 Jul 2026 12:10:28 +0200 Subject: [PATCH 5/6] Render README headers in light and dark READMEs can serve a themed banner through a `` with a `prefers-color-scheme` source, so `?theme=dark` now renders the header on the dark surface. Validated like `framework`: supplied means it must be `light` or `dark`, anything else is a 400. Colours come from the same app.css token sets the site uses, so a dark banner matches the site's own dark mode rather than inventing a palette: background-default for the surface, text-secondary for the prefix and tagline, and the category 300 step for the accent. One deliberate deviation. The site's dark tooling accent is --color-ds-neutral-200, which is also the dark text-secondary value, so devtools/workflow/cli/mcp/config would render name and tagline in one colour. The dark tooling accent uses the neutral *tint* step instead. Light mode has the same collision today (tooling accent and secondary text are both #3e3529, so those banners are flat), but its palette is pinned by an existing test and shared with the 1200x630 social cards, so it is left alone rather than changed as a side effect here. The new collision test asserts dark only and says why. The emblem rasters are now transparent rather than cream-backed, so one file works on either surface, and a cream emblem is generated alongside the charcoal one for the dark banner. --- docs/readme-headers.md | 48 +++++++++++-- .../brand/tanstack-emblem-charcoal-256.png | Bin 9117 -> 8649 bytes .../brand/tanstack-emblem-cream-256.png | Bin 0 -> 9036 bytes scripts/generate-brand-assets.mjs | 27 ++++++-- scripts/readme-header-preview.ts | 27 ++++++-- src/routes/api/readme/{$}[.]png.ts | 11 +++ src/server/og/assets.server.ts | 15 +++- src/server/og/colors.ts | 49 +++++++++++++- src/server/og/generate.server.ts | 14 +++- src/server/og/readme-template.tsx | 6 +- tests/og-branding.test.ts | 64 +++++++++++++++++- 11 files changed, 235 insertions(+), 26 deletions(-) create mode 100644 public/images/brand/tanstack-emblem-cream-256.png diff --git a/docs/readme-headers.md b/docs/readme-headers.md index c19efe94a..8754f1bc5 100644 --- a/docs/readme-headers.md +++ b/docs/readme-headers.md @@ -20,6 +20,38 @@ fold. /> ``` +### Light and dark + +`?theme=dark` renders the banner on the dark surface. Serve both through a +`` so GitHub picks the one matching the reader's theme — this is the +approach GitHub documents in +[How to make your images in Markdown on GitHub adjust for dark mode and light mode](https://github.blog/developer-skills/github/how-to-make-your-images-in-markdown-on-github-adjust-for-dark-mode-and-light-mode/): + +```html + + + + TanStack Query + +``` + +The trailing `` is the fallback for renderers that ignore `` +(npm, most editors), so it should stay the light variant. + +The same post describes a `#gh-dark-mode-only` URL-fragment trick. Prefer +``: the fragment approach renders both images in any client that +doesn't special-case it. + For a package README inside a multi-framework repo (e.g. `packages/react-start/README.md`), add `?framework=`: @@ -42,6 +74,7 @@ The framework label is inserted after the `TanStack` prefix: | `framework` | no | Must be one of the library's supported frameworks. Anything else → `400` listing the accepted values. | | `title` | no | Replaces the rendered name entirely. Clamped to 80 chars. Takes precedence over `framework`, which is then not applied. | | `subtitle` | no | Replaces the tagline. Clamped to 160 chars. | +| `theme` | no | `light` (default) or `dark`. Anything else → `400`. | An invalid `framework` is rejected rather than ignored, so a typo in a README shows up as a broken image during review instead of a banner naming the wrong @@ -61,11 +94,11 @@ assets; don't use this endpoint for anything time-sensitive. pnpm run readme:preview ``` -Renders every library's header — plus one per supported framework — to -`.readme-preview/`, along with an `index.html` gallery that displays them at -GitHub's 900px render width. Open `.readme-preview/index.html` to check that no -long name or tagline overflows. Run this after touching -`src/server/og/readme-template.tsx`. +Renders every library's header in both themes — plus one per supported +framework — to `.readme-preview/`, along with an `index.html` gallery that +displays them at GitHub's 900px render width, each on its own theme surface. +Open `.readme-preview/index.html` to check that no long name or tagline +overflows. Run this after touching `src/server/og/readme-template.tsx`. (`scripts/og-preview.ts` is the equivalent for the 1200×630 social cards.) @@ -76,6 +109,7 @@ long name or tagline overflows. Run this after touching | `src/routes/api/readme/{$}[.]png.ts` | Route handler: param parsing, validation, cache headers | | `src/server/og/generate.server.ts` | `generateReadmeHeaderResponse` + the render path shared with OG | | `src/server/og/readme-template.tsx` | The 1800×450 layout | -| `src/server/og/assets.server.ts` | Loads fonts and the raster brand emblem | -| `scripts/generate-brand-assets.mjs` | Generates `public/images/brand/tanstack-emblem-charcoal-256.png` | +| `src/server/og/assets.server.ts` | Loads fonts and both raster brand emblems | +| `src/server/og/colors.ts` | Category accents and surfaces per theme | +| `scripts/generate-brand-assets.mjs` | Generates the charcoal and cream 256px emblem rasters | | `scripts/readme-header-preview.ts` | Local render + gallery for reviewing layout changes | diff --git a/public/images/brand/tanstack-emblem-charcoal-256.png b/public/images/brand/tanstack-emblem-charcoal-256.png index 168583c9711a664b598e17f3be77d50af3010226..0fd9af58edb7ab3b0a75ccd4ca51ddd84d1f3d55 100644 GIT binary patch literal 8649 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 B(mVhF literal 9117 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 = [] - const base = `${lib.id}-readme.png` - if (await renderToFile(base, { libraryId: lib.id })) { - files.push({ file: base, url: `/api/readme/${lib.id}.png` }) + // Both themes, since a README serves them through a single . + for (const theme of OG_THEMES) { + const suffix = theme === 'dark' ? '-dark' : '' + const query = theme === 'dark' ? '?theme=dark' : '' + const base = `${lib.id}-readme${suffix}.png` + if (await renderToFile(base, { libraryId: lib.id, theme })) { + files.push({ file: base, url: `/api/readme/${lib.id}.png${query}` }) + } } // Per-package variants, for repos whose framework packages ship their own @@ -127,7 +133,18 @@ async function main() { title: 'W'.repeat(MAX_OG_TITLE_LENGTH), subtitle: 'W'.repeat(MAX_OG_DESCRIPTION_LENGTH), }, - ] + { + id: 'max-length-widest-glyphs-dark', + title: 'W'.repeat(MAX_OG_TITLE_LENGTH), + subtitle: 'W'.repeat(MAX_OG_DESCRIPTION_LENGTH), + theme: 'dark' as OgTheme, + }, + ] satisfies Array<{ + id: string + title: string + subtitle: string + theme?: OgTheme + }> for (const boundary of boundaries) { const file = `zz-boundary-${boundary.id}.png` @@ -136,6 +153,7 @@ async function main() { libraryId: 'query', title: boundary.title, subtitle: boundary.subtitle, + theme: boundary.theme, }) ) { // Full query string, so the caption can be pasted to regenerate the @@ -143,6 +161,7 @@ async function main() { const query = new URLSearchParams({ title: boundary.title, subtitle: boundary.subtitle, + ...(boundary.theme ? { theme: boundary.theme } : {}), }) boundaryFiles.push({ file, url: `/api/readme/query.png?${query}` }) } diff --git a/src/routes/api/readme/{$}[.]png.ts b/src/routes/api/readme/{$}[.]png.ts index 3be98b38f..b6298392d 100644 --- a/src/routes/api/readme/{$}[.]png.ts +++ b/src/routes/api/readme/{$}[.]png.ts @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { findLibrary } from '~/libraries' import type { Framework } from '~/libraries/types' +import { OG_THEMES, isOgTheme } from '~/server/og/colors' type GenerateReadmeHeaderResponse = typeof import( '~/server/og/generate.server' @@ -45,6 +46,15 @@ export const Route = createFileRoute('/api/readme/{$}.png')({ ) } + // Same contract as `framework`: supplied means it must be valid. + const themeParam = url.searchParams.get('theme') ?? undefined + if (themeParam !== undefined && !isOgTheme(themeParam)) { + return new Response( + `Unknown theme "${themeParam}". Expected one of: ${OG_THEMES.join(', ')}`, + { status: 400 }, + ) + } + let result: Awaited> try { const { generateReadmeHeaderResponse } = await import( @@ -57,6 +67,7 @@ export const Route = createFileRoute('/api/readme/{$}.png')({ framework: framework as Framework | undefined, title: url.searchParams.get('title') ?? undefined, subtitle: url.searchParams.get('subtitle') ?? undefined, + theme: themeParam, }, { headers: CACHE_HEADERS }, ) diff --git a/src/server/og/assets.server.ts b/src/server/og/assets.server.ts index 7986cb813..bc1e6975b 100644 --- a/src/server/og/assets.server.ts +++ b/src/server/og/assets.server.ts @@ -7,6 +7,7 @@ const interRegularUrl = '/fonts/Inter-Regular.ttf' const bricolageBoldUrl = '/fonts/BricolageGrotesque-Bold.ttf' const brandLogoPngUrl = '/images/brand/tanstack-landscape-black-640.png' const brandEmblemPngUrl = '/images/brand/tanstack-emblem-charcoal-256.png' +const brandEmblemCreamPngUrl = '/images/brand/tanstack-emblem-cream-256.png' function tryReadBinary(relPath: string): Buffer | null { // Resolve from the project root for local dev and tests. Workers normally @@ -33,6 +34,7 @@ let cached: { bricolageBold: Buffer brandLogoPng: Buffer brandEmblemPng: Buffer + brandEmblemCreamPng: Buffer } | null = null export async function loadOgAssets(requestUrl?: string) { @@ -48,13 +50,23 @@ export async function loadOgAssets(requestUrl?: string) { const brandEmblemPng = tryReadBinary( 'public/images/brand/tanstack-emblem-charcoal-256.png', ) + const brandEmblemCreamPng = tryReadBinary( + 'public/images/brand/tanstack-emblem-cream-256.png', + ) - if (interRegular && bricolageBold && brandLogoPng && brandEmblemPng) { + if ( + interRegular && + bricolageBold && + brandLogoPng && + brandEmblemPng && + brandEmblemCreamPng + ) { cached = { interRegular, bricolageBold, brandLogoPng, brandEmblemPng, + brandEmblemCreamPng, } return cached } @@ -68,6 +80,7 @@ export async function loadOgAssets(requestUrl?: string) { bricolageBold: await readAssetUrl(bricolageBoldUrl, requestUrl), brandLogoPng: await readAssetUrl(brandLogoPngUrl, requestUrl), brandEmblemPng: await readAssetUrl(brandEmblemPngUrl, requestUrl), + brandEmblemCreamPng: await readAssetUrl(brandEmblemCreamPngUrl, requestUrl), } return cached } diff --git a/src/server/og/colors.ts b/src/server/og/colors.ts index 0ed138d64..5546389b8 100644 --- a/src/server/og/colors.ts +++ b/src/server/og/colors.ts @@ -1,6 +1,10 @@ import type { LibraryId } from '~/libraries' import { categoryOf, type LibraryCategory } from '~/libraries/categories' +export type OgTheme = 'light' | 'dark' + +export const OG_THEMES = ['light', 'dark'] as const + // Server-rendered OG cards cannot resolve CSS custom properties. Keep these // literals aligned with the category 400 tokens in src/styles/app.css. const CATEGORY_ACCENT_COLORS = { @@ -11,6 +15,47 @@ const CATEGORY_ACCENT_COLORS = { tooling: '#3e3529', } satisfies Record -export function getAccentColor(libraryId: LibraryId): string { - return CATEGORY_ACCENT_COLORS[categoryOf(libraryId)] +// Category accents flip to the 300 step on dark surfaces, mirroring the +// `html.dark` overrides in src/styles/app.css so a dark banner matches the +// site's own dark mode rather than inventing a second palette. +const CATEGORY_ACCENT_COLORS_DARK = { + framework: '#69bc75', + data: '#e06e49', + ui: '#61adbf', + performance: '#f4d648', + // Deliberately --color-ds-neutral-tint-200 rather than the site's dark + // tooling token (--color-ds-neutral-200, #aea691). On the site that accent + // sits next to differently-coloured text; here it would land on the same + // value as `secondaryText` below, flattening the name and tagline into one + // colour. The tint step keeps the hierarchy readable. + tooling: '#c5c3bf', +} satisfies Record + +export function getAccentColor( + libraryId: LibraryId, + theme: OgTheme = 'light', +): string { + const palette = + theme === 'dark' ? CATEGORY_ACCENT_COLORS_DARK : CATEGORY_ACCENT_COLORS + return palette[categoryOf(libraryId)] +} + +type ThemeSurface = { + background: string + /** The `TanStack` prefix line and the tagline. */ + secondaryText: string +} + +// background-default / text-secondary from the same app.css token sets. +const THEME_SURFACES = { + light: { background: '#eeebd4', secondaryText: '#3e3529' }, + dark: { background: '#111111', secondaryText: '#aea691' }, +} satisfies Record + +export function getThemeSurface(theme: OgTheme): ThemeSurface { + return THEME_SURFACES[theme] +} + +export function isOgTheme(value: string): value is OgTheme { + return (OG_THEMES as ReadonlyArray).includes(value) } diff --git a/src/server/og/generate.server.ts b/src/server/og/generate.server.ts index cb6352388..f7868d0b3 100644 --- a/src/server/og/generate.server.ts +++ b/src/server/og/generate.server.ts @@ -8,7 +8,7 @@ import { findLibrary } from '~/libraries' import type { LibraryId } from '~/libraries' import type { Framework } from '~/libraries/types' import { loadOgAssets as loadNodeOgAssets } from './assets.server' -import { getAccentColor } from './colors' +import { getAccentColor, getThemeSurface, type OgTheme } from './colors' import { buildOgTree } from './template' import { buildReadmeHeaderTree } from './readme-template' import { @@ -19,6 +19,7 @@ import { const BRAND_LOGO_KEY = 'brand-logo' const BRAND_EMBLEM_KEY = 'brand-emblem' +const BRAND_EMBLEM_CREAM_KEY = 'brand-emblem-cream' type GenerateInput = { libraryId: LibraryId | string @@ -34,6 +35,8 @@ export type ReadmeHeaderInput = { framework?: Framework title?: string subtitle?: string + /** Defaults to the light (cream) surface. */ + theme?: OgTheme } export type OgLibraryNotFoundError = { @@ -70,6 +73,7 @@ async function renderOgImage( images: [ { src: BRAND_LOGO_KEY, data: assets.brandLogoPng }, { src: BRAND_EMBLEM_KEY, data: assets.brandEmblemPng }, + { src: BRAND_EMBLEM_CREAM_KEY, data: assets.brandEmblemCreamPng }, ], module: takumiWasmModule, ...init, @@ -138,12 +142,16 @@ export async function generateReadmeHeaderResponse( : library.name const tagline = input.subtitle?.trim() ? input.subtitle : library.tagline + const theme = input.theme ?? 'light' + const surface = getThemeSurface(theme) const tree = buildReadmeHeaderTree({ name, tagline: clampOgText(tagline ?? '', MAX_OG_DESCRIPTION_LENGTH), - accentColor: getAccentColor(library.id), - emblemSrc: BRAND_EMBLEM_KEY, + accentColor: getAccentColor(library.id, theme), + emblemSrc: theme === 'dark' ? BRAND_EMBLEM_CREAM_KEY : BRAND_EMBLEM_KEY, + background: surface.background, + secondaryText: surface.secondaryText, }) return renderOgImage( diff --git a/src/server/og/readme-template.tsx b/src/server/og/readme-template.tsx index d085c2af7..0dd416f71 100644 --- a/src/server/og/readme-template.tsx +++ b/src/server/og/readme-template.tsx @@ -6,6 +6,8 @@ type ReadmeHeaderProps = { tagline: string accentColor: string emblemSrc: string + background: string + secondaryText: string } const WIDTH = 1800 @@ -80,9 +82,9 @@ export function buildReadmeHeaderTree(props: ReadmeHeaderProps): ReactElement { display: 'flex', alignItems: 'center', position: 'relative', - color: '#3e3529', + color: props.secondaryText, fontFamily: 'Inter', - backgroundColor: '#eeebd4', + backgroundColor: props.background, paddingLeft: PADDING_X, paddingRight: PADDING_X, paddingBottom: ACCENT_BAR_HEIGHT, diff --git a/tests/og-branding.test.ts b/tests/og-branding.test.ts index 300730133..338318dc3 100644 --- a/tests/og-branding.test.ts +++ b/tests/og-branding.test.ts @@ -1,6 +1,20 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { getAccentColor } from '../src/server/og/colors' +import { + OG_THEMES, + getAccentColor, + getThemeSurface, + isOgTheme, +} from '../src/server/og/colors' + +const SAMPLE_LIBRARY_IDS = [ + 'start', + 'query', + 'table', + 'virtual', + 'workflow', + 'devtools', +] as const test('OG accents follow the rebrand category palette', () => { assert.equal(getAccentColor('start'), '#39af46') @@ -9,3 +23,51 @@ test('OG accents follow the rebrand category palette', () => { assert.equal(getAccentColor('virtual'), '#ffa216') assert.equal(getAccentColor('workflow'), '#3e3529') }) + +test('dark accents use the category 300 step', () => { + assert.equal(getAccentColor('start', 'dark'), '#69bc75') + assert.equal(getAccentColor('query', 'dark'), '#e06e49') + assert.equal(getAccentColor('table', 'dark'), '#61adbf') + assert.equal(getAccentColor('virtual', 'dark'), '#f4d648') +}) + +test('an explicit light theme matches the default', () => { + for (const id of SAMPLE_LIBRARY_IDS) { + assert.equal(getAccentColor(id, 'light'), getAccentColor(id)) + } +}) + +test('no dark accent collides with the text it sits beside', () => { + // A category accent equal to the surface's secondary text flattens the + // library name and its tagline into a single colour. This is why the dark + // tooling accent uses the neutral *tint* step rather than the site's dark + // tooling token, which is the same value as the dark secondary text. + // + // Light mode is deliberately not asserted: its tooling accent is already + // #3e3529, the same as the light secondary text, so tooling banners are flat + // in light mode today. Changing it would move the 1200x630 social cards too, + // so it is left alone here rather than fixed as a side effect. + const { secondaryText, background } = getThemeSurface('dark') + + for (const id of SAMPLE_LIBRARY_IDS) { + const accent = getAccentColor(id, 'dark') + assert.notEqual(accent, secondaryText, `${id} accent matches dark text`) + assert.notEqual(accent, background, `${id} accent matches dark background`) + } +}) + +test('every theme resolves a distinct surface', () => { + const surfaces = OG_THEMES.map((theme) => getThemeSurface(theme)) + for (const surface of surfaces) { + assert.notEqual(surface.background, surface.secondaryText) + } + assert.notEqual(surfaces[0].background, surfaces[1].background) +}) + +test('theme validation accepts only the rendered themes', () => { + assert.ok(isOgTheme('light')) + assert.ok(isOgTheme('dark')) + assert.ok(!isOgTheme('')) + assert.ok(!isOgTheme('Dark')) + assert.ok(!isOgTheme('sepia')) +}) From 3cce8fb5f77fb93bfb12a7351454d00ecdd88113 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:13:43 +0000 Subject: [PATCH 6/6] ci: apply automated fixes --- docs/readme-headers.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/readme-headers.md b/docs/readme-headers.md index 8754f1bc5..5fcbe5873 100644 --- a/docs/readme-headers.md +++ b/docs/readme-headers.md @@ -104,12 +104,12 @@ overflows. Run this after touching `src/server/og/readme-template.tsx`. ## Implementation -| File | Role | -| ------------------------------------ | ---------------------------------------------------------------- | -| `src/routes/api/readme/{$}[.]png.ts` | Route handler: param parsing, validation, cache headers | -| `src/server/og/generate.server.ts` | `generateReadmeHeaderResponse` + the render path shared with OG | -| `src/server/og/readme-template.tsx` | The 1800×450 layout | -| `src/server/og/assets.server.ts` | Loads fonts and both raster brand emblems | -| `src/server/og/colors.ts` | Category accents and surfaces per theme | -| `scripts/generate-brand-assets.mjs` | Generates the charcoal and cream 256px emblem rasters | -| `scripts/readme-header-preview.ts` | Local render + gallery for reviewing layout changes | +| File | Role | +| ------------------------------------ | --------------------------------------------------------------- | +| `src/routes/api/readme/{$}[.]png.ts` | Route handler: param parsing, validation, cache headers | +| `src/server/og/generate.server.ts` | `generateReadmeHeaderResponse` + the render path shared with OG | +| `src/server/og/readme-template.tsx` | The 1800×450 layout | +| `src/server/og/assets.server.ts` | Loads fonts and both raster brand emblems | +| `src/server/og/colors.ts` | Category accents and surfaces per theme | +| `scripts/generate-brand-assets.mjs` | Generates the charcoal and cream 256px emblem rasters | +| `scripts/readme-header-preview.ts` | Local render + gallery for reviewing layout changes |