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
This commit is contained in:
iAstro
2026-09-04 10:03:43 +08:00
parent c455a09fc3
commit 759eebd4b6
6 changed files with 202 additions and 4 deletions
+7
View File
@@ -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"
}
}
+76 -1
View File
@@ -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),
};
}
+12 -3
View File
@@ -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"],
@@ -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([]);
});
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
},
});
+4
View File
@@ -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: