diff --git a/config/i18n.json b/config/i18n.json index fc48aec286..941ea0577e 100644 --- a/config/i18n.json +++ b/config/i18n.json @@ -350,7 +350,7 @@ "native": "δΈ­ζ–‡ (繁體)", "english": "Chinese (Traditional)", "flag": "πŸ‡ΉπŸ‡Ό", - "aliases": ["zh-hk", "zh-mo"] + "aliases": ["zh-hk", "zh-mo", "zh-hant"] } ] } diff --git a/src/i18n/detectBrowserLocale.ts b/src/i18n/detectBrowserLocale.ts index ca23971b7a..47dd742fdf 100644 --- a/src/i18n/detectBrowserLocale.ts +++ b/src/i18n/detectBrowserLocale.ts @@ -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; +} diff --git a/tests/unit/i18n-config.test.ts b/tests/unit/i18n-config.test.ts index 5811e0f045..01bb1610ed 100644 --- a/tests/unit/i18n-config.test.ts +++ b/tests/unit/i18n-config.test.ts @@ -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"]); }); diff --git a/tests/unit/i18n-detect-browser-locale.test.ts b/tests/unit/i18n-detect-browser-locale.test.ts index eed1b27483..410045c397 100644 --- a/tests/unit/i18n-detect-browser-locale.test.ts +++ b/tests/unit/i18n-detect-browser-locale.test.ts @@ -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"); + }); }); diff --git a/tests/unit/ui/LocaleAutoDetect-refresh.test.tsx b/tests/unit/ui/LocaleAutoDetect-refresh.test.tsx index e013261252..576edf84d4 100644 --- a/tests/unit/ui/LocaleAutoDetect-refresh.test.tsx +++ b/tests/unit/ui/LocaleAutoDetect-refresh.test.tsx @@ -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); + }); });