From 759eebd4b6ad06c5fda79861a7a8d635adc40095 Mon Sep 17 00:00:00 2001 From: iAstro Date: Wed, 2 Sep 2026 18:46:24 +0800 Subject: [PATCH 1/2] fix(i18n): match browser locale by language subtag instead of exact code The extension ships en-US and zh-CN resources, but only en-US was ever selected: every other browser language fell through to fallbackLng ("zh-CN"), so users on en, en-GB, en-CN, en-AU, ja-JP, fr-FR saw Chinese. Match on the BCP 47 language subtag and normalise it to a resource key: en* -> en-US zh-Hans* / zh-CN / zh-SG / zh -> zh-CN zh-Hant* / zh-TW / zh-HK / zh-MO -> zh-TW anything else -> unchanged (fallbackLng default: en-US) Simplified vs Traditional is decided by the script subtag (Hans/Hant) when present, then by the region subtag, since Chrome reports zh-CN / zh-TW rather than script subtags. The zh-TW branch is written now so adding a Traditional translation later needs no code change. chrome.i18n.getUILanguage() is now preferred over navigator.language, which is unreliable on the chrome-extension:// origin the popup runs on. Adds a locale-resolution regression matrix (28 cases). Closes #168 --- packages/i18n/package.json | 7 ++ packages/i18n/src/chrome-storage-sync.ts | 77 ++++++++++++++- packages/i18n/src/i18n.ts | 15 ++- packages/i18n/tests/locale-resolution.test.ts | 95 +++++++++++++++++++ packages/i18n/vitest.config.ts | 8 ++ pnpm-lock.yaml | 4 + 6 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 packages/i18n/tests/locale-resolution.test.ts create mode 100644 packages/i18n/vitest.config.ts diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 6856a2d..dac463a 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -13,6 +13,10 @@ "default": "./src/react.tsx" } }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, "peerDependencies": { "react": "^19.0.0" }, @@ -20,5 +24,8 @@ "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.2", "react-i18next": "^15.3.4" + }, + "devDependencies": { + "vitest": "^4.1.6" } } diff --git a/packages/i18n/src/chrome-storage-sync.ts b/packages/i18n/src/chrome-storage-sync.ts index 239b98f..182bf62 100644 --- a/packages/i18n/src/chrome-storage-sync.ts +++ b/packages/i18n/src/chrome-storage-sync.ts @@ -24,6 +24,9 @@ declare const chrome: ) => void; }; }; + i18n?: { + getUILanguage(): string; + }; } | undefined; @@ -31,14 +34,86 @@ function getChromeLocal(): ChromeStorageLocal | undefined { return typeof chrome !== "undefined" ? chrome.storage?.local : undefined; } +/** Regions whose written Chinese defaults to Traditional. */ +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`. + */ +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"; + } + + 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"; + } + + // No translation shipped: leave untouched so i18next applies `fallbackLng`. + return code; +} + +/** + * i18next detector that prefers `chrome.i18n.getUILanguage()`, Chrome's + * authoritative source for the extension UI language. The popup runs on a + * `chrome-extension://` origin where `navigator.language` can disagree with it. + */ +export const chromeUiLanguageDetector = { + name: "chromeUILanguage", + lookup(): string | undefined { + if (typeof chrome !== "undefined" && chrome.i18n?.getUILanguage) { + return chrome.i18n.getUILanguage(); + } + return undefined; + }, +}; + /** Avoid page-origin localStorage; use extension storage + navigator only. */ export function getLanguageDetectionOptions(): { order: string[]; caches: string[]; + convertDetectedLanguage: (lng: string) => string; } { return { - order: ["navigator"], + order: ["chromeUILanguage", "navigator"], caches: [], + // Normalise the detected tag before i18next resolves it against `resources`. + convertDetectedLanguage: (lng: string) => normalizeLanguageCode(lng), }; } diff --git a/packages/i18n/src/i18n.ts b/packages/i18n/src/i18n.ts index a3eafa3..d88bd72 100644 --- a/packages/i18n/src/i18n.ts +++ b/packages/i18n/src/i18n.ts @@ -2,7 +2,11 @@ import i18n from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import { initReactI18next } from "react-i18next"; -import { bindChromeStorageLanguageSync, getLanguageDetectionOptions } from "./chrome-storage-sync"; +import { + bindChromeStorageLanguageSync, + chromeUiLanguageDetector, + getLanguageDetectionOptions, +} from "./chrome-storage-sync"; import enUSCommon from "./locales/en-US/common.json"; import enUSExtension from "./locales/en-US/extension.json"; import zhCNCommon from "./locales/zh-CN/common.json"; @@ -19,12 +23,17 @@ const resources = { }, } as const; +const languageDetector = new LanguageDetector(); +languageDetector.addDetector(chromeUiLanguageDetector); + i18n - .use(LanguageDetector) + .use(languageDetector) .use(initReactI18next) .init({ resources, - fallbackLng: "zh-CN", + // English is the international default; Chinese users still get Chinese. + // `zh` → zh-CN also covers zh-TW/zh-HK until a Traditional resource exists. + fallbackLng: { en: ["en-US"], zh: ["zh-CN"], default: ["en-US"] }, defaultNS: "common", ns: ["common", "extension"], diff --git a/packages/i18n/tests/locale-resolution.test.ts b/packages/i18n/tests/locale-resolution.test.ts new file mode 100644 index 0000000..2949c70 --- /dev/null +++ b/packages/i18n/tests/locale-resolution.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { + chromeUiLanguageDetector, + getLanguageDetectionOptions, + normalizeLanguageCode, +} from "../src/chrome-storage-sync"; + +/** + * 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", () => { + const cases: Array<[detected: string, resource: string]> = [ + // English — every variant maps to the single en-US resource. + ["en", "en-US"], + ["en-US", "en-US"], + ["en-GB", "en-US"], + ["en-CN", "en-US"], + ["en-AU", "en-US"], + + // Simplified Chinese — script subtag wins. + ["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"], + + // No translation shipped — returned unchanged so i18next applies the + // `default` fallback (en-US) rather than Chinese. + ["ja", "ja"], + ["ja-JP", "ja-JP"], + ["fr-FR", "fr-FR"], + ["de-DE", "de-DE"], + ]; + + it.each(cases)("maps %s to %s", (detected, resource) => { + expect(normalizeLanguageCode(detected)).toBe(resource); + }); + + it("is case-insensitive", () => { + expect(normalizeLanguageCode("EN-us")).toBe("en-US"); + expect(normalizeLanguageCode("zh-hant-tw")).toBe("zh-TW"); + }); +}); + +describe("chromeUiLanguageDetector", () => { + it("prefers chrome.i18n.getUILanguage() when available", () => { + vi.stubGlobal("chrome", { i18n: { getUILanguage: () => "en-GB" } }); + expect(chromeUiLanguageDetector.lookup()).toBe("en-GB"); + }); + + it("returns undefined outside a Chrome extension context", () => { + vi.stubGlobal("chrome", undefined); + expect(chromeUiLanguageDetector.lookup()).toBeUndefined(); + }); + + it("returns undefined when the i18n API is unavailable", () => { + vi.stubGlobal("chrome", {}); + expect(chromeUiLanguageDetector.lookup()).toBeUndefined(); + }); +}); + +describe("getLanguageDetectionOptions", () => { + it("orders the Chrome UI language ahead of navigator", () => { + expect(getLanguageDetectionOptions().order).toEqual(["chromeUILanguage", "navigator"]); + }); + + it("normalises whatever the detectors report", () => { + const { convertDetectedLanguage } = getLanguageDetectionOptions(); + expect(convertDetectedLanguage("en-GB")).toBe("en-US"); + expect(convertDetectedLanguage("zh-TW")).toBe("zh-TW"); + expect(convertDetectedLanguage("ja-JP")).toBe("ja-JP"); + }); + + it("does not write to page-origin storage", () => { + expect(getLanguageDetectionOptions().caches).toEqual([]); + }); +}); diff --git a/packages/i18n/vitest.config.ts b/packages/i18n/vitest.config.ts new file mode 100644 index 0000000..faa6d98 --- /dev/null +++ b/packages/i18n/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d60c24f..4fc27be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,10 @@ importers: react-i18next: specifier: ^15.3.4 version: 15.7.4(i18next@24.2.3(typescript@5.9.3))(react@19.2.6)(typescript@5.9.3) + devDependencies: + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@25.8.0)(happy-dom@15.11.7)(vite@8.0.13(@types/node@25.8.0)(esbuild@0.27.7)(jiti@2.7.0)) packages/ui: dependencies: From 93853ebc6516ef1fa5408b686c9566f166ffe8e4 Mon Sep 17 00:00:00 2001 From: iAstro Date: Fri, 4 Sep 2026 15:42:40 +0800 Subject: [PATCH 2/2] 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. --- packages/i18n/src/chrome-storage-sync.ts | 120 +++++++++++------- packages/i18n/src/i18n.ts | 6 +- packages/i18n/tests/locale-resolution.test.ts | 87 +++++++++---- 3 files changed, 139 insertions(+), 74 deletions(-) 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([]); }); });