Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ test-results
.tsbuildinfo
src/routeTree.gen.ts
.og-preview/
.readme-preview/
115 changes: 115 additions & 0 deletions docs/readme-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# README header images

`GET /api/readme/<libraryId>.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
<img
src="https://tanstack.com/api/readme/query.png"
alt="TanStack Query"
width="900"
/>
```

### Light and dark

`?theme=dark` renders the banner on the dark surface. Serve both through a
`<picture>` 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
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="https://tanstack.com/api/readme/query.png?theme=dark"
/>
<source
media="(prefers-color-scheme: light)"
srcset="https://tanstack.com/api/readme/query.png"
/>
<img
src="https://tanstack.com/api/readme/query.png"
alt="TanStack Query"
width="900"
/>
</picture>
```

The trailing `<img>` is the fallback for renderers that ignore `<picture>`
(npm, most editors), so it should stay the light variant.

The same post describes a `#gh-dark-mode-only` URL-fragment trick. Prefer
`<picture>`: 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
<img
src="https://tanstack.com/api/readme/start.png?framework=react"
alt="TanStack React Start"
width="900"
/>
```

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 |
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/brand/tanstack-emblem-cream-256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions scripts/generate-brand-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
]
Expand Down
178 changes: 178 additions & 0 deletions scripts/readme-header-preview.ts
Original file line number Diff line number Diff line change
@@ -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<typeof generateReadmeHeaderResponse>[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 `<!doctype html>
<meta charset="utf-8">
<title>TanStack README header preview</title>
<style>
body { margin: 0; padding: 40px; background: #121212; color: #eeebd4;
font: 16px/1.4 ui-sans-serif, system-ui, sans-serif; }
h1 { font-size: 22px; margin: 0 0 8px; }
p.lede { margin: 0 0 40px; opacity: .55; font-size: 14px; max-width: 70ch; }
section { margin-bottom: 48px; }
h2 { font-size: 15px; letter-spacing: .08em; text-transform: uppercase;
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; overflow-wrap: anywhere; }
/* 900px matches how GitHub renders these inside a README. */
img { width: 900px; max-width: 100%; display: block; border: 1px solid #2e2e2e; }
</style>
<h1>GitHub README headers &mdash; local render</h1>
<p class="lede">Shown at 900px, the width GitHub renders README images at.
Each caption is the endpoint URL a repo would put in its README.</p>
${entries
.map(
(entry) => `<section>
<h2>${entry.name}</h2>
${entry.files
.map(
({ file, url }) =>
`<figure><figcaption>${url}</figcaption><img src="./${file}" alt=""></figure>`,
)
.join('\n ')}
</section>`,
)
.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 <picture>.
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<Framework>) {
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)
})
Loading
Loading