Unverified Commit 6943d9c4 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(i18n): 繁体、港台和不带地区的中文浏览器都显示中文界面

parent 4291d08e
Loading
Loading
Loading
Loading
+9 −6
Changes for panel/src/i18n/index.ts: 9 added lines, 6 removed lines.
Original line number Diff line number Diff line
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;
+71 −0
Changes for panel/src/i18n/language.test.ts: 71 added lines, 0 removed lines.
Original line number Diff line number Diff line
// @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");
  });
});
+17 −0
Changes for panel/src/i18n/language.ts: 17 added lines, 0 removed lines.
Original line number Diff line number Diff line
// 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;
}
+1 −1
Changes for panel/vite.config.ts: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -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}"],
    },
+38 −0
Changes for panel/vitest.storage.ts: 38 added lines, 0 removed lines.
Original line number Diff line number Diff line
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());
}