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..5fcbe5873 --- /dev/null +++ b/docs/readme-headers.md @@ -0,0 +1,115 @@ +# 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 +``` + +### 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=`: + +```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. | +| `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 +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 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.) + +## 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 | diff --git a/package.json b/package.json index 3a9029b76..007c67a74 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 000000000..0fd9af58e Binary files /dev/null and b/public/images/brand/tanstack-emblem-charcoal-256.png differ diff --git a/public/images/brand/tanstack-emblem-cream-256.png b/public/images/brand/tanstack-emblem-cream-256.png new file mode 100644 index 000000000..2efd75be2 Binary files /dev/null and b/public/images/brand/tanstack-emblem-cream-256.png differ diff --git a/scripts/generate-brand-assets.mjs b/scripts/generate-brand-assets.mjs index efb6e790d..03fe60bfa 100644 --- a/scripts/generate-brand-assets.mjs +++ b/scripts/generate-brand-assets.mjs @@ -28,6 +28,7 @@ const colors = { } const emblemPath = join(brandDir, 'tanstack-emblem-charcoal.svg') +const creamEmblemPath = join(brandDir, 'tanstack-emblem-cream.svg') const landscapePath = join(brandDir, 'tanstack-landscape-black.svg') const heroPath = join(publicDir, 'images', 'hero-palm-gradient-1600.webp') const displayFontPath = join(fontsDir, 'BricolageGrotesque-Bold.ttf') @@ -166,6 +167,27 @@ await writeFile( landscapePng, ) +// Raster emblems for the README header renderer (takumi takes raster data). +// Transparent background so one file works on either theme surface. +const transparent = { r: 0, g: 0, b: 0, alpha: 0 } + +for (const [source, output] of [ + [emblemPath, 'tanstack-emblem-charcoal-256.png'], + [creamEmblemPath, 'tanstack-emblem-cream-256.png'], +]) { + const png = await sharp(source, { density: 1200 }) + .resize({ + width: 256, + height: 256, + fit: 'contain', + background: transparent, + }) + .png() + .toBuffer() + + await writeFile(join(brandDir, output), png) +} + const ogLogo = await sharp(landscapePng).resize({ width: 360 }).png().toBuffer() const ogHeadline = await renderText( 'The open source\napplication stack\nfor the web', @@ -198,6 +220,8 @@ const outputs = [ 'public/android-chrome-192x192.png', 'public/android-chrome-512x512.png', 'public/images/brand/tanstack-landscape-black-640.png', + 'public/images/brand/tanstack-emblem-charcoal-256.png', + 'public/images/brand/tanstack-emblem-cream-256.png', 'src/images/og.png', 'src/images/og-light.png', ] diff --git a/scripts/readme-header-preview.ts b/scripts/readme-header-preview.ts new file mode 100644 index 000000000..459369e97 --- /dev/null +++ b/scripts/readme-header-preview.ts @@ -0,0 +1,178 @@ +/** + * Renders the GitHub README header banner for every library to + * `.readme-preview/`, plus an index.html gallery for eyeballing them. + * Run with: pnpm exec tsx scripts/readme-header-preview.ts + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { libraries } from '../src/libraries/libraries' +import { generateReadmeHeaderResponse } from '../src/server/og/generate.server' +import { OG_THEMES, type OgTheme } from '../src/server/og/colors' +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') + +async function renderToFile( + fileName: string, + input: Parameters[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 }> = [] + + // 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 + // 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 }) + } + + // 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), + }, + { + 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` + if ( + await renderToFile(file, { + libraryId: 'query', + title: boundary.title, + subtitle: boundary.subtitle, + theme: boundary.theme, + }) + ) { + // 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, + ...(boundary.theme ? { theme: boundary.theme } : {}), + }) + boundaryFiles.push({ file, url: `/api/readme/query.png?${query}` }) + } + } + entries.push({ name: 'Boundary cases (clamp limits)', files: boundaryFiles }) + + 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..b6298392d --- /dev/null +++ b/src/routes/api/readme/{$}[.]png.ts @@ -0,0 +1,101 @@ +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' +)['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. + // 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 !== undefined && + !library.frameworks.includes(framework as Framework) + ) { + return new Response( + `Unknown framework "${framework}" for ${library.name}. Expected one of: ${library.frameworks.join(', ')}`, + { status: 400 }, + ) + } + + // 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( + '~/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, + theme: themeParam, + }, + { 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..bc1e6975b 100644 --- a/src/server/og/assets.server.ts +++ b/src/server/og/assets.server.ts @@ -6,6 +6,8 @@ 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' +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 @@ -31,6 +33,8 @@ let cached: { interRegular: Buffer bricolageBold: Buffer brandLogoPng: Buffer + brandEmblemPng: Buffer + brandEmblemCreamPng: Buffer } | null = null export async function loadOgAssets(requestUrl?: string) { @@ -43,12 +47,26 @@ 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', + ) + const brandEmblemCreamPng = tryReadBinary( + 'public/images/brand/tanstack-emblem-cream-256.png', + ) - if (interRegular && bricolageBold && brandLogoPng) { + if ( + interRegular && + bricolageBold && + brandLogoPng && + brandEmblemPng && + brandEmblemCreamPng + ) { cached = { interRegular, bricolageBold, brandLogoPng, + brandEmblemPng, + brandEmblemCreamPng, } return cached } @@ -61,6 +79,8 @@ 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), + 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 0bf46d43a..83a74541e 100644 --- a/src/server/og/generate.server.ts +++ b/src/server/og/generate.server.ts @@ -1,9 +1,12 @@ import { ImageResponse, type ImageResponseOptions } from 'takumi-js/response' +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 { getAccentColor, getThemeSurface, type OgTheme } from './colors' import { buildOgTree } from './template' +import { buildReadmeHeaderTree } from './readme-template' import { MAX_OG_DESCRIPTION_LENGTH, MAX_OG_TITLE_LENGTH, @@ -11,6 +14,8 @@ import { } from '~/utils/og-limits' 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 @@ -19,11 +24,59 @@ 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 + /** Defaults to the light (cream) surface. */ + theme?: OgTheme +} + 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 }, + { src: BRAND_EMBLEM_CREAM_KEY, data: assets.brandEmblemCreamPng }, + ], + ...init, + } + + return new ImageResponse(tree, options) +} + export async function generateOgImageResponse( input: GenerateInput, init?: ResponseInit, @@ -33,7 +86,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), @@ -47,27 +99,60 @@ 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 }], - ...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 theme = input.theme ?? 'light' + const surface = getThemeSurface(theme) + + const tree = buildReadmeHeaderTree({ + name, + tagline: clampOgText(tagline ?? '', MAX_OG_DESCRIPTION_LENGTH), + accentColor: getAccentColor(library.id, theme), + emblemSrc: theme === 'dark' ? BRAND_EMBLEM_CREAM_KEY : BRAND_EMBLEM_KEY, + background: surface.background, + secondaryText: surface.secondaryText, + }) + + 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..0dd416f71 --- /dev/null +++ b/src/server/og/readme-template.tsx @@ -0,0 +1,179 @@ +import type { ReactElement } from 'react' +import { splitName } from './template' + +type ReadmeHeaderProps = { + name: string + tagline: string + accentColor: string + emblemSrc: string + background: string + secondaryText: string +} + +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), + ) + + // 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.95 +const BODY_GLYPH_RATIO = 0.82 + +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] 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', ) 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')) +})