fix(docs): match language tags case-insensitively, key folded and value never - #225
Merged
Merged
Conversation
…ue never BCP 47 tags are case-insensitive (RFC 5646 section 2.1.1), but both lookup tables in `middleware.ts` were matched with plain string equality. They are written in the recommended canonical casing, so a client that spells its tags differently missed rows that name it exactly. Measured on the pre-fix build: `zh-tw`, `zh-hk`, `zh-mo`, `zh-hant` and `zh-hant-tw` all negotiated to Simplified, since the Chinese keys are the only ones carrying a script or region subtag. The same run showed the lowercase direction is what is Chinese-only: `DE-AT`, `JA-jp`, `KO-kr` and `ZH-TW` fell through to English. One case-folded index is now built once from `i18n.languages` and `LANGUAGE_MAPPING`, with the canonical string as the payload, and the RFC 4647 truncation walk consults it. The key is folded; the value never is, so the redirect target stays a path the site publishes. The membership check is not removed, it is moved into the index build and paid once: every value is a member of `i18n.languages` by construction, so the walk still has exactly one exit and cannot hand back an unpublished locale. The path side is settled rather than left to accident. A locale prefix is now matched case-insensitively and a non-canonical spelling gets a 308 to the canonical one, because the measured status quo was not a clean 404: with a Traditional browser, `/zh-hant/docs/quickstart` 307'd to `/zh-Hant/zh-hant/docs/quickstart`, prepending a locale to a path that already named one. Published locales only, never `LANGUAGE_MAPPING`; no cookie written; no loop possible. The reasoning is recorded in the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
hotlong
marked this pull request as ready for review
August 26, 2026 09:48
This was referenced Aug 26, 2026
hotlong
added a commit
that referenced
this pull request
Aug 26, 2026
…228) The Host header carries host[:port], so comparing it whole against a bare hostname meant www.objectos.app:8080 was never equal to LEGACY_HOST. It fell through to locale negotiation and was served under the legacy host (200) instead of redirected off it (308). Three lines below, the same block already stripped the port from the target, so the port was accounted for on the way out and not on the way in. Compares the hostname now. The target-side normalisation is unchanged. Verified with the redirect table established by #204, #223, #225 and #227, plus two rows: the ported legacy host now 308s with no port on the target, and Host: docs.objectos.ai:3000 stays 200 on both sides -- which proves the comparison is against LEGACY_HOST specifically rather than a blanket port strip, a fix that would have passed every other row. The before column was reproduced by rebuilding and running the pre-fix file, not inferred. Closes #226
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #220
Notation: this repo's body sanitizer strips angle brackets and decodes HTML numeric character references, including inside code fences. Nothing below needs either — RFC section numbers are written as the word "section", and redirect steps are written as the word "to".
All readings below are from
90e6c3f, the head of this branch.What was wrong
apps/docs/middleware.tslooked a browser tag up with plain string equality. BCP 47 tags are case-insensitive — RFC 5646 section 2.1.1: "the tags and their subtags ... are not case sensitive" — and the casing the spec recommends (lowercase language, Titlecase script, UPPERCASE region) is a writing convention, not a constraint on the wire. Both lookup tables are written in that convention, so a client spelling its tags differently missed rows that name it exactly.Measured on a rebuilt pre-fix tree, one tag, no q-list, asserting
Locationon/docs/quickstart:zh-tw,zh-hk,zh-mo,zh-hantandzh-hant-twall negotiated to Simplified, because the Chinese keys are the only ones carrying a script or region subtag. Those are the five rows the card was filed for, and they reproduce exactly.The same run found the defect is wider than the card's picture of it. The card says the other six locales are unaffected because their supported tags are already all-lowercase. That holds for the lowercase direction only. Any other casing missed every locale:
DE-AT,JA-jpandKO-krfell through to English, andZH-TWandZH-HANT-TWdid too — a baredeis lowercase by convention as well, not by rule. So the fix is written as a fold over the whole key space, not as five more table rows, and it closes the uppercase direction in the same stroke. Those rows are in table A2 below.The change
One file,
apps/docs/middleware.ts, as the card's surface allowed.One folded index, consulted by #217's walk
PUBLISHED_BY_FOLDED_LOCALEmaps each folded locale spelling to the canonical stringi18n.languagesitself holds.CANONICAL_BY_FOLDED_TAGis that map plus theLANGUAGE_MAPPINGrulings, andresolveSupportedLanguage— the RFC 4647 truncation walk that landed with #217 — now folds each candidate and takes a single look at it. The walk is otherwise untouched: still long to short, still consulting the rulings at every step.Merging the two tables preserves #217's second ordering decision structurally rather than by repetition: the ruled rows are written last, so where a tag is both published and ruled, the ruling wins. No such tag exists today, and the order is fixed in code so that adding one cannot silently reverse the rule.
The key is folded. The value never is.
The payload is always the canonical string taken from
i18n.languages, never the folded key and never the caller's spelling. A folded value would build a redirect to/zh-hant/..., which the site does not publish: it 404s, that 404 re-enters negotiation, and a wrong-script page becomes a loop.The membership check is still the only exit
#217 made a single check against
i18n.languagesthe one door out of the walk. That check is not removed here — it is moved to where the index is built, and paid once. A supported locale enters the index as itself; aLANGUAGE_MAPPINGrow enters only if its target resolves through the published map. Every value in the index is therefore a member ofi18n.languagesby construction, so the walk still has exactly one exit and still cannot hand back a locale the site does not publish. Same invariant, established at module load instead of re-checked per lookup. No second door.The
FD_LOCALEcookie is deliberately not folded, and the file says so: it is written by this file and nowhere else, always in canonical form, so folding it would widen only what a hand-set cookie can say. The fold belongs where strings arrive from outside — the header, and the path.The decision:
pathnameHasLocalenow accepts a case variant and 308s to canonicalThe card and the dispatch both asked for this to be settled either way rather than left to fall out of the matcher. It is settled in the code, with the reasoning written there.
Both answers are defensible on their face. URL path segments are case-sensitive (RFC 3986 section 6.2.2.1), so reading
/zh-hant/...as a different resource is legitimate, and an extra redirect hop is a real cost.What decided it is what the file actually did, measured before choosing:
GET /zh-hant/docs/quickstartGET /zh-hant/docs/quickstartwithAccept-Language: zh-Hant/zh-Hant/zh-hant/docs/quickstart, which 404sThe second row settles it. A lowercase locale path did not fail as an unknown URL. It fell through to negotiation, which prepended a locale to a path that already named one and produced a URL nobody could have meant. That is the header-side defect's own shape — a wrong answer rather than an absent one, arrived at by accident — and it is reachable the same way, by a proxy that lowercases what it forwards.
So the first path segment is matched against the published locales case-insensitively, and a non-canonical spelling gets a 308 to the spelling the site publishes. 308 for the same reason as the legacy
/cn/rows: permanent, and it collapses the aliases onto one indexable URL./zh-Hant/...still costs zero hops; only a URL that is broken today pays anything.Three things it deliberately does not do, each stated in the file:
LANGUAGE_MAPPING. That table rules on what a browser tag means; it is not a list of URL aliases./zh-TW/docsstays a 404 rather than becoming a second name for/zh-Hant/docs— serving one page under two URLs is an SEO problem this file has no mandate to create.next()); this hop is URL normalisation, not negotiation, and/zh-hant/xshould not carry a side effect that/zh-Hant/xlacks.i18n.languages, so the redirected request matches canonically and falls straight through. Confirmed by following the chains, below./EN/docstherefore takes two hops — 308 to/en/docs, then the default-locale strip 307s to/docs. Same shape as the pre-existing two-hop/cn/case; each rule stays responsible for one thing.Verification
Against a running
next start,Accept-Languageset to exactly one tag with no q-list, asserting theLocationheader. 89 rows, run twice — 71 unchanged, 18 changed, and every one of the 18 is an intended row of this fix.How the before column was produced
The before column is a real second run on a rebuilt pre-fix tree, not a reading of the source.
Method, and a deliberate deviation from the mutate-measure-restore shape #223 used: the pre-fix column was measured first, on the pristine
cae6bf5tree, before this branch had any edit on it. That is strictly stronger than mutating and restoring — there was never a fixed build in the worktree to leak into the before column, and no restore to get wrong. The equivalent proofs were taken at the moment of the run:Both builds used
--force: the turbo cache is shared across worktrees in this container, and a replayed sibling green would make the comparison meaningless. Both builds reportedcache bypass, force executing.The edit itself was confirmed on disk by content, not by an editor's exit code — the patch script fails loudly on a zero-match anchor, and the landing was checked by blob hash plus grep counts of the inserted and the removed text:
The committed tree is byte-identical to the tree the after column was measured on:
git rev-parse HEAD:apps/docs/middleware.tsis63661d4991cf29df567b27359f0eb97cf77107b2, the same blob the after build was made from, andgit statusis clean.A. #223's 33 single-tag rows,
GET /docs/quickstartRe-run in full. Two rows move; they are the two #220 was filed for.
Accept-Languagede-AT/de/docs/quickstart/de/docs/quickstartfr-CA/fr/docs/quickstart/fr/docs/quickstartes-MX/es/docs/quickstart/es/docs/quickstartja-JP/ja/docs/quickstart/ja/docs/quickstartzh-Hant-TW/zh-Hant/docs/quickstart/zh-Hant/docs/quickstartzh-TW/zh-Hant/docs/quickstart/zh-Hant/docs/quickstartzh-HK/zh-Hant/docs/quickstart/zh-Hant/docs/quickstartzh-MO/zh-Hant/docs/quickstart/zh-Hant/docs/quickstartzh-Hant/zh-Hant/docs/quickstart/zh-Hant/docs/quickstartzh-CN/zh-Hans/docs/quickstart/zh-Hans/docs/quickstartzh-Hans/zh-Hans/docs/quickstart/zh-Hans/docs/quickstartzh/zh-Hans/docs/quickstart/zh-Hans/docs/quickstartzh-SG/zh-Hans/docs/quickstart/zh-Hans/docs/quickstartzh-Hans-CN/zh-Hans/docs/quickstart/zh-Hans/docs/quickstartko/ko/docs/quickstart/ko/docs/quickstartko-KR/ko/docs/quickstart/ko/docs/quickstartde/de/docs/quickstart/de/docs/quickstartes/es/docs/quickstart/es/docs/quickstartfr/fr/docs/quickstart/fr/docs/quickstartja/ja/docs/quickstart/ja/docs/quickstartenen-GBxx-YYtlhnan-Hant-TWyue-HK*zh-TW-x-private/zh-Hant/docs/quickstart/zh-Hant/docs/quickstartzh-Hant-x-private/zh-Hant/docs/quickstart/zh-Hant/docs/quickstartzh-hant-tw/zh-Hans/docs/quickstart/zh-Hant/docs/quickstartzh-tw/zh-Hans/docs/quickstart/zh-Hant/docs/quickstartde-at/de/docs/quickstart/de/docs/quickstartko-kr/ko/docs/quickstart/ko/docs/quickstartA2. The rest of #220's rows, and the case space around them
zh-hk,zh-moandzh-hantcomplete the card's five. The uppercase rows were not in the card and are the reason the fix is written as a fold rather than as five extra table entries.Accept-Languagezh-hk/zh-Hans/docs/quickstart/zh-Hant/docs/quickstartzh-mo/zh-Hans/docs/quickstart/zh-Hant/docs/quickstartzh-hant/zh-Hans/docs/quickstart/zh-Hant/docs/quickstartZH-HANT-TW/zh-Hant/docs/quickstartzh-HANT/zh-Hans/docs/quickstart/zh-Hant/docs/quickstartZH-TW/zh-Hant/docs/quickstartDE-AT/de/docs/quickstartEN-GBKO-kr/ko/docs/quickstartJA-jp/ja/docs/quickstartzh-hans/zh-Hans/docs/quickstart/zh-Hans/docs/quickstartzh-hans-cn/zh-Hans/docs/quickstart/zh-Hans/docs/quickstartzh-tw-x-private/zh-Hans/docs/quickstart/zh-Hant/docs/quickstartNAN-HANT-TWYUE-hkEN-GBdoes not move, but it is right for a different reason afterwards: before, it missed bothen-GBandenand fell through to the default, which happens to be English. Now it matchesenand negotiates to it.B. q-lists — unchanged
Accept-Languageja-JP,ja;q=0.9/ja/docs/quickstart/ja/docs/quickstartzh-TW,zh;q=0.9/zh-Hant/docs/quickstart/zh-Hant/docs/quickstartde-AT,de;q=0.9/de/docs/quickstart/de/docs/quickstartxx-YY,de;q=0.9/de/docs/quickstart/de/docs/quickstarten-US,en;q=0.9zh-Hant-TW,zh-Hant;q=0.9,zh;q=0.8/zh-Hant/docs/quickstart/zh-Hant/docs/quickstartC. Root path
GET /— unchangedAccept-Languagede-AT/de/dezh-Hant-TW/zh-Hant/zh-Hantja-JP/ja/jaen/docs/docsxx-YY/docs/docsD. #204's six-case host table — unchanged
Host: www.objectos.app/docs/architecturehttps://docs.objectos.ai/docs/architecturehttps://docs.objectos.ai/docs/architectureHost: www.objectos.app/https://docs.objectos.ai/https://docs.objectos.ai/Host: www.objectos.app/docs/architecture?q=1https://docs.objectos.ai/docs/architecture?q=1https://docs.objectos.ai/docs/architecture?q=1Host: www.objectos.app/zh-Hans/docs/architecturehttps://docs.objectos.ai/zh-Hans/docs/architecturehttps://docs.objectos.ai/zh-Hans/docs/architectureHost: docs.objectos.ai/docs/architectureHost: docs.objectos.ai/cn/docs/architecture/zh-Hans/docs/architecture/zh-Hans/docs/architectureE. Legacy
/cn/redirects — unchanged/cn/zh-Hans/zh-Hans/cn//cn/cn/cn/docs/zh-Hans/docs/zh-Hans/docs/cn/docs/quickstart/zh-Hans/docs/quickstart/zh-Hans/docs/quickstart/cnx/docs/docs/cnF. Dotted machine-facing routes, all sent with
Accept-Language: zh-Hant-TW— unchangedThe worst-case header, to confirm the matcher still excludes these routes from negotiation entirely.
/llms.txttext/plain;charset=UTF-8/llms-full.txttext/plain;charset=UTF-8/sitemap.xmlapplication/xml/robots.txttext/plain/llms.mdx/docs/architecturetext/markdownG. The
pathnameHasLocaledecision, before and afterRequested explicitly: the before and after for
/zh-hant/docs/quickstartand/zh-Hant/docs/quickstart, plus the neighbours that show the rule's edges. NoAccept-Languageheader is sent on these rows. A 200 here is the localized page itself, not an English fallback./zh-hant/docs/quickstart/zh-Hant/docs/quickstart/zh-Hant/docs/quickstart/zh-hans/docs/quickstart/zh-Hans/docs/quickstart/zh-Hans/docs/quickstart/EN/docs/quickstart/en/docs/quickstart/en/docs/quickstart/docs/quickstart/docs/quickstart/DE/docs/quickstart/de/docs/quickstart/de/docs/quickstart/zh-hant/zh-Hant/zh-Hant/zh-Hant/docs/zh-Hant/docs/zh-Hantfoo/docs/quickstart/zh-Hantfoo/docs/quickstartis the control: matching is on the whole first segment, so a locale that is merely a prefix of the segment is still not a locale.G2. The row that decided it
A Traditional browser on the lowercase path. The before column is not a clean 404 — it is a redirect that prepends a locale to a path that already names one.
/zh-hant/docs/quickstart(Accept-Language: zh-Hant)/zh-Hant/zh-hant/docs/quickstart/zh-Hant/docs/quickstart/zh-Hant/docs/quickstart(Accept-Language: zh-Hant)Chains followed, to prove the new 308 resolves
And the page actually served is in the right script, not merely the right
Location:Machine-checked against #223's recorded rows
The after column was asserted row by row against the values PR #223 recorded, rather than eyeballed: 33 table-A rows, 6 q-list rows, 5 root-path rows, 6 host rows, 6 legacy
/cn/rows and 5 dotted routes with their content-types, plus the three remaining #220 rows. 64 assertions.Four rows worth reading twice, all unchanged:
nan-Hant-TWandyue-HKstill reach English — folding the key does not leak Min Nan or Cantonese intozh-Hantthrough the script subtag, because the walk still has to match a whole subtag prefix and none ofnan-hant-tw,nan-hant,nanis in the index.xx-YYandtlhstill invent nothing — they exhaust the tag and negotiation moves on. That is the membership check still doing its job from inside the index build.Gates
All at
90e6c3f, re-run as a union after the final commit, each read from the command's own verdict line with the exit code captured before any pipe.pnpm turbo run type-check --continue --forceTasks: 1 successful, 1 total(cache bypass, force executing)pnpm turbo run build --forceCompiled successfully in 58spnpm turbo run test --force4 self-test(s) passednode .github/scripts/check-locale-surface.mjsnode apps/docs/scripts/gen-zh-hant.mjs --check73 generated file(s) match the zh-Hans sources byte for bytenode .github/scripts/check-node-floor.mjsEvery declared floor clears what the dependency tree requiresThe test gate reads
4 self-test(s) passedwhere #223 recorded 3. That is not this change: #223 branched fromd454ccband so never carried #222's added self-test, which is onmainunderneath this branch.check-translation-ownership.mjswas not run as a gate — invoked bare it exits 1 on its own usage message, because it takes--actorand--filesfrom the workflow. That is an invocation error, not a measurement, so it is recorded here as not measured rather than as a result. CI runs it with its arguments.Scope
One file. No changeset — this repo has no changeset flow, and no
.changesetdirectory. No content, no locale files, noi18n.ts: the locale list is untouched, so the oracle the locale-surface gate derives is untouched too, which is why it still reads 409.Considered and deliberately left alone:
LEGACY_LOCALE_REDIRECTSis still matched case-sensitively, so/CN/docsremains a 404. Folding it would be a change to a legacy alias table rather than to language-tag matching,cnis not a BCP 47 tag, and no measured client produces the uppercase form. Naming it here so it is visible rather than discovered.No new issues were filed: nothing was found outside this card's own defect.
Generated by Claude Code
Generated by Claude Code