Found while shipping #172. Filed unassigned. Pre-existing on main; #172 did not introduce it and did not change this function.
Notation: this repo's issue-body sanitizer strips angle brackets and decodes HTML numeric character references, including inside code fences. Nothing below needs either.
The reading
apps/docs/middleware.ts:
function normalizeLanguage(lang: string): string {
if (LANGUAGE_MAPPING[lang]) return LANGUAGE_MAPPING[lang];
const baseLang = lang.split('-')[0];
if (LANGUAGE_MAPPING[baseLang]) return LANGUAGE_MAPPING[baseLang];
return lang;
}
Two lookups: the exact tag, then the substring before the first hyphen. The result is then required to be in i18n.languages.
LANGUAGE_MAPPING holds only Chinese and Korean entries. So for every other language the second lookup misses too, and the function returns the tag unchanged — which is not in i18n.languages when it carries a region.
Consequences, for a browser that sends a region tag without also listing the bare language:
Accept-Language |
negotiated |
should be |
de-AT |
en |
de |
fr-CA |
en |
fr |
es-MX |
en |
es |
ja-JP |
en |
ja |
zh-Hant-TW |
zh-Hans |
zh-Hant |
zh-Hans-CN |
zh-Hans (by luck, via the bare zh entry) |
zh-Hans |
Most real browsers send de-AT,de;q=0.9, and the bare de on the second pass rescues it — which is why this has not been noticed. The ones that send a single region tag fall through to English.
The zh-Hant-TW row is the one that got worse in kind rather than degree: before #172 every Chinese browser landed on Simplified, so a script-plus-region tag landing on Simplified was indistinguishable from the general policy. Now that Traditional ships, zh-Hant-TW is a wrong answer where zh-TW, zh-HK, zh-MO and zh-Hant are all right ones.
Why it is not just "add more table rows"
Adding de-AT, fr-CA, zh-Hant-TW … enumerates a set that has no end. The shape of the bug is that the fallback chain drops all subtags in one step instead of one at a time. BCP 47 truncation is: try the whole tag, then drop the last subtag, repeat, until something matches — zh-Hant-TW -> zh-Hant -> zh. That is a few lines and closes the class.
Whichever route is taken, the mapping table and the truncation need to agree about which wins, and the result must still be checked against i18n.languages rather than returned raw.
Verification
Against a running server, Accept-Language set to exactly one tag with no q-list, asserting the Location header of /docs/quickstart. Measured today on the #172 branch: zh-TW, zh-HK, zh-MO, zh-Hant all reach /zh-Hant/docs/quickstart, and zh-CN, zh-Hans reach /zh-Hans/...; the region tags in the table above were not covered by that run and are the cases this issue is about.
Found while shipping #172. Filed unassigned. Pre-existing on
main; #172 did not introduce it and did not change this function.Notation: this repo's issue-body sanitizer strips angle brackets and decodes HTML numeric character references, including inside code fences. Nothing below needs either.
The reading
apps/docs/middleware.ts:Two lookups: the exact tag, then the substring before the first hyphen. The result is then required to be in
i18n.languages.LANGUAGE_MAPPINGholds only Chinese and Korean entries. So for every other language the second lookup misses too, and the function returns the tag unchanged — which is not ini18n.languageswhen it carries a region.Consequences, for a browser that sends a region tag without also listing the bare language:
Accept-Languagede-ATendefr-CAenfres-MXenesja-JPenjazh-Hant-TWzh-Hanszh-Hantzh-Hans-CNzh-Hans(by luck, via the barezhentry)zh-HansMost real browsers send
de-AT,de;q=0.9, and the baredeon the second pass rescues it — which is why this has not been noticed. The ones that send a single region tag fall through to English.The
zh-Hant-TWrow is the one that got worse in kind rather than degree: before #172 every Chinese browser landed on Simplified, so a script-plus-region tag landing on Simplified was indistinguishable from the general policy. Now that Traditional ships,zh-Hant-TWis a wrong answer wherezh-TW,zh-HK,zh-MOandzh-Hantare all right ones.Why it is not just "add more table rows"
Adding
de-AT,fr-CA,zh-Hant-TW… enumerates a set that has no end. The shape of the bug is that the fallback chain drops all subtags in one step instead of one at a time. BCP 47 truncation is: try the whole tag, then drop the last subtag, repeat, until something matches —zh-Hant-TW->zh-Hant->zh. That is a few lines and closes the class.Whichever route is taken, the mapping table and the truncation need to agree about which wins, and the result must still be checked against
i18n.languagesrather than returned raw.Verification
Against a running server,
Accept-Languageset to exactly one tag with noq-list, asserting theLocationheader of/docs/quickstart. Measured today on the #172 branch:zh-TW,zh-HK,zh-MO,zh-Hantall reach/zh-Hant/docs/quickstart, andzh-CN,zh-Hansreach/zh-Hans/...; the region tags in the table above were not covered by that run and are the cases this issue is about.