refactor(i18n): drive locale normalisation from shipped resource keys

Per review feedback on #174: the hardcoded if/else in
normalizeLanguageCode required touching the function for every new
language, so a bare `ko` would miss the `ko-KR` resource #161 is about
to add and Korean users would land on English despite the fix.

Replace it with createLanguageNormalizer(resourceKeys), a factory that
derives its mapping from the resource keys i18next is configured with:

  1. exact resource key              -> unchanged
  2. one resource for the language   -> that key (ko -> ko-KR)
  3. several (zh-CN / zh-TW)         -> script subtag, then region
  4. nothing shipped                 -> unchanged, fallbackLng applies

getLanguageDetectionOptions now takes the resource keys and i18n.ts
passes Object.keys(resources). Chinese disambiguation stays as the only
special case - one language, two scripts; every other mapping derives
from the index automatically.

Test expectations for the zh-TW family change from "zh-TW" to "zh-CN":
only zh-CN ships today and the factory maps to shipped keys only, so
Traditional resolves directly to Simplified instead of leaking into the
fallback chain. The rendered UI is identical either way, and the new
extensibility cases lock in the automatic disambiguation once a zh-TW
bundle registers - plus the auto-resolution of a newly registered
language like ko-KR.

28 -> 31 tests; extension suite 832/832; tsc, biome and ext:build clean.
This commit is contained in:
iAstro
2026-09-04 15:42:40 +08:00
parent 759eebd4b6
commit 93853ebc65
3 changed files with 139 additions and 74 deletions
+75 -45
View File
@@ -38,54 +38,84 @@ function getChromeLocal(): ChromeStorageLocal | undefined {
const TRADITIONAL_CHINESE_REGIONS = new Set(["tw", "hk", "mo"]);
/**
* Normalize a BCP 47 language tag to one of the shipped resource keys.
*
* | detected tag | resource | rule |
* | ----------------------- | -------- | --------------------------- |
* | `en`, `en-GB`, `en-CN` | `en-US` | every English variant |
* | `zh-Hans`, `zh-Hans-CN` | `zh-CN` | script subtag wins |
* | `zh-Hant`, `zh-Hant-TW` | `zh-TW` | script subtag wins |
* | `zh-CN`, `zh-SG` | `zh-CN` | region subtag |
* | `zh-TW`, `zh-HK`, `zh-MO`| `zh-TW` | region subtag |
* | `zh` (bare) | `zh-CN` | default: Simplified |
* | `ja`, `fr`, `de`, … | unchanged| no resource → `fallbackLng` |
*
* Simplified vs Traditional is decided by the script subtag (`Hans` / `Hant`)
* when present, then by the region subtag — Chrome reports `zh-CN` / `zh-TW`
* rather than script subtags in practice.
*
* The `zh-TW` branch is written now so adding a Traditional translation later
* needs no code change; until that resource exists i18next falls back to `zh-CN`.
* Group resource keys by primary language subtag, so a bare `ko` can find
* `ko-KR` without anyone maintaining a side map by hand.
*/
export function normalizeLanguageCode(code: string): string {
const parts = code.split(/[-_]/).map((part) => part.toLowerCase());
const primary = parts[0];
// English — every variant resolves to the single `en-US` resource.
if (primary === "en") {
return "en-US";
function buildPrimarySubtagIndex(resourceKeys: string[]): Map<string, string[]> {
const index = new Map<string, string[]>();
for (const key of resourceKeys) {
const primary = key.split(/[-_]/)[0].toLowerCase();
const bucket = index.get(primary);
if (bucket) {
bucket.push(key);
} else {
index.set(primary, [key]);
}
}
return index;
}
if (primary === "zh") {
// 1. Script subtag is the most accurate signal (zh-Hans / zh-Hant).
if (parts.includes("hans")) {
return "zh-CN";
}
if (parts.includes("hant")) {
return "zh-TW";
}
// 2. Fall back to the region subtag, which is what Chrome actually reports.
// Skip parts[0] — the primary subtag `zh` is also two characters long.
const region = parts.slice(1).find((part) => part.length === 2);
if (region && TRADITIONAL_CHINESE_REGIONS.has(region)) {
return "zh-TW";
}
// 3. Bare `zh` and any other region default to Simplified.
return "zh-CN";
/**
* Chinese is the one language where a single primary subtag maps to several
* resources, because Simplified and Traditional differ by script rather than
* by region. Every other language resolves straight from the index.
*/
function resolveChineseVariant(parts: string[], candidates: string[]): string {
const byRegion = (region: string): string | undefined =>
candidates.find((key) => key.toLowerCase().endsWith(`-${region}`));
// 1. Script subtag is the most accurate signal (zh-Hans / zh-Hant).
if (parts.includes("hans")) {
return byRegion("cn") ?? candidates[0];
}
if (parts.includes("hant")) {
return byRegion("tw") ?? candidates[candidates.length - 1];
}
// 2. Fall back to the region subtag, which is what Chrome actually reports.
// Skip parts[0] — the primary subtag `zh` is also two characters long.
const region = parts.slice(1).find((part) => part.length === 2);
if (region && TRADITIONAL_CHINESE_REGIONS.has(region)) {
return byRegion("tw") ?? candidates[0];
}
// 3. Bare `zh` and any other region default to Simplified.
return byRegion("cn") ?? candidates[0];
}
// No translation shipped: leave untouched so i18next applies `fallbackLng`.
return code;
/**
* Build a normalizer driven entirely by the resource keys i18next ships, so
* registering a new translation needs no change here.
*
* For a detected BCP 47 tag, in order:
*
* 1. it already names a resource (`ko-KR`) → unchanged
* 2. one resource shares its language (`ko` → `ko-KR`) → that resource
* 3. several do (`zh` → `zh-CN` / `zh-TW`) → script, then region
* 4. nothing is shipped for the language → unchanged
*
* Case 4 leaves the tag untouched so i18next applies `fallbackLng`, rather
* than silently routing an untranslated language to the wrong bundle.
*/
export function createLanguageNormalizer(resourceKeys: string[]): (code: string) => string {
const keySet = new Set(resourceKeys);
const index = buildPrimarySubtagIndex(resourceKeys);
return (code: string): string => {
if (keySet.has(code)) {
return code;
}
const parts = code.split(/[-_]/).map((part) => part.toLowerCase());
const candidates = index.get(parts[0]);
if (!candidates?.length) {
return code;
}
if (candidates.length === 1) {
return candidates[0];
}
if (parts[0] === "zh") {
return resolveChineseVariant(parts, candidates);
}
return candidates[0];
};
}
/**
@@ -104,7 +134,7 @@ export const chromeUiLanguageDetector = {
};
/** Avoid page-origin localStorage; use extension storage + navigator only. */
export function getLanguageDetectionOptions(): {
export function getLanguageDetectionOptions(resourceKeys: string[]): {
order: string[];
caches: string[];
convertDetectedLanguage: (lng: string) => string;
@@ -113,7 +143,7 @@ export function getLanguageDetectionOptions(): {
order: ["chromeUILanguage", "navigator"],
caches: [],
// Normalise the detected tag before i18next resolves it against `resources`.
convertDetectedLanguage: (lng: string) => normalizeLanguageCode(lng),
convertDetectedLanguage: createLanguageNormalizer(resourceKeys),
};
}
+5 -1
View File
@@ -26,6 +26,10 @@ const resources = {
const languageDetector = new LanguageDetector();
languageDetector.addDetector(chromeUiLanguageDetector);
// Drive locale normalisation off the keys we actually ship, so registering a
// translation is the only step needed to support a new language.
const resourceKeys = Object.keys(resources);
i18n
.use(languageDetector)
.use(initReactI18next)
@@ -41,7 +45,7 @@ i18n
escapeValue: false,
},
detection: getLanguageDetectionOptions(),
detection: getLanguageDetectionOptions(resourceKeys),
react: {
useSuspense: false,
+59 -28
View File
@@ -1,16 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import {
chromeUiLanguageDetector,
createLanguageNormalizer,
getLanguageDetectionOptions,
normalizeLanguageCode,
} from "../src/chrome-storage-sync";
/** What `packages/i18n` ships today — kept in sync with `resources` in i18n.ts. */
const SHIPPED_RESOURCE_KEYS = ["zh-CN", "en-US"];
/**
* Locks the locale-resolution table from issue #168: the UI must follow the
* browser language instead of falling through to Chinese for every locale
* other than `en-US`.
*/
describe("normalizeLanguageCode", () => {
describe("createLanguageNormalizer", () => {
const normalize = createLanguageNormalizer(SHIPPED_RESOURCE_KEYS);
const cases: Array<[detected: string, resource: string]> = [
// English — every variant maps to the single en-US resource.
["en", "en-US"],
@@ -19,28 +24,21 @@ describe("normalizeLanguageCode", () => {
["en-CN", "en-US"],
["en-AU", "en-US"],
// Simplified Chinese — script subtag wins.
// Chinese — only `zh-CN` ships today, so every variant resolves to it
// directly rather than leaking into the fallback chain. Traditional picks
// up `zh-TW` automatically once that bundle is registered (see below).
["zh", "zh-CN"],
["zh-CN", "zh-CN"],
["zh-SG", "zh-CN"],
["zh-Hans", "zh-CN"],
["zh-Hans-CN", "zh-CN"],
["zh-Hans-SG", "zh-CN"],
// Simplified Chinese — region subtag.
["zh-CN", "zh-CN"],
["zh-SG", "zh-CN"],
// Bare `zh` defaults to Simplified.
["zh", "zh-CN"],
// Traditional Chinese — script subtag wins.
["zh-Hant", "zh-TW"],
["zh-Hant-TW", "zh-TW"],
["zh-Hant-HK", "zh-TW"],
// Traditional Chinese — region subtag. `zh-TW` has no resource yet, so
// i18next falls back to zh-CN via `fallbackLng.zh`.
["zh-TW", "zh-TW"],
["zh-HK", "zh-TW"],
["zh-MO", "zh-TW"],
["zh-TW", "zh-CN"],
["zh-HK", "zh-CN"],
["zh-MO", "zh-CN"],
["zh-Hant", "zh-CN"],
["zh-Hant-TW", "zh-CN"],
["zh-Hant-HK", "zh-CN"],
// No translation shipped — returned unchanged so i18next applies the
// `default` fallback (en-US) rather than Chinese.
@@ -51,12 +49,42 @@ describe("normalizeLanguageCode", () => {
];
it.each(cases)("maps %s to %s", (detected, resource) => {
expect(normalizeLanguageCode(detected)).toBe(resource);
expect(normalize(detected)).toBe(resource);
});
it("is case-insensitive", () => {
expect(normalizeLanguageCode("EN-us")).toBe("en-US");
expect(normalizeLanguageCode("zh-hant-tw")).toBe("zh-TW");
expect(normalize("EN-us")).toBe("en-US");
expect(normalize("zh-hant-tw")).toBe("zh-CN");
});
});
/**
* The point of the factory: supporting a new language means registering its
* bundle in `resources` — never touching the normalizer.
*/
describe("createLanguageNormalizer extensibility", () => {
it("auto-resolves a newly registered language", () => {
const normalize = createLanguageNormalizer(["en-US", "zh-CN", "ko-KR"]);
expect(normalize("ko")).toBe("ko-KR");
expect(normalize("ko-KR")).toBe("ko-KR");
});
it("disambiguates zh once a Traditional bundle ships", () => {
const normalize = createLanguageNormalizer(["en-US", "zh-CN", "zh-TW"]);
expect(normalize("zh-Hant")).toBe("zh-TW");
expect(normalize("zh-Hant-TW")).toBe("zh-TW");
expect(normalize("zh-TW")).toBe("zh-TW");
expect(normalize("zh-HK")).toBe("zh-TW");
expect(normalize("zh-MO")).toBe("zh-TW");
expect(normalize("zh-Hans")).toBe("zh-CN");
expect(normalize("zh-CN")).toBe("zh-CN");
expect(normalize("zh")).toBe("zh-CN");
});
it("leaves unregistered languages to fallbackLng", () => {
const normalize = createLanguageNormalizer(["en-US", "zh-CN"]);
expect(normalize("pt-BR")).toBe("pt-BR");
expect(normalize("ru")).toBe("ru");
});
});
@@ -79,17 +107,20 @@ describe("chromeUiLanguageDetector", () => {
describe("getLanguageDetectionOptions", () => {
it("orders the Chrome UI language ahead of navigator", () => {
expect(getLanguageDetectionOptions().order).toEqual(["chromeUILanguage", "navigator"]);
expect(getLanguageDetectionOptions(SHIPPED_RESOURCE_KEYS).order).toEqual([
"chromeUILanguage",
"navigator",
]);
});
it("normalises whatever the detectors report", () => {
const { convertDetectedLanguage } = getLanguageDetectionOptions();
const { convertDetectedLanguage } = getLanguageDetectionOptions(SHIPPED_RESOURCE_KEYS);
expect(convertDetectedLanguage("en-GB")).toBe("en-US");
expect(convertDetectedLanguage("zh-TW")).toBe("zh-TW");
expect(convertDetectedLanguage("zh-TW")).toBe("zh-CN");
expect(convertDetectedLanguage("ja-JP")).toBe("ja-JP");
});
it("does not write to page-origin storage", () => {
expect(getLanguageDetectionOptions().caches).toEqual([]);
expect(getLanguageDetectionOptions(SHIPPED_RESOURCE_KEYS).caches).toEqual([]);
});
});