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 { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import "#/lib/i18n/i18n";
|
||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,13 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { PageHeader } from "#/components/PageHeader/PageHeader";
|
import { PageHeader } from "#/components/PageHeader/PageHeader";
|
||||||
|
|
||||||
// Toolbar (All/Public/Private tabs, framework filter, search) and the document
|
// Toolbar (All/Public/Private tabs, framework filter, search) and the document
|
||||||
// count are deferred until the v2 Tabs/Select/TextField components exist.
|
// count are deferred until the v2 Tabs/Select/TextField components exist.
|
||||||
export default function DocumentsPage() {
|
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:
|
message:
|
||||||
"Use useMutation from #/lib/relay/useMutation, not react-relay.",
|
"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