mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Merge pull request #174 from iAstro/fix/extension-locale-normalization
fix(i18n): match browser locale by language subtag instead of exact code
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string[]> {
|
||||
const index = new Map<string, string[]>();
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Generated
+4
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user