fix(i18n): 繁体、港台和不带地区的中文浏览器都显示中文界面
This commit is contained in:
5 files changed
+136
-7
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import i18next from "i18next";
|
||||
import i18next, { type InitOptions } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import enCommon from "./resources/en-US/common.json";
|
||||
@@ -25,11 +25,10 @@ import zhNavigation from "./resources/zh-CN/navigation.json";
|
||||
import zhBackups from "./resources/zh-CN/backups.json";
|
||||
import zhSubmissions from "./resources/zh-CN/submissions.json";
|
||||
import zhFiles from "./resources/zh-CN/files.json";
|
||||
import { panelLanguage, SUPPORTED_LANGUAGES } from "./language";
|
||||
|
||||
i18next
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
// Exported so a test can boot a fresh instance with the same detection.
|
||||
export const i18nOptions: InitOptions = {
|
||||
resources: {
|
||||
"en-US": {
|
||||
common: enCommon,
|
||||
@@ -60,6 +59,7 @@ i18next
|
||||
files: zhFiles,
|
||||
},
|
||||
},
|
||||
supportedLngs: [...SUPPORTED_LANGUAGES],
|
||||
fallbackLng: "en-US",
|
||||
defaultNS: "common",
|
||||
interpolation: {
|
||||
@@ -69,7 +69,10 @@ i18next
|
||||
order: ["localStorage", "navigator"],
|
||||
caches: ["localStorage"],
|
||||
lookupLocalStorage: "felis-lang",
|
||||
convertDetectedLanguage: panelLanguage,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
i18next.use(LanguageDetector).use(initReactI18next).init(i18nOptions);
|
||||
|
||||
export default i18next;
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import i18next from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { i18nOptions } from "./index";
|
||||
import { panelLanguage } from "./language";
|
||||
|
||||
// boot starts a fresh instance with the panel's own options, as a first visit
|
||||
// with these browser languages (and whatever is in storage) would.
|
||||
async function boot(languages: string[]) {
|
||||
vi.spyOn(window.navigator, "languages", "get").mockReturnValue(languages);
|
||||
vi.spyOn(window.navigator, "language", "get").mockReturnValue(languages[0] ?? "");
|
||||
const instance = i18next.createInstance().use(LanguageDetector);
|
||||
await instance.init(i18nOptions);
|
||||
return instance;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("the panel language on a first visit", () => {
|
||||
it.each([
|
||||
[["zh-CN"]],
|
||||
[["zh-TW"]],
|
||||
[["zh-HK", "en"]],
|
||||
[["zh"]],
|
||||
[["zh-Hant-TW"]],
|
||||
// English listed after Chinese must not win.
|
||||
[["zh-TW", "en-US", "en"]],
|
||||
[["zh", "en-US"]],
|
||||
])("reads Chinese for a browser asking %j", async (languages) => {
|
||||
const i18n = await boot(languages);
|
||||
|
||||
expect(i18n.language).toBe("zh-CN");
|
||||
expect(i18n.t("common:loading")).toBe(i18n.getFixedT("zh-CN")("common:loading"));
|
||||
expect(localStorage.getItem("felis-lang")).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it.each([[["en-GB"]], [["en-US", "zh-CN"]], [["fr-FR", "de"]], [[]]])(
|
||||
"reads English for a browser asking %j",
|
||||
async (languages) => {
|
||||
const i18n = await boot(languages);
|
||||
|
||||
expect(i18n.language).toBe("en-US");
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a language chosen here over the browser's", async () => {
|
||||
localStorage.setItem("felis-lang", "en-US");
|
||||
const i18n = await boot(["zh-CN"]);
|
||||
|
||||
expect(i18n.language).toBe("en-US");
|
||||
});
|
||||
|
||||
it("maps a regional code a past visit stored onto the shipped text", async () => {
|
||||
localStorage.setItem("felis-lang", "zh-TW");
|
||||
const i18n = await boot(["en-US"]);
|
||||
|
||||
expect(i18n.language).toBe("zh-CN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("panelLanguage", () => {
|
||||
it("maps any Chinese or English code onto a shipped translation", () => {
|
||||
expect(panelLanguage("zh_TW")).toBe("zh-CN");
|
||||
expect(panelLanguage("ZH-hk")).toBe("zh-CN");
|
||||
expect(panelLanguage("en")).toBe("en-US");
|
||||
expect(panelLanguage("ja-JP")).toBe("ja-JP");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// The panel ships two translations; everything a browser reports is mapped onto
|
||||
// one of them before i18next picks. Any Chinese (zh, zh-TW, zh-HK, zh-Hant, a
|
||||
// stale "zh_TW" in storage) reads the zh-CN text and any English the en-US
|
||||
// text. Mapping at detection matters: i18next first looks for an exact
|
||||
// supported code anywhere in the browser's list, so ["zh-TW", "en-US"] would
|
||||
// land on English if zh-TW reached it unmapped.
|
||||
|
||||
export const SUPPORTED_LANGUAGES = ["en-US", "zh-CN"] as const;
|
||||
|
||||
/** panelLanguage maps a detected language code onto a shipped translation, or
|
||||
* returns it unchanged for i18next to pass over. */
|
||||
export function panelLanguage(code: string): string {
|
||||
const base = code.trim().toLowerCase().split(/[-_]/)[0];
|
||||
if (base === "zh") return "zh-CN";
|
||||
if (base === "en") return "en-US";
|
||||
return code;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default defineConfig(async ({ mode }) => {
|
||||
},
|
||||
},
|
||||
test: {
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
setupFiles: ["./vitest.storage.ts", "./vitest.setup.ts"],
|
||||
// e2e/*.spec.ts belong to Playwright (npm run test:e2e).
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { beforeEach } from "vitest";
|
||||
|
||||
// Node 26 puts its own localStorage on globalThis, undefined unless node runs
|
||||
// with --localstorage-file, and it shadows jsdom's. Component tests get an
|
||||
// in-memory one so code that remembers things (the language, console history)
|
||||
// runs as in a browser. This file runs before the i18n boot in vitest.setup.ts,
|
||||
// because the language detector decides once whether storage works.
|
||||
class MemoryStorage implements Storage {
|
||||
#data = new Map<string, string>();
|
||||
get length() {
|
||||
return this.#data.size;
|
||||
}
|
||||
key(index: number) {
|
||||
return [...this.#data.keys()][index] ?? null;
|
||||
}
|
||||
getItem(key: string) {
|
||||
return this.#data.get(key) ?? null;
|
||||
}
|
||||
setItem(key: string, value: string) {
|
||||
this.#data.set(key, String(value));
|
||||
}
|
||||
removeItem(key: string) {
|
||||
this.#data.delete(key);
|
||||
}
|
||||
clear() {
|
||||
this.#data.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && !globalThis.localStorage) {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: new MemoryStorage(),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
// Each test starts empty, including of the boot's own cached language.
|
||||
beforeEach(() => localStorage.clear());
|
||||
}
|
||||
Reference in new issue
Block a user