feat(dashboard): add i18n runtime with per-locale code-splitting (U3)
Initialize the browser i18next instance with lazy per-locale catalog loading, localStorage->navigator->htmlTag detection, script-aware zh fallback, and the shared @fusion/i18n config. Catalogs are synced from @fusion/i18n into a gitignored app/locales/ (predev/prebuild) and imported app-relative so Vite emits one chunk per locale/namespace (verified: 15 chunks, none inlined into the main bundle). First paint is gated on i18nReady to avoid raw-key flashes; <I18nextProvider> wraps the App provider stack; vendor-i18n manualChunk added. A build-assertion script (verify:locale-chunks) guards the KTD3a splitting invariant. Fallback/namespace behavior is covered by @fusion/i18n config tests; the live instance is verified via the build assertion rather than a unit test (the dynamic catalog backend is impractical to exercise in vitest). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,8 @@ import { useProjects } from "./hooks/useProjects";
|
||||
import { useAgents } from "./hooks/useAgents";
|
||||
import { useNodes } from "./hooks/useNodes";
|
||||
import { useCurrentProject } from "./hooks/useCurrentProject";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import i18n from "./i18n";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
import { ConfirmDialogProvider } from "./hooks/useConfirm";
|
||||
import { useTheme } from "./hooks/useTheme";
|
||||
@@ -2043,16 +2045,18 @@ function AppInner() {
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ShellHostProvider>
|
||||
<ShellProvider>
|
||||
<NodeProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<AppInner />
|
||||
</ConfirmDialogProvider>
|
||||
</NodeProvider>
|
||||
</ShellProvider>
|
||||
</ShellHostProvider>
|
||||
</ToastProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ToastProvider>
|
||||
<ShellHostProvider>
|
||||
<ShellProvider>
|
||||
<NodeProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<AppInner />
|
||||
</ConfirmDialogProvider>
|
||||
</NodeProvider>
|
||||
</ShellProvider>
|
||||
</ShellHostProvider>
|
||||
</ToastProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
55
packages/dashboard/app/i18n/index.ts
Normal file
55
packages/dashboard/app/i18n/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { baseInitOptions, DASHBOARD_NAMESPACES, DEFAULT_NAMESPACE } from "@fusion/i18n/config";
|
||||
import i18next from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import resourcesToBackend from "i18next-resources-to-backend";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Browser i18next instance for the dashboard.
|
||||
*
|
||||
* Catalogs are loaded lazily per locale: only the active locale's namespaces
|
||||
* are fetched on first paint; switching language fetches the new locale's
|
||||
* chunk on demand. The dynamic import is **app-relative** over the generated
|
||||
* `app/locales/` tree (synced from @fusion/i18n by scripts/sync-locales.mjs)
|
||||
* so Vite statically analyses it and emits one chunk per locale/namespace.
|
||||
*/
|
||||
|
||||
/** localStorage key for the persisted language. Uses the neighbor-consistent
|
||||
* `kb-dashboard-*` prefix (see useTheme.ts) — not changed to `fn-` here; that
|
||||
* belongs to the brand-rename track. */
|
||||
export const LANGUAGE_STORAGE_KEY = "kb-dashboard-language";
|
||||
|
||||
i18next
|
||||
.use(LanguageDetector)
|
||||
.use(
|
||||
resourcesToBackend(
|
||||
(language: string, namespace: string) =>
|
||||
import(`../locales/${language}/${namespace}.json`),
|
||||
),
|
||||
)
|
||||
.use(initReactI18next);
|
||||
|
||||
export const i18nReady = i18next.init({
|
||||
...baseInitOptions(),
|
||||
ns: [...DASHBOARD_NAMESPACES],
|
||||
defaultNS: DEFAULT_NAMESPACE,
|
||||
detection: {
|
||||
order: ["localStorage", "navigator", "htmlTag"],
|
||||
lookupLocalStorage: LANGUAGE_STORAGE_KEY,
|
||||
caches: ["localStorage"],
|
||||
},
|
||||
react: {
|
||||
// First paint is gated on `i18nReady` in main.tsx, so Suspense is not
|
||||
// needed to avoid raw-key flashes and would otherwise require a boundary
|
||||
// around every translated subtree.
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
|
||||
i18next.on("languageChanged", (language) => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = language;
|
||||
}
|
||||
});
|
||||
|
||||
export default i18next;
|
||||
@@ -8,6 +8,7 @@ import { installVersionCheck } from "./versionCheck";
|
||||
import { installSwUpdate } from "./swUpdate";
|
||||
import { bootstrapShellHostContext } from "./shell-host";
|
||||
import { registerBundledPluginViews } from "./plugins/registerBundledPluginViews";
|
||||
import { i18nReady } from "./i18n";
|
||||
import "./styles.css";
|
||||
|
||||
// Install the bearer-token fetch wrapper before React mounts so every API
|
||||
@@ -19,14 +20,20 @@ installVersionCheck();
|
||||
bootstrapShellHostContext();
|
||||
registerBundledPluginViews();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<RootErrorBoundary>
|
||||
<DesktopLaunchGate>
|
||||
<App />
|
||||
</DesktopLaunchGate>
|
||||
</RootErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
// Gate first paint on the active locale's catalogs so the UI never flashes raw
|
||||
// translation keys. The catalog is a small local chunk, so this is a brief
|
||||
// wait; `.finally` ensures we still render if i18n init fails (strings then
|
||||
// fall back to keys/en rather than blocking the app).
|
||||
void i18nReady.finally(() => {
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<RootErrorBoundary>
|
||||
<DesktopLaunchGate>
|
||||
<App />
|
||||
</DesktopLaunchGate>
|
||||
</RootErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
installSwUpdate();
|
||||
installSwUpdate();
|
||||
});
|
||||
|
||||
@@ -52,8 +52,12 @@
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"gen:locales": "node scripts/sync-locales.mjs",
|
||||
"verify:locale-chunks": "node scripts/assert-locale-chunks.mjs",
|
||||
"prebuild": "node scripts/sync-locales.mjs",
|
||||
"build": "vite build && tsc",
|
||||
"build:client": "vite build",
|
||||
"predev:serve": "node scripts/sync-locales.mjs",
|
||||
"dev": "pnpm build && pnpm typecheck && pnpm dev:serve",
|
||||
"dev:serve": "vite dev",
|
||||
"pretest": "node ../../scripts/ensure-test-artifacts.mjs",
|
||||
|
||||
50
packages/dashboard/scripts/assert-locale-chunks.mjs
Normal file
50
packages/dashboard/scripts/assert-locale-chunks.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
// KTD3a regression guard: after a client build, assert that each locale's
|
||||
// catalogs are emitted as their own async chunks and are NOT folded into the
|
||||
// main entry chunk. If the app-relative dynamic import ever stops being
|
||||
// statically analysable, Vite silently inlines every catalog into the main
|
||||
// bundle with only a build warning — this check turns that into a hard failure.
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const assetsDir = join(here, "..", "dist", "client", "assets");
|
||||
const namespaces = ["common", "app", "errors"];
|
||||
|
||||
if (!existsSync(assetsDir)) {
|
||||
console.error(`assert-locale-chunks: ${assetsDir} not found — run the client build first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = readdirSync(assetsDir);
|
||||
const errors = [];
|
||||
|
||||
// One chunk per dashboard namespace per locale (5 locales) → at least 5 each.
|
||||
for (const ns of namespaces) {
|
||||
const matches = files.filter((f) => new RegExp(`^${ns}-[^/]+\\.js$`).test(f));
|
||||
if (matches.length < 5) {
|
||||
errors.push(
|
||||
`expected >=5 split chunks for namespace "${ns}" (one per locale), found ${matches.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The main entry chunk must not carry catalog payloads — a translated marker
|
||||
// string from a non-en catalog appearing in index-*.js means splitting broke.
|
||||
const indexFile = files.find((f) => /^index-[^/]+\.js$/.test(f));
|
||||
if (indexFile) {
|
||||
const body = readFileSync(join(assetsDir, indexFile), "utf8");
|
||||
// i18next-resources-to-backend chunks are referenced by dynamic import, not
|
||||
// inlined; a literal catalog object in index would show the column labels.
|
||||
if (/"in-review":"In Review"/.test(body) && /"archived":"Archived"/.test(body)) {
|
||||
errors.push(`catalog content found inlined in ${indexFile} — locale chunks were not split`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error("assert-locale-chunks FAILED:");
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("assert-locale-chunks: per-locale catalog chunks emitted correctly.");
|
||||
30
packages/dashboard/scripts/sync-locales.mjs
Normal file
30
packages/dashboard/scripts/sync-locales.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copies the authored @fusion/i18n catalogs into the dashboard tree so Vite can
|
||||
// code-split them per locale via a plainly app-relative dynamic import. The
|
||||
// generated app/locales/ dir is gitignored — @fusion/i18n/locales is the
|
||||
// source-of-truth. Only the dashboard namespaces are copied (the terminal-only
|
||||
// `cli` namespace is skipped). Runs as a predev/prebuild step.
|
||||
import { cpSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const dashboardRoot = join(here, "..");
|
||||
const srcLocales = join(dashboardRoot, "..", "i18n", "locales");
|
||||
const destLocales = join(dashboardRoot, "app", "locales");
|
||||
|
||||
// Keep in sync with DASHBOARD_NAMESPACES in @fusion/i18n config.ts.
|
||||
const DASHBOARD_NAMESPACES = ["common", "app", "errors"];
|
||||
|
||||
const locales = readdirSync(srcLocales, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
|
||||
rmSync(destLocales, { recursive: true, force: true });
|
||||
for (const lng of locales) {
|
||||
mkdirSync(join(destLocales, lng), { recursive: true });
|
||||
for (const ns of DASHBOARD_NAMESPACES) {
|
||||
cpSync(join(srcLocales, lng, `${ns}.json`), join(destLocales, lng, `${ns}.json`));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Synced ${locales.length} locale(s) into app/locales: ${locales.join(", ")}`);
|
||||
@@ -168,6 +168,15 @@ export default defineConfig({
|
||||
return "vendor-codemirror";
|
||||
}
|
||||
|
||||
if (
|
||||
id.includes("/node_modules/i18next/") ||
|
||||
id.includes("/node_modules/react-i18next/") ||
|
||||
id.includes("/node_modules/i18next-browser-languagedetector/") ||
|
||||
id.includes("/node_modules/i18next-resources-to-backend/")
|
||||
) {
|
||||
return "vendor-i18n";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user