fix(docs): resolve a browser tag by BCP 47 truncation, one subtag at a time - #223
Merged
Conversation
…a time `normalizeLanguage` took two shots at every Accept-Language tag: the exact tag, then the substring before the first hyphen. `LANGUAGE_MAPPING` held only Chinese and Korean rows, so a browser sending a single region tag with no q-list missed both — `de-AT`, `fr-CA`, `es-MX` and `ja-JP` all negotiated to English, four locales we translate and nobody could reach. `zh-Hant-TW`, whose middle subtag names the script, skipped past `zh-Hant` to `zh` and landed on Simplified. Replace it with RFC 4647 lookup: walk the tag most-specific first, dropping one subtag per step, and consult the table at every step. Two orderings are now stated in the code rather than left to accident — most specific first, and the table beats truncation where they disagree. Every return leaves through one membership check against `i18n.languages`, so neither mechanism can invent a locale the site does not publish. The rows truncation now subsumes (`zh-CN`, `zh-SG`, `zh-Hans`, `zh-Hant`, `ko`, `ko-KR`) are removed; a differential run over 167 tags shows the pruned table and the full one agree on every one. What is left is the Chinese script decision, which truncation cannot make from the tag alone. Issue #217. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C
The truncation walk leaves lowercase Chinese tags on Simplified. Measured on both sides of the fix, unchanged by it, filed separately so the comment points somewhere instead of trailing off. 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:23
hotlong
added a commit
that referenced
this pull request
Aug 26, 2026
…ue never (#225) BCP 47 tags are case-insensitive (RFC 5646 section 2.1.1) and the canonical casing is a writing convention, not a wire constraint. Both lookup tables were written in that convention and matched with plain string equality, so zh-tw, zh-hk, zh-mo, zh-hant and zh-hant-tw negotiated to Simplified. The defect is wider than the card recorded: that holds for the lowercase direction only. DE-AT, JA-jp, KO-kr, ZH-TW and ZH-HANT-TW also fell through to English, since a bare de is lowercase by convention too. So this is a fold over the whole key space, not five added rows, and it closes the uppercase direction in the same stroke. One folded index built once from i18n.languages plus LANGUAGE_MAPPING, with the canonical string as the payload; #223's truncation walk consults it. The key is folded, the value never is -- a folded value would redirect to a path the site does not publish, which 404s and re-enters negotiation. The membership check is not removed but moved into the index build, so every value is a member of i18n.languages by construction and the walk still has one exit. Ruled rows are written last so a ruling wins over a published spelling if the two ever collide. pathnameHasLocale now matches a locale prefix case-insensitively and 308s to the canonical spelling. Decided by measurement: with a Traditional browser, /zh-hant/docs/quickstart previously 307'd to /zh-Hant/zh-hant/docs/quickstart -- negotiation prepending a locale to a path that already named one, a wrong answer rather than an absent one. Published locales only, never LANGUAGE_MAPPING, which rules on browser tags rather than URL aliases; no cookie written; no loop possible. 89 rows measured twice: 71 unchanged, 18 changed, all intended. The before column was measured on the pristine tree before any edit existed. 64 assertions machine-checked against the rows #223 recorded. Closes #220
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 #217
Notation: this repo's body sanitizer strips angle brackets and decodes HTML numeric character references, including inside code fences. Nothing below needs either — truncation steps are written with the word "to", and header captures are quoted as plain text.
All readings below are from
2dd7630, the head of this branch.What was wrong
normalizeLanguagetook exactly two shots at everyAccept-Languagetag: the exact tag, then the substring before the first hyphen.LANGUAGE_MAPPINGheld only Chinese and Korean rows, so for every other language both shots missed and the function handed back the tag unchanged — which is not ini18n.languagesonce it carries a region, so negotiation fell through to English.Measured, not reasoned: four of the six locales we pay to translate were unreachable for a browser sending a single region tag, and
zh-Hant-TW— whose middle subtag names the script — skipped straight pastzh-Hanttozhand landed on Simplified. That last row is the one #218 made newly wrong in kind rather than degree: now that Traditional ships, it is a wrong answer sitting beside four right ones.Most browsers send
de-AT,de;q=0.9, and the baredein the second list entry rescued them. That is why this survived unnoticed, and why every measurement below uses a single tag with no q-list.The change
One file,
apps/docs/middleware.ts, as the card's surface allowed.normalizeLanguagebecomesresolveSupportedLanguage(tag), which walks the tag the way RFC 4647 section 3.4 defines lookup: whole tag, drop the last subtag, repeat —zh-Hant-TW, thenzh-Hant, thenzh. It returns a supported locale orundefined; the caller takes the first tag that resolves to something.The two questions the card asked to be settled explicitly are settled in the code, not by accident:
1. Most specific first. The walk runs long to short, so
zh-Hant-TWreacheszh-Hantbefore it can reachzh. Short to long would answer Simplified for every Traditional tag that carries a region — the exact defect, with the sign flipped.2. The table beats truncation, at every step of the walk.
LANGUAGE_MAPPINGis a ruling about what a tag MEANS; truncation is the mechanical default for tags nobody has ruled on. Where they disagree the ruling wins — that is what keepszh-TWon Traditional instead of the Simplified that droppingTWwould reach. Consulting the table at every step rather than only on the full tag is also what lets a longer tag built on a ruled one land correctly:zh-TW-x-privatereaches Traditional (row in the matrix), where a table consulted only on the full tag would have dropped tozhand answered Simplified.The membership check is the only exit. Both paths leave through the same
SUPPORTED_LANGUAGES.includes(...), so neither mechanism can hand back a locale the site does not publish. Truncation manufactures strings out of whatever the client sent (xx,tlh), and a table row outliving the locale it names would do the same; either would otherwise produce a redirect to a path that 404s and re-enters negotiation.The table is now an exceptions list, and six rows left it
Truncation subsumes every row whose answer is its own base language, so
zh-CN,zh-SG,zh-Hans,zh-Hant,koandko-KRare removed. What remains is the one decision truncation cannot make, because the information is not in the tag:zhtozh-Hanszhis not a supported tag at all; the house answer for unqualified Chinese is Simplifiedzh-TW/zh-HK/zh-MOtozh-Hantzh, and answer SimplifiedThis is not cosmetic. A table containing
zh-CN,ko-KRandzh-Hansteaches the next reader — human or model — that region tags belong in the table, which is precisely whyde-ATwas never added and never worked. The comment above the table now says the rule: add a row only when the mechanical answer is WRONG, never when it is merely absent.Pruning is proven behaviour-neutral, not assumed. A differential harness ran three algorithms over a 167-tag corpus (every old table key, every supported tag, every card tag, the case variants, and a 12-region cross-product over 11 base languages): the old two-shot lookup, truncation with the FULL old table, and truncation with the pruned table that ships here.
All 66 changed tags are improvements in the card's direction: 13
de-*, 12es-*, 12fr-*, 12ja-*region tags moving from English to their language, and 17zh-Hant-*/zh-*-x-*tags moving from Simplified to Traditional.Verification
Against a running
next starton the build of this branch,Accept-Languageset to exactly one tag with no q-list, asserting theLocationheader. The before column is a real second run, not a reading of the source: the pre-fixmiddleware.tswas checked out over the tree, the mutation confirmed on disk by blob hash againstd454ccb, rebuilt withturbo run build --force, measured, then restored — restore proven by blob hash identity withHEADand an emptygit diff HEAD, not by an exit code.A. One tag, no q-list —
GET /docs/quickstartThis is the shape the defect lives in.
Accept-Languagede-AT/de/docs/quickstartfr-CA/fr/docs/quickstartes-MX/es/docs/quickstartja-JP/ja/docs/quickstartzh-Hant-TW/zh-Hans/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-Hans/docs/quickstart/zh-Hant/docs/quickstartzh-Hant-x-private/zh-Hans/docs/quickstart/zh-Hant/docs/quickstartzh-hant-tw/zh-Hans/docs/quickstart/zh-Hans/docs/quickstartzh-tw/zh-Hans/docs/quickstart/zh-Hans/docs/quickstartde-at/de/docs/quickstartko-kr/ko/docs/quickstart/ko/docs/quickstartFour rows are worth reading twice:
xx-YYandtlhland on English, not on an invented locale. Truncation exhausts the tag and returns nothing; negotiation moves on. That is the membership check doing its job.nan-Hant-TWandyue-HKalso land on English. Min Nan and Cantonese are not Mandarin, and truncation does not leak them intozh-Hantthrough the script subtag: the walk triesnan-Hant-TW,nan-Hant,nan, matches none, and stops. The tag would have to sayzhfor the Chinese rows to apply.zh-TW-x-privatereaches Traditional. This is the table being consulted at every step and not only on the full tag.zh-hant-twandzh-twstill reach Simplified. Matching is case-sensitive; this is pre-existing, unchanged in both columns, out of the card's scope, and now filed as Language-tag matching is case-sensitive, so a lowercasezh-tworzh-hant-twnegotiates to Simplified #220 with the comment in the code pointing at it.B. q-lists — the shape that already worked, confirmed not broken
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/quickstartxx-YY,de;q=0.9is the one to keep: an unresolvable first entry is skipped rather than ending the search, so German is still reached.C. Root path —
GET /Accept-Languagede-AT/dezh-Hant-TW/zh-Hans/zh-Hantja-JP/jaen/docs/docsxx-YY/docs/docsD. #204's six-case redirect table, unchanged
Host: www.objectos.app/docs/architecturehttps://docs.objectos.ai/docs/architectureHost: www.objectos.app/https://docs.objectos.ai/Host: www.objectos.app/docs/architecture?q=1https://docs.objectos.ai/docs/architecture?q=1(query kept)Host: www.objectos.app/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/architectureIdentical to the table #204 recorded.
E. Legacy
/cn/redirects/cn/zh-Hans/cn//cn(Next strips the trailing slash first, then the row above applies)/cn/docs/zh-Hans/docs/cn/docs/quickstart/zh-Hans/docs/quickstart/cnx/docscnxis notcn)/docs/cnThe two-hop
/cn/case is pre-existing Next path normalisation, identical in both columns, and left alone.F. Dotted machine-facing routes, with the worst-case header
Sent with
Accept-Language: zh-Hant-TW— the tag that now negotiates hardest — to confirm the matcher still excludes them./llms.txttext/plain;charset=UTF-8/llms-full.txttext/plain;charset=UTF-8/sitemap.xmlapplication/xml/robots.txttext/plain/llms.mdx/docs/architecturetext/markdownGates
All at
2dd7630, each read from the command's own verdict line, with the exit code captured before any pipe.pnpm turbo run type-check --continueTasks: 1 successful, 1 total(cache miss, real execution)pnpm turbo run build --forceCompiled successfully in 60spnpm turbo run test --force3 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 locale surface reads 409 URLs, up from the 346 #204 recorded, which is #218's Traditional set now being advertised.
--forceon build and test because the turbo cache is shared across worktrees in this container and a replayed sibling green would make the before/after comparison meaningless.Scope
One file. No changeset (this repo has no changeset flow). No content, no locale files, no
i18n.ts— the locale list is untouched, so the oracle the locale gate derives is untouched too.Filed while here, not touched here: #220, the case-sensitivity gap described above.
Generated by Claude Code
Generated by Claude Code