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 { initReactI18next } from "react-i18next";
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import enCommon from "./resources/en-US/common.json";
|
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 zhBackups from "./resources/zh-CN/backups.json";
|
||||||
import zhSubmissions from "./resources/zh-CN/submissions.json";
|
import zhSubmissions from "./resources/zh-CN/submissions.json";
|
||||||
import zhFiles from "./resources/zh-CN/files.json";
|
import zhFiles from "./resources/zh-CN/files.json";
|
||||||
|
import { panelLanguage, SUPPORTED_LANGUAGES } from "./language";
|
||||||
|
|
||||||
i18next
|
// Exported so a test can boot a fresh instance with the same detection.
|
||||||
.use(LanguageDetector)
|
export const i18nOptions: InitOptions = {
|
||||||
.use(initReactI18next)
|
|
||||||
.init({
|
|
||||||
resources: {
|
resources: {
|
||||||
"en-US": {
|
"en-US": {
|
||||||
common: enCommon,
|
common: enCommon,
|
||||||
@@ -60,6 +59,7 @@ i18next
|
|||||||
files: zhFiles,
|
files: zhFiles,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
supportedLngs: [...SUPPORTED_LANGUAGES],
|
||||||
fallbackLng: "en-US",
|
fallbackLng: "en-US",
|
||||||
defaultNS: "common",
|
defaultNS: "common",
|
||||||
interpolation: {
|
interpolation: {
|
||||||
@@ -69,7 +69,10 @@ i18next
|
|||||||
order: ["localStorage", "navigator"],
|
order: ["localStorage", "navigator"],
|
||||||
caches: ["localStorage"],
|
caches: ["localStorage"],
|
||||||
lookupLocalStorage: "felis-lang",
|
lookupLocalStorage: "felis-lang",
|
||||||
|
convertDetectedLanguage: panelLanguage,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
i18next.use(LanguageDetector).use(initReactI18next).init(i18nOptions);
|
||||||
|
|
||||||
export default i18next;
|
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: {
|
test: {
|
||||||
setupFiles: ["./vitest.setup.ts"],
|
setupFiles: ["./vitest.storage.ts", "./vitest.setup.ts"],
|
||||||
// e2e/*.spec.ts belong to Playwright (npm run test:e2e).
|
// e2e/*.spec.ts belong to Playwright (npm run test:e2e).
|
||||||
include: ["src/**/*.test.{ts,tsx}"],
|
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