Skip to content

Add dynamic README header image endpoint - #1076

Open
AlemTuzlak wants to merge 7 commits into
mainfrom
readme-header-endpoint
Open

Add dynamic README header image endpoint#1076
AlemTuzlak wants to merge 7 commits into
mainfrom
readme-header-endpoint

Conversation

@AlemTuzlak

@AlemTuzlak AlemTuzlak commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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 Query light
TanStack Query dark

TanStack Router

TanStack Router light
TanStack Router dark

TanStack Pacer

TanStack Pacer light
TanStack Pacer dark

Per-package READMEs add ?framework=, so packages/react-start/README.md gets its own name:

TanStack React Start
TanStack Solid Start

Every other library, both themes

ai

ai light
ai dark

charts

charts light
charts dark

cli

cli light
cli dark

config

config light
config dark

db

db light
db dark

devtools

devtools light
devtools dark

form

form light
form dark

highlight

highlight light
highlight dark

hotkeys

hotkeys light
hotkeys dark

intent

intent light
intent dark

markdown

markdown light
markdown dark

mcp

mcp light
mcp dark

ranger

ranger light
ranger dark

start

start light
start dark

store

store light
store dark

table

table light
table dark

virtual

virtual light
virtual dark

workflow

workflow light
workflow dark

Usage

<!-- TanStack/query → README.md -->
<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>

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-only fragment trick is documented but not recommended — it renders both images in any client that doesn't special-case it.

Parameters

Param Behavior
path splat Library id. Unknown → 404
?framework= Must be one of the library's frameworks. Anything else → 400 listing the accepted values
?theme= light (default) or dark. Anything else → 400
?title= Replaces the name (80 char clamp). Wins over ?framework=
?subtitle= Replaces the tagline (160 char clamp)

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.css token sets the site uses, so a dark banner matches the site's own dark mode: background-default for the surface, text-secondary for 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 dark text-secondary value — 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

  • The render path (asset loading, fonts, takumi module) is now shared between the OG cards and the README headers rather than duplicated — generateOgImageResponse is unchanged from the outside, and getAccentColor keeps its old signature via a defaulted theme argument.
  • Emblem rasters are now transparent rather than cream-backed, so one file serves either surface; a cream emblem is generated alongside the charcoal one.
  • Text is bounded to the canvas: maxWidth handles wrapping, and since takumi supports neither wordBreak nor overflowWrap, 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:preview renders every library in both themes plus every framework variant and a set of clamp-length boundary cases. The preview PNGs above live on the readme-header-previews branch, deliberately off this one so the diff carries no generated images.
  • Docs: docs/readme-headers.md.
  • tests/og-branding.test.ts covers the dark palette, light/dark parity, theme validation and the accent/text collision rule.
  • One commit is unrelated to the feature: tests/local-repo-path.test.ts hardcoded 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

  • New Features
    • Added README header OG image API with optional light/dark theme support and theme-aware rendering, plus improved preview gallery.
    • Generated branded emblem assets for both charcoal and cream variants.
  • Documentation
    • Updated README header image guidance for light/dark usage and the local preview workflow.
  • Bug Fixes
    • Added validation for unknown/blank theme values and improved caption wrapping for long text.
  • Tests
    • Expanded theme/dark-mode OG branding coverage and improved local test path portability.

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`.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
tanstack-com 6d13d97 Commit Preview URL

Branch Preview URL
Jul 30 2026, 01:12 PM

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

An error occurred during the review process. Please try again later.

📝 Walkthrough

Walkthrough

Changes

README Header Generation

Layer / File(s) Summary
Themed OG contracts, assets, and rendering
scripts/generate-brand-assets.mjs, src/server/og/{assets.server.ts,colors.ts,generate.server.ts,readme-template.tsx,template.tsx}
Adds light/dark theme colors and surfaces, charcoal and cream emblem assets, shared OG rendering, theme-aware README headers, and updated text-fitting behavior.
Themed README image API route
src/routes/api/readme/{$}[.]png.ts, src/routeTree.gen.ts
Validates optional themes, passes them to generation, handles render readiness and errors, and registers the generated file-backed route.
Local preview and usage documentation
scripts/readme-header-preview.ts, package.json, .gitignore, docs/readme-headers.md
Adds light/dark preview generation, boundary-case URLs, gallery wrapping, the preview command, ignored output, and theme usage guidance.
Theme behavior tests
tests/og-branding.test.ts
Tests dark accents, theme surfaces, light defaults, collision avoidance, and theme validation.

Path Portability

Layer / File(s) Summary
Platform-independent repository path tests
tests/local-repo-path.test.ts
Constructs test paths with Node path utilities for platform-specific separators and drive letters.

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
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a dynamic README header image endpoint.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch readme-header-endpoint

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between d78b090 and bb99c6b.

⛔ Files ignored due to path filters (1)
  • public/images/brand/tanstack-emblem-charcoal-256.png is excluded by !**/*.png
📒 Files selected for processing (12)
  • .gitignore
  • docs/readme-headers.md
  • package.json
  • scripts/generate-brand-assets.mjs
  • scripts/readme-header-preview.ts
  • src/routeTree.gen.ts
  • src/routes/api/readme/{$}[.]png.ts
  • src/server/og/assets.server.ts
  • src/server/og/generate.server.ts
  • src/server/og/readme-template.tsx
  • src/server/og/template.tsx
  • tests/local-repo-path.test.ts

Comment thread src/routes/api/readme/{$}[.]png.ts Outdated
Comment thread src/server/og/readme-template.tsx
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between bb99c6b and 33ea337.

📒 Files selected for processing (3)
  • scripts/readme-header-preview.ts
  • src/routes/api/readme/{$}[.]png.ts
  • src/server/og/readme-template.tsx

Comment thread scripts/readme-header-preview.ts Outdated
Comment thread src/server/og/readme-template.tsx Outdated
AlemTuzlak and others added 3 commits July 30, 2026 11:49
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant