Add dynamic README header image endpoint - #1076
Conversation
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/<libraryId>.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 <img> 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
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`.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
tanstack-com | 3cce8fb | Commit Preview URL Branch Preview URL |
Jul 30 2026, 10:15 AM |
📝 WalkthroughWalkthroughChangesREADME Header Generation
Path Portability
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant ReadmeImageRoute
participant generateReadmeHeaderResponse
participant loadOgAssets
participant ImageResponse
Client->>ReadmeImageRoute: GET /api/readme/{libraryId}.png?theme=dark
ReadmeImageRoute->>ReadmeImageRoute: validate theme and README options
ReadmeImageRoute->>generateReadmeHeaderResponse: pass dark theme and content
generateReadmeHeaderResponse->>loadOgAssets: load fonts and themed emblems
loadOgAssets-->>generateReadmeHeaderResponse: return cached assets
generateReadmeHeaderResponse->>ImageResponse: render themed README header
ImageResponse-->>ReadmeImageRoute: return rendered PNG
ReadmeImageRoute-->>Client: return image or HTTP error
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/routes/api/readme/`{$}[.]png.ts:
- Around line 35-36: Update the framework validation in the route handler so it
runs whenever the query parameter is not undefined, including an explicitly
blank value. Continue accepting omitted framework parameters for the default
banner, while rejecting blank or unsupported values using the existing
invalid-framework response path.
In `@src/server/og/readme-template.tsx`:
- Around line 51-69: Update the README title and tagline layout around the
visible title container and tagline <div> to constrain long unbroken tokens
within the OG canvas, using appropriate width/overflow or wrapping styles while
preserving normal text rendering. Add OG preview/tests covering boundary-length
single-token title and subtitle inputs to verify they do not overflow.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 9ab9e803-7c4e-43ad-88cc-9794120179d5
⛔ Files ignored due to path filters (1)
public/images/brand/tanstack-emblem-charcoal-256.pngis excluded by!**/*.png
📒 Files selected for processing (12)
.gitignoredocs/readme-headers.mdpackage.jsonscripts/generate-brand-assets.mjsscripts/readme-header-preview.tssrc/routeTree.gen.tssrc/routes/api/readme/{$}[.]png.tssrc/server/og/assets.server.tssrc/server/og/generate.server.tssrc/server/og/readme-template.tsxsrc/server/og/template.tsxtests/local-repo-path.test.ts
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.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@scripts/readme-header-preview.ts`:
- Around line 141-144: Update the boundaryFiles entry in the readme header
preview flow to build the URL with the complete encoded title and subtitle query
values, preserving the exact string needed to regenerate the image. If a
shortened caption is required for display, truncate only that presentation value
rather than the stored url.
In `@src/server/og/readme-template.tsx`:
- Line 60: Update fitFontSize so its returned font size no longer enforces the
16px minimum; remove or lower the Math.max floor while preserving the computed
fitted value and existing upper-bound behavior, allowing long boundary text to
shrink enough to fit TEXT_MAX_WIDTH.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 2f9a4d0b-76c7-46b8-9d9a-6ba9113cb579
📒 Files selected for processing (3)
scripts/readme-header-preview.tssrc/routes/api/readme/{$}[.]png.tssrc/server/og/readme-template.tsx
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.
READMEs can serve a themed banner through a `<picture>` 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.
Library repo READMEs each open with a hand-made banner PNG committed to
media/header_*.png. They're inconsistent in size (query is 3000×1704, table is 1728×874), too tall for a README, and updating the logo or a tagline means re-exporting images across a dozen repos.This adds
GET /api/readme/<libraryId>.png, which renders that banner on demand at 1800×450 in light and dark, reusing the takumi renderer, brand assets and per-category accent colors that already back the OG cards. Repos point a<picture>at the URL instead of committing a file — so a branding change lands in every README at once, in both themes.What it looks like
Shown at 900px, the width GitHub renders README images at. Light then dark for each.
TanStack Query
TanStack Router
TanStack Pacer
Per-package READMEs add
?framework=, sopackages/react-start/README.mdgets its own name:Every other library, both themes
ai
charts
cli
config
db
devtools
form
highlight
hotkeys
intent
markdown
mcp
ranger
start
store
table
virtual
workflow
Usage
Per GitHub's own guidance. The trailing
<img>stays the light variant as the fallback for renderers that ignore<picture>(npm, most editors). The post's#gh-dark-mode-onlyfragment trick is documented but not recommended — it renders both images in any client that doesn't special-case it.Parameters
404?framework=400listing the accepted values?theme=light(default) ordark. Anything else →400?title=?framework=?subtitle=Supplied-but-invalid is always a 400 rather than a silent fallback, so a typo in a README surfaces as a broken image during review instead of a banner that quietly says the wrong thing.
Dark palette
Taken from the same
app.csstoken sets the site uses, so a dark banner matches the site's own dark mode:background-defaultfor the surface,text-secondaryfor the prefix and tagline, and the category 300 step for the accent.One deliberate deviation, visible in the devtools banner above: the site's dark tooling accent is
--color-ds-neutral-200, which is also the darktext-secondaryvalue — so devtools/workflow/cli/mcp/config would render name and tagline in a single colour. The dark tooling accent uses the neutral tint step instead.Worth flagging that light mode has the same collision today — its tooling accent and secondary text are both
#3e3529, so those banners are flat in light mode (compare the two devtools images above). Its palette is pinned by an existing test and shared with the live 1200×630 social cards, so I left it alone rather than changing it as a side effect here. Happy to fix it in a follow-up if you want the light tooling banners to have hierarchy too.Notes for reviewers
generateOgImageResponseis unchanged from the outside, andgetAccentColorkeeps its old signature via a defaultedthemeargument.maxWidthhandles wrapping, and since takumi supports neitherwordBreaknoroverflowWrap, a single unbreakable token is scaled to fit. The fitting is a character-count estimate with deliberately pessimistic ratios — takumi exposes no text-measurement API — so a bad guess can only shrink a line, never overflow.pnpm run readme:previewrenders every library in both themes plus every framework variant and a set of clamp-length boundary cases. The preview PNGs above live on thereadme-header-previewsbranch, deliberately off this one so the diff carries no generated images.docs/readme-headers.md.tests/og-branding.test.tscovers the dark palette, light/dark parity, theme validation and the accent/text collision rule.tests/local-repo-path.test.tshardcoded POSIX paths and failed on Windows, blocking the pre-commit hook. Made it platform-agnostic.~25% dead space on the right is inherent to a left-aligned 4:1 banner. Happy to drop to 1600×450 or center the group if reviewers prefer.
Summary by CodeRabbit