Skip to content

fix(docs): point an untranslated locale URL at the English page it serves - #188

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-174-fallback-canonical
Aug 25, 2026
Merged

hotlong merged 1 commit into
mainfrom
claude/issue-174-fallback-canonical

Conversation

@hotlong

@hotlong hotlong commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #174

Option A, per the adjudication comment on the card: when the current locale has no real translation of a page, every field that asserts where this content lives names the English URL instead of this locale's URL.

/ja/docs/operate/backup has no Japanese source. fumadocs falls back to the default language and serves the English body, so the URL was a second copy of the English page that declared itself canonical. #169 took it out of the sitemap and out of every hreflang cluster, but "no longer advertised" is not "no longer indexable" — the in-page language switcher links to it from every translated page.

The predicate is translatedLocales(slugs), which landed with #169 and already computes exactly this. No second implementation.

The set of URL-asserting fields, and what each one now emits

#166 landed after the card was written and put openGraph, twitter and two JSON-LD blocks into the same function, so the set is larger than the card describes. Enumerated so it can be checked rather than trusted. Example is /ja/docs/operate/backup (English-only page under a non-English prefix); "unchanged" means byte-identical to 23a976b.

Field Asserts Before Now
alternates.canonical where this page lives …/ja/docs/operate/backup …/docs/operate/backup
openGraph.url (og:url) Open Graph's permanent id for the object …/ja/docs/operate/backup …/docs/operate/backup
TechArticle.url where this article lives …/ja/docs/operate/backup …/docs/operate/backup
BreadcrumbList item, per crumb where that crumb's page lives …/ja/docs, …/ja/docs/operate, …/ja/docs/operate/backup …/ja/docs, …/docs/operate, …/docs/operate/backup
alternates.languages (hreflang + x-default) which locales really have this page en + x-default only unchanged — settled by #169; an untranslated locale is already absent
openGraph.images / twitter.images (og:image) where the share card lives …/og/docs/operate/backup/image.png unchanged — locale-independent by construction (getPageImage takes no lang); that is #185's card, deliberately untouched
TechArticle.image same share card as above unchanged, same reason
LLMCopyButton markdownUrl, ViewOptions githubUrl in-page UI affordances /ja/docs/…mdx, GitHub blob URL unchanged — reading-experience links, not metadata claims. The GitHub URL already resolves to the English source, because page.path keeps the authored filename.

Breadcrumb item resolves crumb by crumb, not once per request: a translated page can sit under an untranslated ancestor. /ja/docs/configure/permissions/positions is that case — the page and configure/permissions are both translated into Japanese, configure is not, so only the middle crumb moves to the English URL.

Two fields that assert a language, not a URL, and why they moved anyway

og:locale and TechArticle.inLanguage sit on the same object as a URL that changed. Leaving them would have introduced an incoherence rather than found one: a TechArticle node naming the English URL while claiming inLanguage: "ja" asserts that the English page is Japanese. Both now describe the object their URL names — en on a fallback route, unchanged everywhere else.

html lang is a different claim — it describes the document being served, translated chrome included — and #181 settled it on the route segment. Untouched.

Verification

Read out of the prerendered HTML in .next/server/app, not out of the source. Two production builds of the same tree, one at 23a976b and one at ea353af, compared file by file.

The three cases from the card.

  1. English-only page under a non-English prefix, ja/docs/operate/backup.html — canonical, og:url, TechArticle.url and the page's own crumb all moved to https://docs.objectos.ai/docs/operate/backup; og:locale and inLanguage moved ja to en; hreflang, both image URLs and html lang unchanged.
  2. Fully translated page under a non-English prefix, ja/docs/configure/permissions/positions.html — canonical still self-referential and unchanged, as are og:url, TechArticle.url, og:locale, inLanguage, hreflang and the images. Only the configure crumb moved, which is the ancestor case above.
  3. English page, en/docs/operate/backup.html — unchanged. All 81 English routes are byte-identical across every extracted field.

Scale, against an independent oracle. 218 of the 553 docs routes change canonical. The count of untranslated locale/page pairs, computed straight from content/docs/ without going through the app code, is also 218 (zh-Hans 17, ja 40, de 41, es 40, fr 40, ko 40). The number is equal, not merely bounded.

No regression anywhere else. Whole-document byte comparison of all 577 prerendered HTML files, normalizing only Next's per-build random buildId and the content-hashed asset names:

  • 370 documents differ, 207 are byte-identical.
  • 370 is again the oracle's number: a document differs iff some page in its ancestry is untranslated in its locale. Per locale — zh-Hans 30, ja 68, de 68, es 68, fr 68, ko 68 — matching the oracle exactly.
  • Zero en/ routes differ. Zero non-docs routes differ (locale home pages, privacy, terms, _not-found).
  • Blanking absolute site URLs and the two language values makes the rendered markup of all 370 byte-identical. So every relative navigation href, the visible breadcrumb, and all body text are untouched — Sitemap and hreflang advertise seven locales for pages that exist only in English — 41 of 79 pages #169's constraint that the reading experience must not change holds.

One residual difference, run down rather than waved past. 47 documents also differ inside the RSC flight payload after that normalization. It is not build nondeterminism: two builds of the identical tree are byte-identical in all 577 files. It is React moving where it outlines a subtree into a lazy chunk, because the shortened URL strings shift every later write position. Concretely, on de/docs/build/automation/approvals.html, chunk 1d goes from ["$","td",null,{"children":["$","strong",null,{"children":"Approver"}]}] to ["$","strong",null,{"children":"Approver"}] with the td inlined into the row — the same td containing the same strong containing the same text, with the chunk boundary one level lower. Checked mechanically across all 370: same element tags, same text leaves, zero documents with a genuinely different tree.

Gates, all on ea353af, exit codes captured before any pipe:

  • pnpm turbo run type-check --continue — Tasks: 1 successful, 1 total, Cached: 0 cached, 1 total (executed, not replayed)
  • pnpm turbo run build — Tasks: 1 successful, 1 total, Cached: 0 cached, 1 total, 47.9s
  • pnpm turbo run test --force — Tasks: 1 successful, 1 total, cache bypass, force executing
  • node .github/scripts/check-node-floor.mjs — "Every declared floor clears what the dependency tree requires, and the declarations agree."
  • node .github/scripts/check-translations.mjs — "translations gate passed"
  • node .github/scripts/check-translation-ownership.mjs --actor … --files … — "This PR touches 0 translation artifact(s) and 1 other file(s)."

tsc --noEmit --listFiles confirms page.tsx is genuinely in the type-check program, so the green covers the edited file rather than merely excluding it.

Scope

One file: apps/docs/app/[lang]/docs/[[...slug]]/page.tsx. lib/seo.ts is read, not edited — translatedLocales is used as it landed. lib/source.ts and the OG route are untouched (#185). No changeset flow in this repo, no packages/.

Filed while implementing, not fixed here: #187 — BreadcrumbList advertises /docs/reference and /docs/resources as crumb items on 98 prerendered pages, and neither path has a page in any locale. Pre-existing since #166; this PR changes which dead URL is emitted, not the fact that it is dead.


Generated by Claude Code

…rves

`/ja/docs/operate/backup` has no Japanese source. fumadocs falls back to the
default language and serves the English body, so the URL was a second copy of
the English page that declared itself canonical. #169 removed it from the
sitemap and from every hreflang cluster, but "no longer advertised" is not "no
longer indexable" — the in-page language switcher links to it from every
translated page.

Every field that asserts where this content lives now names the English URL
whenever the current locale has no real translation: `alternates.canonical`,
`og:url`, the `TechArticle` `url`, and each `BreadcrumbList` `item`. Breadcrumb
items resolve crumb by crumb, because a translated page can sit under an
untranslated ancestor. `og:locale` and `TechArticle.inLanguage` follow the URL
they sit next to — a node naming the English URL while claiming `ja` would
assert that the English page is Japanese.

`alternates.languages` is untouched: an untranslated locale is already absent
from the cluster, which is #169's settled shape.

Read out of the prerendered HTML: 218 of 553 docs routes change canonical,
which equals the count of untranslated locale/page pairs computed independently
from `content/docs/`. All 81 English routes are byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
@hotlong
hotlong marked this pull request as ready for review August 25, 2026 21:42
@hotlong
hotlong merged commit 5602623 into main Aug 25, 2026
2 checks passed
hotlong added a commit that referenced this pull request Aug 26, 2026
…gs to (#190)

Fixes #185

The generated share card was locale-independent in two compounding places —
getPageImage built its URL from page.slugs alone, and the OG route resolved the
page with no language argument — so all seven locales served one English card.
A Japanese reader sharing a Japanese page got an English preview.

getPageImage(page, lang) now carries the locale, and the route passes that
language to the loader. lang is required rather than optional-with-a-default:
the defect being fixed is a call site that did not pass a language, and a
default is what lets the next one reproduce it silently. The locale is spelled
out for English too, because an omitted "en" would make /og/docs/es/... ambiguous
between the Spanish card and the English card for a page slugged "es".

Static params enumerate over translatedLocales() from the English page set, so a
page that exists only in English gets exactly one card: 335 cards, matching an
independent count off the content tree, not a naive 553. On an untranslated
locale route the card URL follows the #188 fallback rule and names the English
card — the body being served is the English page, so the card depicting it is
the English card. That also makes contentLang a member of translatedLocales() by
construction, so no emitted card URL can fall outside the enumeration, which is
what makes dynamicParams = false safe.

Build cost: 1.15x wall-clock (50.5s to 58.0s) for 4.24x the image count.
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.

Untranslated locale URLs still self-canonicalize after #169 — /ja/docs/operate/backup declares itself canonical while serving the English body

2 participants