From 86077e6c274d221b434fdf2e500e412b13118dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Sun, 28 Jun 2026 13:54:24 +0200 Subject: [PATCH] Set up i18next with lazy per-route catalogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire i18next into the compliance portal with a custom backend built on import.meta.glob, so each _locales/*.json becomes its own lazily loaded chunk keyed by a namespace derived from the folder path. The active language is resolved from the browser, collapsing any fr*/en* tag to fr-FR/en-US with en-US as the ultimate fallback; fallbackLng then only covers individual missing keys. Add an app-level default namespace catalog and switch the documents page title to a translation key to exercise the lazy-loading path. Signed-off-by: Émile Ré --- .../compliance-portal/src/_locales/en-US.json | 5 ++ .../compliance-portal/src/_locales/fr-FR.json | 5 ++ .../compliance-portal/src/lib/i18n/backend.ts | 78 +++++++++++++++++++ apps/compliance-portal/src/lib/i18n/i18n.ts | 41 ++++++++++ .../src/lib/i18n/resolveLanguage.ts | 40 ++++++++++ apps/compliance-portal/src/main.tsx | 1 + .../src/pages/DocumentsPage.tsx | 5 +- eslint.config.mjs | 6 ++ 8 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 apps/compliance-portal/src/_locales/en-US.json create mode 100644 apps/compliance-portal/src/_locales/fr-FR.json create mode 100644 apps/compliance-portal/src/lib/i18n/backend.ts create mode 100644 apps/compliance-portal/src/lib/i18n/i18n.ts create mode 100644 apps/compliance-portal/src/lib/i18n/resolveLanguage.ts diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json new file mode 100644 index 000000000..a0f812fd9 --- /dev/null +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -0,0 +1,5 @@ +{ + "documents": { + "title": "Documents" + } +} diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json new file mode 100644 index 000000000..a0f812fd9 --- /dev/null +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -0,0 +1,5 @@ +{ + "documents": { + "title": "Documents" + } +} diff --git a/apps/compliance-portal/src/lib/i18n/backend.ts b/apps/compliance-portal/src/lib/i18n/backend.ts new file mode 100644 index 000000000..94042c473 --- /dev/null +++ b/apps/compliance-portal/src/lib/i18n/backend.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { BackendModule, ReadCallback, ResourceKey } from "i18next"; + +// The default namespace for app-wide chrome strings, sourced from src/_locales/. +export const DEFAULT_NAMESPACE = "app"; + +type CatalogModule = { default: ResourceKey }; + +// Vite turns each translation JSON into its own lazily-imported chunk. The keys +// are project-root-absolute paths, e.g. +// "/src/pages/organizations/measures/_locales/en-US.json". +const catalogs = import.meta.glob("/src/**/_locales/*.json"); + +const CATALOG_PATH = /^\/src\/(.*)_locales\/([^/]+)\.json$/; + +// Map "\u0000" -> lazy importer for that catalog chunk. +function buildLookup(): Map Promise> { + const lookup = new Map Promise>(); + + for (const [path, importer] of Object.entries(catalogs)) { + const match = CATALOG_PATH.exec(path); + if (!match) { + continue; + } + + const [, prefix, language] = match; + // prefix is the path between "src/" and "_locales/", e.g. "pages/foo/". + // Drop the leading "pages/" and trailing slash; an empty prefix (src/_locales) + // is the app-wide default namespace. + const namespace + = prefix.replace(/^pages\//, "").replace(/\/$/, "") || DEFAULT_NAMESPACE; + + lookup.set(catalogKey(namespace, language), importer); + } + + return lookup; +} + +function catalogKey(namespace: string, language: string): string { + return `${namespace}\u0000${language}`; +} + +const lookup = buildLookup(); + +// Custom i18next backend that resolves a (language, namespace) pair to its lazy +// JSON chunk. A missing catalog resolves to an empty resource so i18next falls +// through to fallbackLng rather than treating it as a hard load error. +export const globBackend: BackendModule = { + type: "backend", + init() {}, + read(language: string, namespace: string, callback: ReadCallback) { + const importer = lookup.get(catalogKey(namespace, language)); + + if (!importer) { + callback(null, {}); + return; + } + + importer().then( + module => callback(null, module.default), + (error: unknown) => + callback(error instanceof Error ? error : new Error(String(error)), null), + ); + }, +}; diff --git a/apps/compliance-portal/src/lib/i18n/i18n.ts b/apps/compliance-portal/src/lib/i18n/i18n.ts new file mode 100644 index 000000000..0a3f04212 --- /dev/null +++ b/apps/compliance-portal/src/lib/i18n/i18n.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { createInstance } from "i18next"; +import { initReactI18next } from "react-i18next"; + +import { DEFAULT_NAMESPACE, globBackend } from "./backend"; +import { resolveLanguage, SUPPORTED_LANGUAGES } from "./resolveLanguage"; + +// Build a dedicated instance rather than mutating i18next's global singleton. +// Initializing it through initReactI18next still registers it as the instance +// react-i18next's hooks read from, so no I18nextProvider is required. +const i18n = createInstance(); + +void i18n + .use(globBackend) + .use(initReactI18next) + .init({ + lng: resolveLanguage(), + fallbackLng: "en-US", + supportedLngs: SUPPORTED_LANGUAGES, + load: "currentOnly", + defaultNS: DEFAULT_NAMESPACE, + fallbackNS: DEFAULT_NAMESPACE, + ns: [DEFAULT_NAMESPACE], + interpolation: { escapeValue: false }, + react: { useSuspense: true }, + }); + +export { i18n }; diff --git a/apps/compliance-portal/src/lib/i18n/resolveLanguage.ts b/apps/compliance-portal/src/lib/i18n/resolveLanguage.ts new file mode 100644 index 000000000..12f24f3d1 --- /dev/null +++ b/apps/compliance-portal/src/lib/i18n/resolveLanguage.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +export const SUPPORTED_LANGUAGES = ["en-US", "fr-FR"] as const; + +export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; + +// Collapse the browser's preferred languages to one of our supported locales: +// any fr* tag maps to fr-FR, any en* tag maps to en-US. en-US is the ultimate +// fallback when nothing matches. Resolving to a canonical supported tag here +// means i18next is never asked to load an unsupported locale; fallbackLng only +// has to cover individual missing keys. +export function resolveLanguage(): SupportedLanguage { + const candidates = navigator.languages?.length + ? navigator.languages + : [navigator.language]; + + for (const tag of candidates) { + const lower = tag.toLowerCase(); + if (lower.startsWith("fr")) { + return "fr-FR"; + } + if (lower.startsWith("en")) { + return "en-US"; + } + } + + return "en-US"; +} diff --git a/apps/compliance-portal/src/main.tsx b/apps/compliance-portal/src/main.tsx index d60e6628b..ab63147a4 100644 --- a/apps/compliance-portal/src/main.tsx +++ b/apps/compliance-portal/src/main.tsx @@ -15,6 +15,7 @@ import { createRoot } from "react-dom/client"; import "./index.css"; +import "#/lib/i18n/i18n"; import { App } from "./App"; diff --git a/apps/compliance-portal/src/pages/DocumentsPage.tsx b/apps/compliance-portal/src/pages/DocumentsPage.tsx index 5c4ef0fd6..05b5d93e1 100644 --- a/apps/compliance-portal/src/pages/DocumentsPage.tsx +++ b/apps/compliance-portal/src/pages/DocumentsPage.tsx @@ -12,10 +12,13 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { useTranslation } from "react-i18next"; + import { PageHeader } from "#/components/PageHeader/PageHeader"; // Toolbar (All/Public/Private tabs, framework filter, search) and the document // count are deferred until the v2 Tabs/Select/TextField components exist. export default function DocumentsPage() { - return ; + const { t } = useTranslation(); + return ; } diff --git a/eslint.config.mjs b/eslint.config.mjs index 784a62744..2df23b042 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -63,6 +63,12 @@ export default defineConfig([ message: "Use useMutation from #/lib/relay/useMutation, not react-relay.", }, + { + name: "i18next", + importNames: ["default"], + message: + "Don't import i18next's default (global singleton). Build a dedicated instance via `import { createInstance } from \"i18next\"`.", + }, ], }, ],