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:
5
apps/compliance-portal/src/_locales/en-US.json
Normal file
5
apps/compliance-portal/src/_locales/en-US.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"documents": {
|
||||
"title": "Documents"
|
||||
}
|
||||
}
|
||||
5
apps/compliance-portal/src/_locales/fr-FR.json
Normal file
5
apps/compliance-portal/src/_locales/fr-FR.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"documents": {
|
||||
"title": "Documents"
|
||||
}
|
||||
}
|
||||
78
apps/compliance-portal/src/lib/i18n/backend.ts
Normal file
78
apps/compliance-portal/src/lib/i18n/backend.ts
Normal 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),
|
||||
);
|
||||
},
|
||||
};
|
||||
41
apps/compliance-portal/src/lib/i18n/i18n.ts
Normal file
41
apps/compliance-portal/src/lib/i18n/i18n.ts
Normal 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 };
|
||||
40
apps/compliance-portal/src/lib/i18n/resolveLanguage.ts
Normal file
40
apps/compliance-portal/src/lib/i18n/resolveLanguage.ts
Normal 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";
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import "./index.css";
|
||||
import "#/lib/i18n/i18n";
|
||||
|
||||
import { App } from "./App";
|
||||
|
||||
|
||||
@@ -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")} />;
|
||||
}
|
||||
|
||||
@@ -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\"`.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user