mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 12:22:34 +03:00
fix(i18n): RFC 4647 truncation so zh-Hant tags resolve to zh-TW; guard alias wiring in LocaleAutoDetect
This commit is contained in:
@@ -350,7 +350,7 @@
|
||||
"native": "中文 (繁體)",
|
||||
"english": "Chinese (Traditional)",
|
||||
"flag": "🇹🇼",
|
||||
"aliases": ["zh-hk", "zh-mo"]
|
||||
"aliases": ["zh-hk", "zh-mo", "zh-hant"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user