Skip to content

fix(docs): render the share card in the language of the page it belongs to - #190

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-185-localized-og-cards
Aug 26, 2026
Merged

hotlong merged 1 commit into
mainfrom
claude/issue-185-localized-og-cards

Conversation

@hotlong

@hotlong hotlong commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #185

The generated share card was locale-independent in two compounding places, so every locale served one English card. This passes the locale through and renders each card in the language of the page it belongs to — the adjudicated option, not the strip-the-text one.

What changed

lib/source.ts — getPageImage(page, lang) puts the locale in the card URL. The card is a rendered artifact of the page's text, so a Japanese page and its English source are two different images and need two URLs.

lang is required, not optional-with-a-default, for the same reason languageAlternates requires its locales: the defect being fixed is a call site that did not pass a language, and a default is exactly what lets the next call site reproduce it silently. Required makes tsc the thing that notices — which is this card's answer to the lint question, scoped to one signature rather than generalised (that stays with #184).

The locale is spelled out for every locale, English included, rather than hidden the way localeUrl hides the default language. hideLocale exists so /docs/x reads as the plain English URL and nothing reads an image asset path; and an omitted en would make the first segment ambiguous — the handler would have to guess whether /og/docs/es/… is the Spanish card or the English card for a page slugged es. That guess fails silently the day someone adds content/docs/es.mdx.

app/og/docs/[...slug]/route.tsx — passes the language to the loader (the missing argument), and enumerates static params over translatedLocales(). Enumerated from the English page set because English is the authored source: getPages() with no argument returns all seven locales concatenated (553) and getPages(lang) cannot tell a translation from a fallback, so neither answers "which pages exist".

Also sets dynamicParams = false. Without it the route stays willing to render any locale for any page on demand, so /og/docs/ja/operate/backup/image.png would render English text under a ja URL — the exact incoherence being removed, just at an unlinked URL. This is a small deliberate addition beyond the literal defect; flagging it for review rather than burying it. It is safe because both sides derive the locale identically (see below), and verified below to 404 nothing the site actually emits.

app/[lang]/docs/[[...slug]]/page.tsx — shareCardUrl(page, lang) resolves through contentLang.

The card URL on an untranslated locale route

Flagged in dispatch as not pre-decided. The card URL follows the #188 fallback rule: on an untranslated locale route it names the English card.

On /ja/docs/architecture the body being served is the English page, so the card depicting it is the English card. Pointing at a ja card would mean generating a second URL that renders byte-identical English pixels, and — worse — og:image sits beside an og:url that names the English page, so a card URL claiming to be Japanese would contradict the object it illustrates. That is the new incoherence dispatch warned about.

This also makes the fallback rule and the cost ceiling the same rule rather than two constraints that happen to agree. contentLang is a member of translatedLocales(slugs) by construction (canonicalLocale returns either a locale already in that list, or the default language, which the list always contains for a page with an English source). So no card URL this site can emit falls outside the enumeration — which is what makes dynamicParams = false safe.

Verification

Read from the prerendered output of a cold build, not from the source.

Card count — translatedLocales() sum, not 79 × 7. Independently computed from the content tree (79 English .mdx, plus locale siblings): 335. Prerendered image.png.body files: 335. Before: 79. A naive seven-per-page would be 553. Per locale: en 79, zh-Hans 62, ja/es/fr/ko 39, de 38.

A page with no translation still generates exactly one card. 17 pages are English-only. architecture produces exactly one card (en/architecture), and the ja/de/ko routes all name that English card, with canonical agreeing:

en   og:image=…/og/docs/en/architecture/image.png   canonical=…/docs/architecture
ja   og:image=…/og/docs/en/architecture/image.png   canonical=…/docs/architecture
de   og:image=…/og/docs/en/architecture/image.png   canonical=…/docs/architecture

The card URL differs per locale for a translated page — /docs/build/interface/views, seven distinct URLs where there was one:

en       …/og/docs/en/build/interface/views/image.png
zh-Hans  …/og/docs/zh-Hans/build/interface/views/image.png
ja       …/og/docs/ja/build/interface/views/image.png
de       …/og/docs/de/build/interface/views/image.png
es       …/og/docs/es/build/interface/views/image.png
fr       …/og/docs/fr/build/interface/views/image.png
ko       …/og/docs/ko/build/interface/views/image.png

The card text is in that locale's language. Reading pixels is not practical, so this is established two ways that together are stronger than either.

The renderer is deterministic, proved not assumed: all 79/79 English cards are byte-identical between the before build (og/docs/{slug}/image.png.body) and the after build (og/docs/en/{slug}/image.png.body) — 0 differing, 0 missing. Same inputs, same bytes; the English card is also unchanged by this PR.

Given determinism, differing bytes prove different text reached the image: all 256/256 non-English cards differ from their English sibling; 0 were byte-identical.

And the text is the right language, not merely different — the route's inputs, the frontmatter the loader resolves per locale for build/interface/views:

en Views · zh-Hans 视图 · ja ビュー · de Views · es Vistas · fr Vues · ko 뷰

(de keeps the English loanword "Views" as its title; its card still differs from the English one because the description is translated — consistent, not an exception.)

Nothing 404s and nothing contradicts canonical. Across all 553 prerendered page renders: card URLs pointing at something not prerendered = 0; og:image locale disagreeing with the canonical's locale = 0; twitter:image differing from og:image = 0.

Build wall-clock

Cold builds (rm -rf .next), each holding the container's shared heavy-verify lock so neither competed with the sibling agent building concurrently. Two samples per side:

run 1 run 2 mean
before (d394550) 51s 50s 50.5s
after (29625b1) 59s 57s 58.0s

1.15x wall-clock for 4.24x the image count (79 → 335). Ceiling was 2x; this is well under it, and matches the prediction that parallel rendering of text-only variation would not cost proportionally.

Gates

  • pnpm run type-check (@objectos/docs) — exit 0, re-run on final commit 29625b1. It reported Types generated successfully with no diagnostics.
  • pnpm run build — exit 0, 29625b1, prerendering 335 cards.
  • Not touched: content/, so the translation-ownership gate has nothing to say here. No packages/, no changeset flow in this repo.

One thing I did not do

AGENTS.md asks for a real browser before claiming UI work is done. I did not drive a browser, and say so rather than implying otherwise. It would also be the weaker instrument here: og:image is never painted in the page's own viewport — it is consumed by crawlers out of the document head — so the prerendered head and the generated card bytes above are the artifact a browser would only indirectly report on.


Generated by Claude Code

…gs to

The generated OG card was locale-independent in two compounding places: the
card URL was built from `page.slugs` alone, so all seven locales shared one
string, and the route resolved the page with no language argument, so that one
card carried the English title and description. A Japanese reader sharing a
Japanese page got an English preview.

Put the locale in the card URL and pass it to the loader. Cards are enumerated
over `translatedLocales()` rather than the full locale list, so a page that
exists only in English still generates exactly one card, not seven.

The card URL resolves through `canonicalLocale`, the same rule `canonical` and
`og:url` already use: on a route whose locale has no translation the body being
served is the English page, so the card that depicts it is the English card.
That keeps `og:image` from contradicting the `og:url` it sits beside, and is
what makes the enumeration total -- every card URL the site emits is one the
build prerendered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
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.

Every locale shares the same English OG card — the share-card URL carries no language and the route resolves the page without one

2 participants