diff --git a/graph-ui/src/lib/i18n.test.ts b/graph-ui/src/lib/i18n.test.ts index cc7b10141..44c923c26 100644 --- a/graph-ui/src/lib/i18n.test.ts +++ b/graph-ui/src/lib/i18n.test.ts @@ -7,6 +7,31 @@ describe("i18n", () => { expect(detectLanguage("de-DE,de;q=0.9")).toBe("en"); }); + it("ranks Accept-Language by q rather than by presence", () => { + // A bilingual visitor who prefers English but lists Chinese as a fallback. + expect(detectLanguage("en-US,en;q=0.9,zh;q=0.5")).toBe("en"); + expect(detectLanguage("en,zh;q=0.1")).toBe("en"); + // Chinese still wins when it is actually preferred. + expect(detectLanguage("zh;q=0.9,en;q=0.5")).toBe("zh"); + }); + + it("treats q=0 as unacceptable", () => { + expect(detectLanguage("zh;q=0, en")).toBe("en"); + expect(detectLanguage("zh;q=0")).toBe("en"); + }); + + it("matches the language subtag, not a substring", () => { + expect(detectLanguage("en-GB")).toBe("en"); + expect(detectLanguage("zh-Hant-TW")).toBe("zh"); + }); + + it("honours the override and handles empty input", () => { + expect(detectLanguage("zh-CN", "en")).toBe("en"); + expect(detectLanguage("en-US", "zh")).toBe("zh"); + expect(detectLanguage(null)).toBe("en"); + expect(detectLanguage("")).toBe("en"); + }); + it("keeps UI chrome messages in the catalog", () => { expect(messages.zh.tabs.projects).toBe("项目"); expect(messages.zh.index.newIndex).toBe("新建索引"); diff --git a/graph-ui/src/lib/i18n.ts b/graph-ui/src/lib/i18n.ts index f2473fe9b..1f50ab927 100644 --- a/graph-ui/src/lib/i18n.ts +++ b/graph-ui/src/lib/i18n.ts @@ -152,8 +152,22 @@ export type UiMessages = (typeof messages)[UiLanguage]; export function detectLanguage(acceptLanguage?: string | null, override?: string | null): UiLanguage { if (override === "zh" || override === "en") return override; if (!acceptLanguage) return "en"; - const normalized = acceptLanguage.toLowerCase(); - return normalized.includes("zh-cn") || normalized.includes("zh") ? "zh" : "en"; + + // Ranked by q, not by whether "zh" appears anywhere. A substring test served + // Chinese for "en-US,en;q=0.9,zh;q=0.5", where English is clearly preferred, + // and for "zh;q=0, en", where q=0 means Chinese is unacceptable. + const best = acceptLanguage + .split(",") + .map((part) => { + const [tag, ...params] = part.trim().split(";"); + const q = params.map((p) => /^\s*q\s*=\s*([\d.]+)\s*$/i.exec(p)).find(Boolean); + return { tag: tag.trim().toLowerCase(), q: q ? Number(q[1]) : 1 }; + }) + .filter(({ tag, q }) => tag && Number.isFinite(q) && q > 0) + .sort((a, b) => b.q - a.q) + .find(({ tag }) => tag.split("-")[0] === "zh" || tag.split("-")[0] === "en"); + + return best?.tag.startsWith("zh") ? "zh" : "en"; } let cachedLanguage: UiLanguage = "en";