Set up i18next with lazy per-route catalogs

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é <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-28 13:54:24 +02:00
parent 6ec7efb4fc
commit 86077e6c27
8 changed files with 180 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
{
"documents": {
"title": "Documents"
}
}

View File

@@ -0,0 +1,5 @@
{
"documents": {
"title": "Documents"
}
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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<CatalogModule>("/src/**/_locales/*.json");
const CATALOG_PATH = /^\/src\/(.*)_locales\/([^/]+)\.json$/;
// Map "<namespace>\u0000<language>" -> lazy importer for that catalog chunk.
function buildLookup(): Map<string, () => Promise<CatalogModule>> {
const lookup = new Map<string, () => Promise<CatalogModule>>();
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),
);
},
};

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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 };

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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";
}

View File

@@ -15,6 +15,7 @@
import { createRoot } from "react-dom/client";
import "./index.css";
import "#/lib/i18n/i18n";
import { App } from "./App";

View File

@@ -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 <PageHeader title="Documents" />;
const { t } = useTranslation();
return <PageHeader title={t("documents.title")} />;
}

View File

@@ -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\"`.",
},
],
},
],