From 759eebd4b6ad06c5fda79861a7a8d635adc40095 Mon Sep 17 00:00:00 2001 From: iAstro Date: Wed, 2 Sep 2026 18:46:24 +0800 Subject: [PATCH] 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: