fix(i18n): RFC 4647 truncation so zh-Hant tags resolve to zh-TW; guard alias wiring in LocaleAutoDetect

This commit is contained in:
Markus Hartung
2026-09-02 03:15:24 -03:00
parent b7318e46c6
commit daadfba2f8
5 changed files with 102 additions and 32 deletions

View File

@@ -350,7 +350,7 @@
"native": "中文 (繁體)",
"english": "Chinese (Traditional)",
"flag": "🇹🇼",
"aliases": ["zh-hk", "zh-mo"]
"aliases": ["zh-hk", "zh-mo", "zh-hant"]
}
]
}

View File

@@ -2,18 +2,24 @@
* Pure browser-language detector used to pick an initial locale on first
* visit, before the user has made an explicit selection (no cookie set).
*
* Matching order:
* 1. Exact match against `navigator.languages` entries (case-insensitive).
* 2. `zh-HK` / `zh-MO` are treated as `zh-TW` (Traditional Chinese) since
* OmniRoute does not ship a dedicated Hong-Kong/Macau locale.
* 3. Declared alias — `aliases` has the shape of `LOCALE_ALIASES` from
* `@/i18n/config` (locale code → lower-case BCP-47 tags) and is matched on
* the full tag or on its base language, e.g. `fil`, `fil-PH`, `tl` → `phi`
* and `uk` → `uk-UA`. Aliases of locales not in `locales` are ignored.
* 4. Language-prefix match — e.g. `en-US` matches a supported `en` locale.
* 5. Bare base language → first supported regional locale of that language,
* in `locales` (config) order — e.g. `uk` → `uk-UA`, `zh` → `zh-CN`.
* 6. No match → `null` (caller should keep the existing default).
* Matching order, per `navigator.languages` entry (all comparisons are
* case-insensitive; the first entry that matches wins):
* 1. RFC 4647 lookup truncation — the tag is tried from its full form down to
* its base language, dropping one trailing subtag at a time
* (`zh-Hant-TW` → `zh-hant` → `zh`, `en-US` → `en`). Each candidate is
* checked for (a) an exact supported locale, then (b) a declared alias.
* `aliases` has the shape of `LOCALE_ALIASES` from `@/i18n/config` (locale
* code → lower-case BCP-47 tags): `fil`, `fil-PH`, `tl` → `phi`,
* `uk` → `uk-UA`, `zh-Hant`, `zh-Hant-TW`, `zh-Hant-HK` → `zh-TW`. Aliases
* of locales not in `locales` are ignored. The base-language candidate is
* the classic language-prefix match (`en-US` → `en`, `sr-Latn-RS` → `sr`),
* so no separate prefix step is needed after this one.
* 2. `zh-HK` / `zh-MO` fold to `zh-TW` (Traditional Chinese) when nothing
* above matched — kept for callers that pass no aliases.
* 3. Base language → first supported regional locale of that language, in
* `locales` (config) order — e.g. `uk` → `uk-UA`, `zh` and `zh-Hans-CN`
* → `zh-CN`.
* 4. No match → `null` (caller should keep the existing default).
*
* Kept dependency-free (no DOM/`navigator` access) so it is trivially unit
* testable and reusable from both client components and future server code.
@@ -42,10 +48,17 @@ export function detectBrowserLocale(
const language = rawLanguage.toLowerCase();
const prefix = language.split("-")[0];
// 1. Exact match.
const exactIndex = normalizedLocales.indexOf(language);
if (exactIndex !== -1) {
return locales[exactIndex];
// 1. RFC 4647 lookup: full tag → … → base language. At every level an
// exact supported locale wins, then a declared alias.
for (const candidate of lookupCandidates(language)) {
const exactIndex = normalizedLocales.indexOf(candidate);
if (exactIndex !== -1) {
return locales[exactIndex];
}
const aliased = aliasIndex.get(candidate);
if (aliased) {
return aliased;
}
}
// 2. zh-HK / zh-MO fold to zh-TW when zh-TW is supported (kept for callers
@@ -57,19 +70,7 @@ export function detectBrowserLocale(
}
}
// 3. Declared alias, on the full tag or on its base language (fil-PH → phi).
const aliased = aliasIndex.get(language) ?? aliasIndex.get(prefix);
if (aliased) {
return aliased;
}
// 4. Language-prefix match (e.g. "en-US" -> "en").
const prefixIndex = normalizedLocales.indexOf(prefix);
if (prefixIndex !== -1) {
return locales[prefixIndex];
}
// 5. Bare base language → first supported regional locale of that language
// 3. Base language → first supported regional locale of that language
// ("uk" -> "uk-UA", "zh" -> "zh-CN"). Config order decides the tie.
const regionalIndex = normalizedLocales.findIndex(
(locale) => locale.includes("-") && locale.split("-")[0] === prefix
@@ -81,3 +82,17 @@ export function detectBrowserLocale(
return null;
}
/**
* RFC 4647 §3.4 lookup candidates for a lower-cased tag: the full tag, then
* each shorter form obtained by dropping the last subtag, down to the base
* language — `zh-hant-tw` → `["zh-hant-tw", "zh-hant", "zh"]`.
*/
function lookupCandidates(tag: string): string[] {
const subtags = tag.split("-");
const candidates: string[] = [];
for (let length = subtags.length; length >= 1; length -= 1) {
candidates.push(subtags.slice(0, length).join("-"));
}
return candidates;
}

View File

@@ -50,8 +50,10 @@ test("locale aliases are lower-case, unique and never collide with a locale code
}
});
test("Ukrainian, Filipino and Hong-Kong/Macau browsers resolve through declared aliases", () => {
test("Ukrainian, Filipino, Hong-Kong/Macau and zh-Hant browsers resolve through declared aliases", () => {
assert.deepEqual(LOCALE_ALIASES["uk-UA"], ["uk"]);
assert.deepEqual(LOCALE_ALIASES["phi"], ["fil", "tl"]);
assert.deepEqual(LOCALE_ALIASES["zh-TW"], ["zh-hk", "zh-mo"]);
// `zh-hant` lets script-tagged Traditional Chinese (`zh-Hant-TW`, `zh-Hant-HK`)
// reach zh-TW instead of the first zh-* locale in config order (zh-CN).
assert.deepEqual(LOCALE_ALIASES["zh-TW"], ["zh-hk", "zh-mo", "zh-hant"]);
});

View File

@@ -71,4 +71,34 @@ describe("detectBrowserLocale", () => {
it("still returns null when nothing matches, even with aliases", () => {
assert.equal(detectBrowserLocale(["ja-JP"], REGIONAL_LOCALES, ALIASES), null);
});
// RFC 4647 lookup truncation (`zh-Hant-TW` → `zh-hant` → `zh`): a script-tagged
// Traditional-Chinese browser must reach `zh-TW` through the declared `zh-hant`
// alias instead of falling through to the first `zh-*` locale in config order
// (`zh-CN`, Simplified). Same alias list `config/i18n.json` declares for zh-TW.
const SCRIPT_ALIASES = { ...ALIASES, "zh-TW": ["zh-hk", "zh-mo", "zh-hant"] } as const;
it("resolves zh-Hant-TW to zh-TW through the zh-hant alias, not to zh-CN", () => {
assert.equal(detectBrowserLocale(["zh-Hant-TW"], REGIONAL_LOCALES, SCRIPT_ALIASES), "zh-TW");
});
it("resolves bare zh-Hant to zh-TW through the zh-hant alias", () => {
assert.equal(detectBrowserLocale(["zh-Hant"], REGIONAL_LOCALES, SCRIPT_ALIASES), "zh-TW");
});
it("resolves zh-Hant-HK to zh-TW through the zh-hant alias, not to zh-CN", () => {
assert.equal(detectBrowserLocale(["zh-Hant-HK"], REGIONAL_LOCALES, SCRIPT_ALIASES), "zh-TW");
});
it("resolves zh-Hans-CN to zh-CN through the base language (no zh-hans alias needed)", () => {
assert.equal(detectBrowserLocale(["zh-Hans-CN"], REGIONAL_LOCALES, SCRIPT_ALIASES), "zh-CN");
});
it("still folds zh-HK to zh-TW without aliases, even when zh-CN precedes zh-TW", () => {
assert.equal(detectBrowserLocale(["zh-HK"], REGIONAL_LOCALES), "zh-TW");
});
it("truncates a script+region tag down to its base-language locale (sr-Latn-RS → sr)", () => {
assert.equal(detectBrowserLocale(["sr-Latn-RS"], ["en", "sr"]), "sr");
});
});

View File

@@ -58,4 +58,27 @@ describe("LocaleAutoDetect refresh gating", () => {
await mount();
expect(refresh).toHaveBeenCalledTimes(1);
});
// Alias wiring guard: the component must hand `LOCALE_ALIASES` to
// `detectBrowserLocale`. `fil` (Filipino) is the discriminating input — no
// locale code starts with `fil`, so with the real config it resolves to `phi`
// ONLY through the declared alias; a forgotten third argument yields null and
// no cookie is written.
it("persists the aliased locale for a Filipino browser (fil → phi)", async () => {
document.documentElement.lang = "en";
Object.defineProperty(navigator, "languages", { value: ["fil"], configurable: true });
await mount();
expect(document.cookie).toContain("NEXT_LOCALE=phi");
expect(refresh).toHaveBeenCalledTimes(1);
});
// Script-tagged Traditional Chinese must not fall through to zh-CN (Simplified,
// the first zh-* locale in config order): `zh-hant` is a declared alias of zh-TW.
it("persists zh-TW for a zh-Hant-TW browser, not zh-CN", async () => {
document.documentElement.lang = "en";
Object.defineProperty(navigator, "languages", { value: ["zh-Hant-TW"], configurable: true });
await mount();
expect(document.cookie).toContain("NEXT_LOCALE=zh-TW");
expect(refresh).toHaveBeenCalledTimes(1);
});
});