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..081b9f8 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,116 @@ 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"]); + +/** + * Group resource keys by primary language subtag, so a bare `ko` can find + * `ko-KR` without anyone maintaining a side map by hand. + */ +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; +} + +/** + * 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]; +} + +/** + * 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]; + }; +} + +/** + * 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(): { +export function getLanguageDetectionOptions(resourceKeys: string[]): { 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: createLanguageNormalizer(resourceKeys), }; } diff --git a/packages/i18n/src/i18n.ts b/packages/i18n/src/i18n.ts index a3eafa3..bc15a6c 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,21 @@ const resources = { }, } as const; +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(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"], @@ -32,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 new file mode 100644 index 0000000..7bf8aaf --- /dev/null +++ b/packages/i18n/tests/locale-resolution.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from "vitest"; +import { + chromeUiLanguageDetector, + createLanguageNormalizer, + getLanguageDetectionOptions, +} 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("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"], + ["en-US", "en-US"], + ["en-GB", "en-US"], + ["en-CN", "en-US"], + ["en-AU", "en-US"], + + // 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"], + ["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. + ["ja", "ja"], + ["ja-JP", "ja-JP"], + ["fr-FR", "fr-FR"], + ["de-DE", "de-DE"], + ]; + + it.each(cases)("maps %s to %s", (detected, resource) => { + expect(normalize(detected)).toBe(resource); + }); + + it("is case-insensitive", () => { + 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"); + }); +}); + +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(SHIPPED_RESOURCE_KEYS).order).toEqual([ + "chromeUILanguage", + "navigator", + ]); + }); + + it("normalises whatever the detectors report", () => { + const { convertDetectedLanguage } = getLanguageDetectionOptions(SHIPPED_RESOURCE_KEYS); + expect(convertDetectedLanguage("en-GB")).toBe("en-US"); + expect(convertDetectedLanguage("zh-TW")).toBe("zh-CN"); + expect(convertDetectedLanguage("ja-JP")).toBe("ja-JP"); + }); + + it("does not write to page-origin storage", () => { + expect(getLanguageDetectionOptions(SHIPPED_RESOURCE_KEYS).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: