From 3a493850e91d5c44980fa3b8257fabae57f59040 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 09:11:43 +0000 Subject: [PATCH 1/2] fix(docs): resolve a browser tag by BCP 47 truncation, one subtag at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C --- apps/docs/middleware.ts | 124 ++++++++++++++++++++++++++++------------ 1 file changed, 87 insertions(+), 37 deletions(-) diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index 368a0fa..0450063 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -42,20 +42,34 @@ function setLocaleCookie(response: NextResponse, locale: string): void { } /** - * Language code mapping - * Maps browser language codes to our supported language codes + * Tags whose correct locale is NOT the one BCP 47 truncation would reach. + * + * This is an exceptions list, not a catalogue of browser tags. Truncation + * (see `resolveSupportedLanguage`) already resolves every tag whose answer is + * its own base language: `de-AT` reaches `de`, `ja-JP` reaches `ja`, `ko-KR` + * reaches `ko`, `zh-Hans-CN` reaches `zh-Hans`. Rows for those are not + * shorthand, they are noise — and they teach the next reader that region tags + * belong in this table, which is how `de-AT`, `fr-CA`, `es-MX` and `ja-JP` + * came to negotiate to English while four translated locales sat unused. + * Enumerating them is a set with no end (#217). + * + * What is left is the one decision truncation cannot make, because the + * information is not in the tag: + * + * - `zh` on its own is not a supported tag at all, and the house answer for + * unqualified Chinese is Simplified. + * - `zh-TW` / `zh-HK` / `zh-MO` name a region that implies the Traditional + * script. Truncation would drop the region, reach `zh`, and hand back + * Simplified — a wrong answer rather than an absent one. + * + * Add a row here only when the mechanical answer is WRONG. If it is merely + * missing, the fix belongs in `i18n.languages`, not here. */ const LANGUAGE_MAPPING: Record = { - 'zh': 'zh-Hans', // Chinese -> Simplified - 'zh-CN': 'zh-Hans', // Chinese (China) -> Simplified - 'zh-SG': 'zh-Hans', // Chinese (Singapore) -> Simplified - 'zh-Hans': 'zh-Hans', // already Simplified - 'zh-Hant': 'zh-Hant', // already Traditional - 'zh-TW': 'zh-Hant', // Chinese (Taiwan) -> Traditional - 'zh-HK': 'zh-Hant', // Chinese (Hong Kong) -> Traditional - 'zh-MO': 'zh-Hant', // Chinese (Macau) -> Traditional - 'ko': 'ko', // Korean - 'ko-KR': 'ko', // Korean (Korea) -> Korean + 'zh': 'zh-Hans', // unqualified Chinese -> Simplified (house default) + 'zh-TW': 'zh-Hant', // Taiwan -> Traditional + 'zh-HK': 'zh-Hant', // Hong Kong -> Traditional + 'zh-MO': 'zh-Hant', // Macau -> Traditional }; /** @@ -67,21 +81,60 @@ const LEGACY_LOCALE_REDIRECTS: Record = { }; /** - * Normalize language code to match our supported languages + * Resolve one browser language tag to a supported locale, or `undefined` when + * the tag names nothing we publish. + * + * BCP 47 truncation, as RFC 4647 section 3.4 defines lookup: try the whole + * tag, drop the last subtag, repeat — `zh-Hant-TW`, then `zh-Hant`, then `zh`. + * + * The function this replaced took two shots only: the exact tag, and the + * substring before the first hyphen. Since `LANGUAGE_MAPPING` held nothing but + * Chinese and Korean, `de-AT`, `fr-CA`, `es-MX` and `ja-JP` missed both and + * fell through to English — four locales we translate, unreachable. And + * `zh-Hant-TW`, whose MIDDLE subtag names the script, skipped straight past + * `zh-Hant` to `zh` and landed on Simplified. Most browsers send + * `de-AT,de;q=0.9`, and the bare `de` in the second list entry rescued them, + * which is why this survived unnoticed (#217). + * + * Two orderings decide every answer here, and both are settled on purpose + * rather than by accident: + * + * 1. **Most specific first.** The walk runs long to short, so `zh-Hant-TW` + * reaches `zh-Hant` before it can reach `zh`. Short to long would answer + * Simplified for every Traditional tag that carries a region. + * + * 2. **The table beats truncation, at every step of the walk.** + * `LANGUAGE_MAPPING` is a ruling about what a tag MEANS; truncation is the + * mechanical default for tags nobody has ruled on. Where the two disagree + * the ruling wins — that is what keeps `zh-TW` on Traditional instead of + * the Simplified that dropping `TW` would reach. Consulting the table at + * every step, not only on the full tag, is also what lets a longer tag + * built on a ruled one (`zh-TW-x-anything`) still land on Traditional. + * + * Both paths leave through the same check against `i18n.languages`, and that + * check is the only exit from this function. Neither mechanism can hand back a + * locale the site does not publish: truncation manufactures strings from + * whatever the client sent (`xx`, `tlh`), and a table row outliving the locale + * it names would do the same. Either one would otherwise produce a redirect to + * a path that 404s. + * + * Matching is case-sensitive, so a client that lowercases its tags still + * misses the Chinese rows (`zh-tw` reaches Simplified). Pre-existing, out of + * scope here, tracked separately. */ -function normalizeLanguage(lang: string): string { - // Check direct mapping first - if (LANGUAGE_MAPPING[lang]) { - return LANGUAGE_MAPPING[lang]; - } - - // Check if the base language (without region) is mapped - const baseLang = lang.split('-')[0]; - if (LANGUAGE_MAPPING[baseLang]) { - return LANGUAGE_MAPPING[baseLang]; +function resolveSupportedLanguage(tag: string): string | undefined { + const subtags = tag.split('-'); + + for (let length = subtags.length; length > 0; length -= 1) { + const candidate = subtags.slice(0, length).join('-'); + + const ruled = LANGUAGE_MAPPING[candidate]; + if (ruled && SUPPORTED_LANGUAGES.includes(ruled)) return ruled; + + if (SUPPORTED_LANGUAGES.includes(candidate)) return candidate; } - - return lang; + + return undefined; } /** @@ -94,21 +147,18 @@ function getPreferredLanguage(request: NextRequest): string { return cookieLocale; } - // Then check Accept-Language header + // Then the Accept-Language header, in the browser's own order of preference. + // The first tag that resolves to a supported locale wins; a tag that + // resolves to nothing is skipped rather than ending the search, so + // `xx-YY,de` still reaches German. const negotiatorHeaders = Object.fromEntries(request.headers.entries()); const negotiator = new Negotiator({ headers: negotiatorHeaders }); - const browserLanguages = negotiator.languages(); - - // Normalize browser languages to match our supported languages - const normalizedLanguages = browserLanguages.map(normalizeLanguage); - - // Find the first match - for (const lang of normalizedLanguages) { - if (SUPPORTED_LANGUAGES.includes(lang)) { - return lang; - } + + for (const tag of negotiator.languages()) { + const supported = resolveSupportedLanguage(tag); + if (supported) return supported; } - + return i18n.defaultLanguage; } From 2dd7630518369025d7d04773ce29944b51982cc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 09:16:01 +0000 Subject: [PATCH 2/2] docs(middleware): name the case-sensitivity gap by its tracking card 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 Claude-Session: https://claude.ai/code/session_01G5zjYc2BoFV2NjKBBapC7C --- apps/docs/middleware.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/docs/middleware.ts b/apps/docs/middleware.ts index 0450063..9ec9fb4 100644 --- a/apps/docs/middleware.ts +++ b/apps/docs/middleware.ts @@ -119,8 +119,9 @@ const LEGACY_LOCALE_REDIRECTS: Record = { * a path that 404s. * * Matching is case-sensitive, so a client that lowercases its tags still - * misses the Chinese rows (`zh-tw` reaches Simplified). Pre-existing, out of - * scope here, tracked separately. + * misses the Chinese rows (`zh-tw` reaches Simplified). Pre-existing and + * unchanged by the truncation walk; measured on both sides of it and filed + * as #220. */ function resolveSupportedLanguage(tag: string): string | undefined { const subtags = tag.split('-');