From 6943d9c4c63452fb67cf3190c77f7b06607676b1 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sun, 27 Sep 2026 06:22:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(i18n):=20=E7=B9=81=E4=BD=93=E3=80=81?= =?UTF-8?q?=E6=B8=AF=E5=8F=B0=E5=92=8C=E4=B8=8D=E5=B8=A6=E5=9C=B0=E5=8C=BA?= =?UTF-8?q?=E7=9A=84=E4=B8=AD=E6=96=87=E6=B5=8F=E8=A7=88=E5=99=A8=E9=83=BD?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E4=B8=AD=E6=96=87=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- panel/src/i18n/index.ts | 91 +++++++++++++++++---------------- panel/src/i18n/language.test.ts | 71 +++++++++++++++++++++++++ panel/src/i18n/language.ts | 17 ++++++ panel/vite.config.ts | 2 +- panel/vitest.storage.ts | 38 ++++++++++++++ 5 files changed, 174 insertions(+), 45 deletions(-) create mode 100644 panel/src/i18n/language.test.ts create mode 100644 panel/src/i18n/language.ts create mode 100644 panel/vitest.storage.ts diff --git a/panel/src/i18n/index.ts b/panel/src/i18n/index.ts index d11e2e7..a50bf7a 100644 --- a/panel/src/i18n/index.ts +++ b/panel/src/i18n/index.ts @@ -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,51 +25,54 @@ 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({ - resources: { - "en-US": { - common: enCommon, - auth: enAuth, - dashboard: enDashboard, - servers: enServers, - account: enAccount, - admin: enAdmin, - ops: enOps, - errors: enErrors, - navigation: enNavigation, - backups: enBackups, - submissions: enSubmissions, - files: enFiles, - }, - "zh-CN": { - common: zhCommon, - auth: zhAuth, - dashboard: zhDashboard, - servers: zhServers, - account: zhAccount, - admin: zhAdmin, - ops: zhOps, - errors: zhErrors, - navigation: zhNavigation, - backups: zhBackups, - submissions: zhSubmissions, - files: zhFiles, - }, +// Exported so a test can boot a fresh instance with the same detection. +export const i18nOptions: InitOptions = { + resources: { + "en-US": { + common: enCommon, + auth: enAuth, + dashboard: enDashboard, + servers: enServers, + account: enAccount, + admin: enAdmin, + ops: enOps, + errors: enErrors, + navigation: enNavigation, + backups: enBackups, + submissions: enSubmissions, + files: enFiles, }, - fallbackLng: "en-US", - defaultNS: "common", - interpolation: { - escapeValue: false, + "zh-CN": { + common: zhCommon, + auth: zhAuth, + dashboard: zhDashboard, + servers: zhServers, + account: zhAccount, + admin: zhAdmin, + ops: zhOps, + errors: zhErrors, + navigation: zhNavigation, + backups: zhBackups, + submissions: zhSubmissions, + files: zhFiles, }, - detection: { - order: ["localStorage", "navigator"], - caches: ["localStorage"], - lookupLocalStorage: "felis-lang", - }, - }); + }, + supportedLngs: [...SUPPORTED_LANGUAGES], + fallbackLng: "en-US", + defaultNS: "common", + interpolation: { + escapeValue: false, + }, + detection: { + order: ["localStorage", "navigator"], + caches: ["localStorage"], + lookupLocalStorage: "felis-lang", + convertDetectedLanguage: panelLanguage, + }, +}; + +i18next.use(LanguageDetector).use(initReactI18next).init(i18nOptions); export default i18next; diff --git a/panel/src/i18n/language.test.ts b/panel/src/i18n/language.test.ts new file mode 100644 index 0000000..a7abf5b --- /dev/null +++ b/panel/src/i18n/language.test.ts @@ -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"); + }); +}); diff --git a/panel/src/i18n/language.ts b/panel/src/i18n/language.ts new file mode 100644 index 0000000..6472c69 --- /dev/null +++ b/panel/src/i18n/language.ts @@ -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; +} diff --git a/panel/vite.config.ts b/panel/vite.config.ts index ec8d77b..75e710e 100644 --- a/panel/vite.config.ts +++ b/panel/vite.config.ts @@ -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}"], }, diff --git a/panel/vitest.storage.ts b/panel/vitest.storage.ts new file mode 100644 index 0000000..c77aaa4 --- /dev/null +++ b/panel/vitest.storage.ts @@ -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(); + 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()); +}