diff --git a/packages/i18n/src/chrome-storage-sync.ts b/packages/i18n/src/chrome-storage-sync.ts index 182bf62..081b9f8 100644 --- a/packages/i18n/src/chrome-storage-sync.ts +++ b/packages/i18n/src/chrome-storage-sync.ts @@ -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 { + const index = new Map(); + 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), }; } diff --git a/packages/i18n/src/i18n.ts b/packages/i18n/src/i18n.ts index d88bd72..bc15a6c 100644 --- a/packages/i18n/src/i18n.ts +++ b/packages/i18n/src/i18n.ts @@ -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, diff --git a/packages/i18n/tests/locale-resolution.test.ts b/packages/i18n/tests/locale-resolution.test.ts index 2949c70..7bf8aaf 100644 --- a/packages/i18n/tests/locale-resolution.test.ts +++ b/packages/i18n/tests/locale-resolution.test.ts @@ -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([]); }); });