From cd71b9293075bdaa8b46c07272f45097346adcaa Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 08:16:08 -0700 Subject: [PATCH] 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; 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) --- packages/dashboard/app/App.tsx | 26 +++++---- packages/dashboard/app/i18n/index.ts | 55 +++++++++++++++++++ packages/dashboard/app/main.tsx | 27 +++++---- packages/dashboard/package.json | 4 ++ .../scripts/assert-locale-chunks.mjs | 50 +++++++++++++++++ packages/dashboard/scripts/sync-locales.mjs | 30 ++++++++++ packages/dashboard/vite.config.ts | 9 +++ 7 files changed, 180 insertions(+), 21 deletions(-) create mode 100644 packages/dashboard/app/i18n/index.ts create mode 100644 packages/dashboard/scripts/assert-locale-chunks.mjs create mode 100644 packages/dashboard/scripts/sync-locales.mjs diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 7c0a2d7c51..c923c418bb 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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 ( - - - - - - - - - - - + + + + + + + + + + + + + ); } diff --git a/packages/dashboard/app/i18n/index.ts b/packages/dashboard/app/i18n/index.ts new file mode 100644 index 0000000000..ef1d498f92 --- /dev/null +++ b/packages/dashboard/app/i18n/index.ts @@ -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; diff --git a/packages/dashboard/app/main.tsx b/packages/dashboard/app/main.tsx index 4af2daf17e..c40723db0a 100644 --- a/packages/dashboard/app/main.tsx +++ b/packages/dashboard/app/main.tsx @@ -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( - - - - - - - , -); +// 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( + + + + + + + , + ); -installSwUpdate(); + installSwUpdate(); +}); diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 7ff2cd7be2..65169a169e 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -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", diff --git a/packages/dashboard/scripts/assert-locale-chunks.mjs b/packages/dashboard/scripts/assert-locale-chunks.mjs new file mode 100644 index 0000000000..37cf38c71f --- /dev/null +++ b/packages/dashboard/scripts/assert-locale-chunks.mjs @@ -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."); diff --git a/packages/dashboard/scripts/sync-locales.mjs b/packages/dashboard/scripts/sync-locales.mjs new file mode 100644 index 0000000000..e442f47141 --- /dev/null +++ b/packages/dashboard/scripts/sync-locales.mjs @@ -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(", ")}`); diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts index b7e9adc6cc..f973f74394 100644 --- a/packages/dashboard/vite.config.ts +++ b/packages/dashboard/vite.config.ts @@ -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; }, },