diff --git a/apps/web/src/core/hooks/useLang.test.ts b/apps/web/src/core/hooks/useLang.test.ts new file mode 100644 index 000000000..f3f714ea8 --- /dev/null +++ b/apps/web/src/core/hooks/useLang.test.ts @@ -0,0 +1,44 @@ +import useLang from "@core/hooks/useLang.ts"; +import { act, renderHook } from "@testing-library/react"; +import i18n from "i18next"; +import { afterEach, describe, expect, it } from "vitest"; + +afterEach(async () => { + await act(async () => { + await i18n.changeLanguage("en"); + }); +}); + +describe("useLang().current", () => { + it.each([ + ["en", "en"], + // A US/GB browser resolves to the shipped `en` locale folder, but i18next + // may still report the regional code — the picker must not fall over. + ["en-US", "en"], + ["en-GB", "en"], + // Bare picker codes resolve to region qualified folders (`fi` -> `fi-FI`). + ["fi-FI", "fi"], + ["de-DE", "de"], + ["sv-SE", "sv"], + ])("reports %s as the %s picker entry", async (active, expected) => { + // Mirror production: the locale folder for this language really is loaded. + i18n.addResourceBundle(active, "common", { button: { cancel: "x" } }); + await act(async () => { + await i18n.changeLanguage(active); + }); + + const { result } = renderHook(() => useLang()); + + expect(result.current.current?.code).toBe(expected); + }); + + it("falls back to English for a language we do not ship", async () => { + await act(async () => { + await i18n.changeLanguage("th-TH"); + }); + + const { result } = renderHook(() => useLang()); + + expect(result.current.current?.code).toBe("en"); + }); +}); diff --git a/apps/web/src/core/hooks/useLang.ts b/apps/web/src/core/hooks/useLang.ts index be244f7f4..0060541b6 100644 --- a/apps/web/src/core/hooks/useLang.ts +++ b/apps/web/src/core/hooks/useLang.ts @@ -1,5 +1,6 @@ import { FALLBACK_LANGUAGE_CODE, + getLanguagePart, type Lang, type LangCode, supportedLanguages, @@ -22,12 +23,18 @@ function useLang() { ); const currentLanguage = useMemo((): Lang | undefined => { - const lang = supportedLanguages.find((l) => l.code === i18n.language); + // `i18n.language` can be a regional code that has no picker entry of its + // own (e.g. a `fi` browser resolves to the shipped `fi-FI` folder, and + // `en-US` resolves to `en`), so match on the language part as well. + const active = i18n.resolvedLanguage ?? i18n.language ?? ""; + const lang = + supportedLanguages.find((l) => l.code === active) ?? + supportedLanguages.find((l) => l.code === getLanguagePart(active)); if (lang) { return lang; } return supportedLanguages.find((l) => l.code === FALLBACK_LANGUAGE_CODE); - }, [i18n.language]); + }, [i18n.language, i18n.resolvedLanguage]); const collator = useMemo(() => { return new Intl.Collator(i18n.language, { sensitivity: "base" }); diff --git a/apps/web/src/i18n-config.test.ts b/apps/web/src/i18n-config.test.ts new file mode 100644 index 000000000..fbe647642 --- /dev/null +++ b/apps/web/src/i18n-config.test.ts @@ -0,0 +1,197 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + availableLocales, + buildLocaleFallbacks, + FALLBACK_LANGUAGE_CODE, + i18nOptions, + LOAD_PATH, + namespaces, + supportedLanguages, +} from "@app/i18n-config.ts"; +import { createInstance, type BackendModule } from "i18next"; +import { describe, expect, it } from "vitest"; + +const localesDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../public/i18n/locales", +); + +const localeFoldersOnDisk = () => + fs + .readdirSync(localesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + +const buildUrl = (lng: string, ns: string) => + LOAD_PATH.replace("{{lng}}", lng).replace("{{ns}}", ns); + +const fileForRequest = (lng: string, ns: string) => + path.join(localesDir, lng, `${ns}.json`); + +type Request = { url: string; lng: string; ns: string; status: 200 | 404 }; + +/** + * Stands in for i18next-http-backend: resolves the very same `loadPath` the + * browser would request, but against the files that are actually shipped in + * `public/i18n/locales` (which is what ends up in `dist/i18n/locales`). + */ +const recordingBackend = (requests: Request[]): BackendModule => ({ + type: "backend", + init: () => {}, + read: (lng, ns, callback) => { + const file = fileForRequest(lng, ns); + const exists = fs.existsSync(file); + requests.push({ + url: buildUrl(lng, ns), + lng, + ns, + status: exists ? 200 : 404, + }); + if (exists) { + callback(null, JSON.parse(fs.readFileSync(file, "utf8"))); + return; + } + callback(new Error("404"), false); + }, +}); + +async function loadWithDetectedLanguage(detected: string) { + const requests: Request[] = []; + const instance = createInstance(); + await instance.use(recordingBackend(requests)).init({ + ...i18nOptions, + lng: detected, + react: { useSuspense: false }, + }); + return { instance, requests }; +} + +describe("shipped locale folders", () => { + it("availableLocales matches what is on disk", () => { + expect([...availableLocales].sort()).toEqual(localeFoldersOnDisk()); + }); + + it("ships the English source locale as a plain `en` folder (crowdin source)", () => { + expect(availableLocales).toContain(FALLBACK_LANGUAGE_CODE); + expect(availableLocales).not.toContain("en-US"); + for (const ns of namespaces) { + expect(fs.existsSync(fileForRequest("en", ns))).toBe(true); + } + }); + + it("only ever interpolates locales that exist on disk", () => { + const configured = i18nOptions.supportedLngs as string[]; + expect([...configured].sort()).toEqual(localeFoldersOnDisk()); + }); + + it("does not treat regional variants of a shipped language as supported", () => { + // `nonExplicitSupportedLngs: true` is what made i18next request the + // non-existent /i18n/locales/en-US/*.json paths. + expect(i18nOptions.nonExplicitSupportedLngs).toBe(false); + }); +}); + +describe("buildLocaleFallbacks", () => { + it("maps bare language codes onto the regional folder that ships", () => { + const fallbacks = buildLocaleFallbacks(); + expect(fallbacks.default).toEqual([FALLBACK_LANGUAGE_CODE]); + expect(fallbacks.fi).toEqual(["fi-FI", "en"]); + expect(fallbacks.de).toEqual(["de-DE", "en"]); + expect(fallbacks.fr).toEqual(["fr-FR", "en"]); + expect(fallbacks.ru).toEqual(["ru-RU", "en"]); + expect(fallbacks.sv).toEqual(["sv-SE", "en"]); + expect(fallbacks.cs).toEqual(["cs-CZ", "en"]); + }); + + it("never maps a language onto a locale that is not shipped", () => { + for (const chain of Object.values(buildLocaleFallbacks())) { + for (const locale of chain) { + expect(availableLocales as readonly string[]).toContain(locale); + } + } + }); + + it("leaves `en` and ambiguous multi-variant languages unmapped", () => { + const fallbacks = buildLocaleFallbacks(); + expect(fallbacks.en).toBeUndefined(); + expect(fallbacks.pt).toBeUndefined(); + expect(fallbacks.zh).toBeUndefined(); + }); + + it("every language offered in the picker resolves to a shipped folder", () => { + const fallbacks = buildLocaleFallbacks(); + for (const { code } of supportedLanguages) { + const resolvesTo = (availableLocales as readonly string[]).includes(code) + ? code + : fallbacks[code]?.[0]; + expect( + resolvesTo, + `no shipped locale for picker entry "${code}"`, + ).toBeDefined(); + expect(fs.existsSync(path.join(localesDir, resolvesTo as string))).toBe( + true, + ); + } + }); +}); + +describe("locale request resolution (regression: en-US 404s)", () => { + it("an en-US browser loads every namespace from the `en` folder, with no 404s", async () => { + const { instance, requests } = await loadWithDetectedLanguage("en-US"); + + expect(requests.filter((r) => r.status === 404)).toEqual([]); + expect(requests.map((r) => r.url)).not.toContain( + "/i18n/locales/en-US/common.json", + ); + + // Every namespace from the live bug report must now come from `en`. + for (const ns of namespaces) { + expect(requests).toContainEqual( + expect.objectContaining({ + url: `/i18n/locales/en/${ns}.json`, + status: 200, + }), + ); + expect(instance.hasResourceBundle("en", ns)).toBe(true); + } + + expect(instance.resolvedLanguage).toBe("en"); + expect(instance.t("button.cancel", { ns: "common" })).toBe("Cancel"); + }); + + it.each([ + ["en-US", "en"], + ["en-GB", "en"], + ["en-CA", "en"], + ["en", "en"], + ["fi", "fi-FI"], + ["fi-FI", "fi-FI"], + ["de", "de-DE"], + ["de-AT", "de-DE"], + ["cs-CZ", "cs-CZ"], + ["pt", "pt-BR"], + ["pt-PT", "pt-PT"], + ["zh", "zh-CN"], + ["zh-TW", "zh-TW"], + ["th-TH", "en"], // unsupported language -> English source locale + ])( + "detected %s resolves to the shipped %s folder and never 404s", + async (detected, expectedLocale) => { + const { instance, requests } = await loadWithDetectedLanguage(detected); + + expect( + requests.filter((r) => r.status === 404).map((r) => r.url), + ).toEqual([]); + expect(instance.resolvedLanguage).toBe(expectedLocale); + for (const request of requests) { + expect(availableLocales as readonly string[]).toContain(request.lng); + } + for (const ns of namespaces) { + expect(instance.hasResourceBundle(expectedLocale, ns)).toBe(true); + } + }, + ); +}); diff --git a/apps/web/src/i18n-config.ts b/apps/web/src/i18n-config.ts index ff259bfc7..bb34650ed 100644 --- a/apps/web/src/i18n-config.ts +++ b/apps/web/src/i18n-config.ts @@ -1,4 +1,4 @@ -import i18next from "i18next"; +import i18next, { type InitOptions } from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import Backend from "i18next-http-backend"; import { initReactI18next } from "react-i18next"; @@ -23,44 +23,142 @@ export const supportedLanguages: Lang[] = [ export const FALLBACK_LANGUAGE_CODE: LangCode = "en"; +/** + * The locale folders that are actually shipped in `public/i18n/locales/` (and + * therefore in `dist/i18n/locales/` after a build). + * + * These are the only values that may ever be interpolated into the backend + * `loadPath`, otherwise the request 404s. + * + * Note the deliberate asymmetry: the English *source* strings live in a plain + * `en` folder because that is what `crowdin.yml` uploads + * (`/public/i18n/locales/en/*.json`), while Crowdin writes every *translation* + * back to a region qualified `%locale%` folder (`cs-CZ`, `pt-BR`, ...). So `en` + * has no region suffix while every other locale does. + * + * Keep this list in sync with the directory listing — `i18n-config.test.ts` + * fails if it drifts. + */ +export const availableLocales = [ + "be-BY", + "bg-BG", + "cs-CZ", + "de-DE", + "en", + "es-ES", + "fi-FI", + "fr-FR", + "hu-HU", + "it-IT", + "ja-JP", + "ko-KR", + "nl-NL", + "pl-PL", + "pt-BR", + "pt-PT", + "ru-RU", + "sv-SE", + "tr-TR", + "uk-UA", + "zh-CN", + "zh-TW", +] as const satisfies readonly string[]; + +export type AvailableLocale = (typeof availableLocales)[number]; + +export const getLanguagePart = (locale: string): string => + locale.split("-")[0] ?? locale; + +/** + * Builds the i18next `fallbackLng` map from the locales we actually ship. + * + * A bare language code (`de`, which is what the language picker stores, or a + * browser reporting just `cs`) has no folder of its own, so it has to fall back + * to the regional folder that does exist (`de-DE`, `cs-CZ`) and then to the + * English source locale. + * + * Language codes that already have their own folder (`en`) and languages that + * ship more than one regional variant (`pt` -> pt-BR/pt-PT, `zh` -> zh-CN/zh-TW) + * are intentionally left out: i18next's own `supportedLngs` prefix matching + * picks the first shipped variant for those, and adding a map entry would make + * every pt-PT/zh-TW user download the other dialect as an intermediate fallback. + */ +export function buildLocaleFallbacks( + locales: readonly string[] = availableLocales, + fallbackLocale: string = FALLBACK_LANGUAGE_CODE, +): Record { + const fallbacks: Record = { + default: [fallbackLocale], + }; + + const variantsPerLanguage = new Map(); + for (const locale of locales) { + const languagePart = getLanguagePart(locale); + variantsPerLanguage.set(languagePart, [ + ...(variantsPerLanguage.get(languagePart) ?? []), + locale, + ]); + } + + for (const [languagePart, variants] of variantsPerLanguage) { + const [onlyVariant] = variants; + // Ambiguous (pt-BR/pt-PT) or already a bare folder (en) — leave to i18next. + if (variants.length !== 1 || !onlyVariant || onlyVariant === languagePart) { + continue; + } + fallbacks[languagePart] = [onlyVariant, fallbackLocale]; + } + + return fallbacks; +} + +export const LOAD_PATH = "/i18n/locales/{{lng}}/{{ns}}.json"; + +export const namespaces = [ + "channels", + "connections", + "commandPalette", + "common", + "config", + "moduleConfig", + "dialog", + "messages", + "nodes", + "ui", + "map", +]; + +export const i18nOptions: InitOptions = { + backend: { + // `{{lng}}` is interpolated verbatim, so it must always be one of + // `availableLocales`. `supportedLngs` below guarantees that: an unshipped + // code such as `en-US` is dropped from the resolution hierarchy and + // narrowed to the folder that does exist (`en`) instead of being requested + // and 404ing. + loadPath: LOAD_PATH, + }, + react: { + useSuspense: true, + }, + supportedLngs: [...availableLocales], + // Must stay false: when true, i18next treats `en-US` as supported because + // `en` is, and then still requests `/i18n/locales/en-US/*.json`, which does + // not exist on disk. + nonExplicitSupportedLngs: false, + detection: { + order: ["localStorage", "navigator"], + caches: ["localStorage"], + }, + fallbackLng: buildLocaleFallbacks(), + fallbackNS: ["common", "ui", "dialog"], + ns: namespaces, +}; + i18next .use(Backend) .use(initReactI18next) .use(LanguageDetector) .init({ - backend: { - // With this setup, {{lng}} will correctly resolve to 'en-US', 'fi-FI', etc. - loadPath: "/i18n/locales/{{lng}}/{{ns}}.json", - }, - react: { - useSuspense: true, - }, - nonExplicitSupportedLngs: true, - detection: { - order: ["localStorage", "navigator"], - caches: ["localStorage"], - }, - fallbackLng: { - default: [FALLBACK_LANGUAGE_CODE], - fi: ["fi-FI", FALLBACK_LANGUAGE_CODE], - fr: ["fr-FR", FALLBACK_LANGUAGE_CODE], - ru: ["ru-RU", FALLBACK_LANGUAGE_CODE], - sv: ["sv-SE", FALLBACK_LANGUAGE_CODE], - de: ["de-DE", FALLBACK_LANGUAGE_CODE], - }, - fallbackNS: ["common", "ui", "dialog"], + ...i18nOptions, debug: import.meta.env.MODE === "development", - ns: [ - "channels", - "connections", - "commandPalette", - "common", - "config", - "moduleConfig", - "dialog", - "messages", - "nodes", - "ui", - "map", - ], });