feat(i18n): add @fusion/i18n package, catalogs, and i18next-cli tooling (U2)

Create the @fusion/i18n package as the authored source-of-truth: shared
i18next config (namespace split, script-aware zh-CN/zh-TW fallback, plural
setup), en base catalogs, and a generated CLI static-import map so the
terminal surface is drop-in for new locales. Add the i18next-cli workflow
(extract/sync/types/status/lint) wired as root i18n:* scripts, install the
i18next stack into dashboard + CLI, strip @fusion/i18n from the published CLI
manifest, gitignore the generated dashboard catalog tree, and add a changeset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-03 08:00:31 -07:00
parent b8045b6f2b
commit 9072d71306
36 changed files with 1419 additions and 31 deletions

View File

@@ -0,0 +1,9 @@
---
"@runfusion/fusion": minor
---
Add a localization (i18n) foundation across the UI. Introduces react-i18next-backed translation for both the dashboard and the terminal UI, with English as the source language and Simplified Chinese, Traditional Chinese, French, and Spanish as target locales.
- New `@fusion/i18n` package holding the authored catalogs and shared i18next configuration (namespace split, script-aware zh-CN/zh-TW fallback, plural setup).
- A `language` preference (`fusion settings`) and a Settings language switcher; the CLI resolves locale from `--lang`, settings, then environment.
- An `i18next-cli` workflow (`extract`/`sync`/`types`/`status`/`lint`) so adding a future language is a translate-only, near-zero-code operation.

4
.gitignore vendored
View File

@@ -11,6 +11,10 @@ dist/
dist-electron/
*.tsbuildinfo
# Generated i18n catalogs synced into the dashboard tree from @fusion/i18n
# (authored source-of-truth lives in packages/i18n/locales/)
packages/dashboard/app/locales/
# Desktop packaging artifacts
*.dmg
*.dmg.blockmap

46
i18next.config.ts Normal file
View File

@@ -0,0 +1,46 @@
import {
defineConfig,
recommendedAcceptedAttributes,
recommendedAcceptedTags,
} from "i18next-cli";
/**
* i18next-cli workflow config for the whole monorepo.
*
* - `extract` pulls t()/<Trans> keys from the dashboard and CLI source into the
* authored `en` catalogs under @fusion/i18n.
* - `sync` propagates the `en` key structure to the four other locales.
* - `types` regenerates key types from the `en` catalogs.
* - `status` reports per-locale completion (CI gate).
* - `lint` flags hardcoded user-facing strings (primary guardrail).
*
* Namespaces are routed by the `ns:` prefix in keys / `useTranslation(ns)` in
* source, not by file path. `common` is the default namespace.
*/
export default defineConfig({
locales: ["en", "zh-CN", "zh-TW", "fr", "es"],
extract: {
input: [
"packages/dashboard/app/**/*.{ts,tsx}",
"packages/cli/src/**/*.{ts,tsx}",
"!**/__tests__/**",
"!**/*.test.*",
],
output: "packages/i18n/locales/{{language}}/{{namespace}}.json",
primaryLanguage: "en",
defaultNS: "common",
keySeparator: ".",
nsSeparator: ":",
// Untranslated secondary-locale keys stay empty so `status` can measure
// real completion; the active locale falls back to `en` at runtime.
defaultValue: "",
},
types: {
input: ["packages/i18n/locales/en/*.json"],
output: "packages/i18n/src/i18next-resources.d.ts",
},
lint: {
acceptedTags: recommendedAcceptedTags,
acceptedAttributes: recommendedAcceptedAttributes,
},
});

View File

@@ -46,6 +46,12 @@
"test:coverage:dashboard": "pnpm --filter @fusion/dashboard exec vitest run --silent=passed-only --reporter=dot --coverage",
"test:slow-cli": "pnpm --filter @runfusion/fusion test:slow-cli",
"typecheck": "pnpm -r --filter=!@fusion/desktop --filter=!@fusion/mobile typecheck",
"i18n:extract": "i18next-cli extract",
"i18n:sync": "i18next-cli sync",
"i18n:types": "i18next-cli types",
"i18n:status": "i18next-cli status",
"i18n:lint": "i18next-cli lint",
"i18n:gen-cli": "pnpm --filter @fusion/i18n gen:cli-catalogs",
"changeset": "changeset",
"version": "changeset version",
"release": "node scripts/release.mjs",
@@ -76,6 +82,7 @@
"@eslint/js": "^9.0.0",
"eslint": "^9.0.0",
"fast-glob": "^3.3.3",
"i18next-cli": "^1.59.1",
"react-devtools-core": "^7.0.1",
"tsx": "^4.19.0",
"typescript": "^5.7.0",

View File

@@ -59,13 +59,15 @@
"@earendil-works/pi-coding-agent": "^0.78.0",
"dockerode": "^4.0.12",
"express": "^5.1.0",
"i18next": "^26.3.1",
"ink": "^6.8.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"ioredis": "^5.6.0",
"multer": "^2.1.1",
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
"react": "^19.0.0"
"react": "^19.0.0",
"react-i18next": "^17.0.8"
},
"peerDependencies": {
"@earendil-works/pi-ai": "*",
@@ -87,6 +89,7 @@
"@fusion/core": "workspace:*",
"@fusion/dashboard": "workspace:*",
"@fusion/engine": "workspace:*",
"@fusion/i18n": "workspace:*",
"@fusion/pi-claude-cli": "workspace:*",
"@fusion/pi-llama-cpp": "workspace:*",
"@types/node": "^22.0.0",

View File

@@ -8,6 +8,7 @@ export function applyPrepackTransform(pkg) {
delete devDependencies["@fusion/core"];
delete devDependencies["@fusion/dashboard"];
delete devDependencies["@fusion/engine"];
delete devDependencies["@fusion/i18n"];
delete devDependencies["@fusion/pi-claude-cli"];
delete devDependencies["@fusion/pi-llama-cpp"];
delete devDependencies["@fusion-plugin-examples/roadmap"];

View File

@@ -90,17 +90,18 @@
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.36.4",
"@earendil-works/pi-coding-agent": "^0.78.0",
"@fusion-plugin-examples/cli-printing-press": "workspace:*",
"@fusion-plugin-examples/cursor-runtime": "workspace:*",
"@fusion-plugin-examples/dependency-graph": "workspace:*",
"@fusion-plugin-examples/roadmap": "workspace:*",
"@fusion-plugin-examples/droid-runtime": "workspace:*",
"@fusion-plugin-examples/hermes-runtime": "workspace:*",
"@fusion-plugin-examples/openclaw-runtime": "workspace:*",
"@fusion-plugin-examples/droid-runtime": "workspace:*",
"@fusion-plugin-examples/cursor-runtime": "workspace:*",
"@fusion-plugin-examples/cli-printing-press": "workspace:*",
"@fusion-plugin-examples/paperclip-runtime": "workspace:*",
"@fusion-plugin-examples/roadmap": "workspace:*",
"@fusion/core": "workspace:*",
"@fusion/engine": "workspace:*",
"@earendil-works/pi-coding-agent": "^0.78.0",
"@fusion/i18n": "workspace:*",
"@types/multer": "^2.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
@@ -109,6 +110,9 @@
"@xterm/xterm": "^5.5.0",
"archiver": "^7.0.1",
"express": "^5.1.0",
"i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.1",
"ioredis": "^5.6.0",
"lucide-react": "^1.7.0",
"multer": "^2.1.1",
@@ -116,6 +120,7 @@
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"ws": "^8.18.0",

View File

@@ -0,0 +1,9 @@
{
"settings": {
"appearance": {
"title": "Appearance",
"language": "Language",
"languageHint": "Choose the language for the {{brand}} interface."
}
}
}

View File

@@ -0,0 +1,6 @@
{
"tui": {
"loading": "Loading…",
"quit": "Quit"
}
}

View File

@@ -0,0 +1,15 @@
{
"columns": {
"triage": "Planning",
"todo": "Todo",
"in-progress": "In Progress",
"in-review": "In Review",
"done": "Done",
"archived": "Archived"
},
"actions": {
"save": "Save",
"cancel": "Cancel",
"close": "Close"
}
}

View File

@@ -0,0 +1,4 @@
{
"fetchProjectsFailed": "Failed to fetch projects",
"openTaskLogsFailed": "Failed to open task logs: {{detail}}"
}

View File

@@ -0,0 +1,9 @@
{
"settings": {
"appearance": {
"title": "",
"language": "",
"languageHint": ""
}
}
}

View File

@@ -0,0 +1,6 @@
{
"tui": {
"loading": "",
"quit": ""
}
}

View File

@@ -0,0 +1,15 @@
{
"columns": {
"triage": "",
"todo": "",
"in-progress": "",
"in-review": "",
"done": "",
"archived": ""
},
"actions": {
"save": "",
"cancel": "",
"close": ""
}
}

View File

@@ -0,0 +1,4 @@
{
"fetchProjectsFailed": "",
"openTaskLogsFailed": ""
}

View File

@@ -0,0 +1,9 @@
{
"settings": {
"appearance": {
"title": "",
"language": "",
"languageHint": ""
}
}
}

View File

@@ -0,0 +1,6 @@
{
"tui": {
"loading": "",
"quit": ""
}
}

View File

@@ -0,0 +1,15 @@
{
"columns": {
"triage": "",
"todo": "",
"in-progress": "",
"in-review": "",
"done": "",
"archived": ""
},
"actions": {
"save": "",
"cancel": "",
"close": ""
}
}

View File

@@ -0,0 +1,4 @@
{
"fetchProjectsFailed": "",
"openTaskLogsFailed": ""
}

View File

@@ -0,0 +1,9 @@
{
"settings": {
"appearance": {
"title": "",
"language": "",
"languageHint": ""
}
}
}

View File

@@ -0,0 +1,6 @@
{
"tui": {
"loading": "",
"quit": ""
}
}

View File

@@ -0,0 +1,15 @@
{
"columns": {
"triage": "",
"todo": "",
"in-progress": "",
"in-review": "",
"done": "",
"archived": ""
},
"actions": {
"save": "",
"cancel": "",
"close": ""
}
}

View File

@@ -0,0 +1,4 @@
{
"fetchProjectsFailed": "",
"openTaskLogsFailed": ""
}

View File

@@ -0,0 +1,9 @@
{
"settings": {
"appearance": {
"title": "",
"language": "",
"languageHint": ""
}
}
}

View File

@@ -0,0 +1,6 @@
{
"tui": {
"loading": "",
"quit": ""
}
}

View File

@@ -0,0 +1,15 @@
{
"columns": {
"triage": "",
"todo": "",
"in-progress": "",
"in-review": "",
"done": "",
"archived": ""
},
"actions": {
"save": "",
"cancel": "",
"close": ""
}
}

View File

@@ -0,0 +1,4 @@
{
"fetchProjectsFailed": "",
"openTaskLogsFailed": ""
}

View File

@@ -0,0 +1,37 @@
{
"name": "@fusion/i18n",
"version": "0.39.0",
"license": "MIT",
"description": "Fusion i18n: authored translation catalogs and shared i18next configuration for the Fusion dashboard and terminal UI.",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"source": "./src/index.ts",
"import": "./src/index.ts"
},
"./config": {
"types": "./src/config.ts",
"source": "./src/config.ts",
"import": "./src/config.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"gen:cli-catalogs": "node scripts/gen-cli-catalogs.mjs",
"test": "vitest run --silent=passed-only --reporter=dot"
},
"dependencies": {
"@fusion/core": "workspace:*",
"i18next": "^26.3.1"
},
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^5.7.0",
"vitest": "^3.1.0"
},
"engines": {
"node": ">=22.5.0"
},
"private": true
}

View File

@@ -0,0 +1,49 @@
// Generates src/cli-catalogs.ts: a static import map of the CLI-relevant
// catalogs for every locale present under locales/. The terminal UI bundles
// catalogs statically (tsup, no lazy loading), so it needs an explicit import
// map rather than a dynamic loader. Driving this off the locales/ directory
// listing keeps "add a language" a no-code operation on the CLI side: add the
// locale (via `i18next-cli sync`), regenerate, done.
import { readdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..");
const localesDir = join(root, "locales");
// Keep in sync with CLI_NAMESPACES in src/config.ts.
const CLI_NAMESPACES = ["common", "cli", "errors"];
const locales = readdirSync(localesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort();
const ident = (s) => s.replace(/[^a-zA-Z0-9]/g, "_");
const imports = [];
const entries = [];
for (const lng of locales) {
const nsLines = [];
for (const ns of CLI_NAMESPACES) {
const id = `${ident(lng)}_${ns}`;
imports.push(`import ${id} from "../locales/${lng}/${ns}.json";`);
nsLines.push(` ${ns}: ${id},`);
}
entries.push(` "${lng}": {\n${nsLines.join("\n")}\n },`);
}
const out = [
"// GENERATED by scripts/gen-cli-catalogs.mjs — do not edit by hand.",
"// Run `pnpm --filter @fusion/i18n gen:cli-catalogs` to regenerate.",
...imports,
"",
"export const cliResources = {",
...entries,
"} as const;",
"",
].join("\n");
writeFileSync(join(root, "src", "cli-catalogs.ts"), out);
console.log(`Generated cli-catalogs.ts for ${locales.length} locale(s): ${locales.join(", ")}`);

View File

@@ -0,0 +1,51 @@
import { SUPPORTED_LOCALES } from "@fusion/core";
import { describe, expect, it } from "vitest";
import { cliResources } from "../cli-catalogs.js";
import {
baseInitOptions,
CLI_NAMESPACES,
DASHBOARD_NAMESPACES,
DEFAULT_NAMESPACE,
FALLBACK_LNG,
NAMESPACES,
} from "../config.js";
describe("@fusion/i18n config", () => {
it("dashboard and cli namespaces are subsets of NAMESPACES", () => {
for (const ns of [...DASHBOARD_NAMESPACES, ...CLI_NAMESPACES]) {
expect(NAMESPACES).toContain(ns);
}
});
it("defaults to the common namespace", () => {
expect(DEFAULT_NAMESPACE).toBe("common");
});
it("keeps zh-CN and zh-TW separate (load: currentOnly)", () => {
const opts = baseInitOptions();
expect(opts.load).toBe("currentOnly");
expect(opts.supportedLngs).toEqual([...SUPPORTED_LOCALES]);
expect(opts.interpolation?.escapeValue).toBe(false);
});
it("routes Chinese scripts and defaults everything else to en", () => {
expect(FALLBACK_LNG.zh).toEqual(["zh-CN"]);
expect(FALLBACK_LNG["zh-Hans"]).toEqual(["zh-CN"]);
expect(FALLBACK_LNG["zh-Hant"]).toEqual(["zh-TW"]);
expect(FALLBACK_LNG.default).toEqual(["en"]);
});
it("ships a CLI catalog map for every supported locale and namespace", () => {
for (const lng of SUPPORTED_LOCALES) {
expect(cliResources).toHaveProperty(lng);
for (const ns of CLI_NAMESPACES) {
expect(cliResources[lng]).toHaveProperty(ns);
}
}
});
it("has real en content (catalogs wired, not empty)", () => {
expect(cliResources.en.cli).toMatchObject({ tui: { loading: expect.any(String) } });
expect(cliResources.en.common).toMatchObject({ columns: { done: "Done" } });
});
});

View File

@@ -0,0 +1,45 @@
// GENERATED by scripts/gen-cli-catalogs.mjs — do not edit by hand.
// Run `pnpm --filter @fusion/i18n gen:cli-catalogs` to regenerate.
import en_common from "../locales/en/common.json";
import en_cli from "../locales/en/cli.json";
import en_errors from "../locales/en/errors.json";
import es_common from "../locales/es/common.json";
import es_cli from "../locales/es/cli.json";
import es_errors from "../locales/es/errors.json";
import fr_common from "../locales/fr/common.json";
import fr_cli from "../locales/fr/cli.json";
import fr_errors from "../locales/fr/errors.json";
import zh_CN_common from "../locales/zh-CN/common.json";
import zh_CN_cli from "../locales/zh-CN/cli.json";
import zh_CN_errors from "../locales/zh-CN/errors.json";
import zh_TW_common from "../locales/zh-TW/common.json";
import zh_TW_cli from "../locales/zh-TW/cli.json";
import zh_TW_errors from "../locales/zh-TW/errors.json";
export const cliResources = {
"en": {
common: en_common,
cli: en_cli,
errors: en_errors,
},
"es": {
common: es_common,
cli: es_cli,
errors: es_errors,
},
"fr": {
common: fr_common,
cli: fr_cli,
errors: fr_errors,
},
"zh-CN": {
common: zh_CN_common,
cli: zh_CN_cli,
errors: zh_CN_errors,
},
"zh-TW": {
common: zh_TW_common,
cli: zh_TW_cli,
errors: zh_TW_errors,
},
} as const;

View File

@@ -0,0 +1,54 @@
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from "@fusion/core";
import type { FallbackLngObjList, InitOptions } from "i18next";
/**
* Shared, framework-agnostic i18next configuration for both Fusion UI surfaces.
*
* The dashboard (browser) and the terminal UI (Node) each build their own
* i18next instance, but they share the locale list, namespace split, fallback
* chain, and base options defined here so the two surfaces stay consistent.
*/
/** All translation namespaces. Split so each surface loads only what it needs. */
export const NAMESPACES = ["common", "app", "errors", "cli"] as const;
export type Namespace = (typeof NAMESPACES)[number];
/** Default namespace keys resolve against when none is specified. */
export const DEFAULT_NAMESPACE: Namespace = "common";
/** Namespaces the browser dashboard loads (skips the terminal-only `cli`). */
export const DASHBOARD_NAMESPACES: readonly Namespace[] = ["common", "app", "errors"];
/** Namespaces the terminal UI loads (skips the dashboard-only `app`). */
export const CLI_NAMESPACES: readonly Namespace[] = ["common", "cli", "errors"];
/**
* Script-aware fallback chain. A generic `zh` resolves to Simplified, the
* Han-script tags resolve to their region catalog, and everything else falls
* back to the source language. Combined with `load: "currentOnly"` this keeps
* `zh-CN` and `zh-TW` from ever collapsing into a single generic `zh`.
*/
export const FALLBACK_LNG: FallbackLngObjList = {
"zh-Hans": ["zh-CN"],
"zh-Hant": ["zh-TW"],
zh: ["zh-CN"],
default: [DEFAULT_LOCALE],
};
/**
* Base init options shared by every surface. Each surface spreads these and
* adds its own resource-loading strategy (lazy backend for the dashboard,
* static `resources` for the CLI) plus framework plugins.
*/
export function baseInitOptions(): InitOptions {
return {
supportedLngs: [...SUPPORTED_LOCALES],
fallbackLng: FALLBACK_LNG,
// Never collapse zh-CN/zh-TW into a generic `zh`.
load: "currentOnly",
nonExplicitSupportedLngs: false,
// React (and Ink) escape on render; double-escaping mangles output.
interpolation: { escapeValue: false },
returnNull: false,
};
}

View File

@@ -0,0 +1,3 @@
export * from "./config.js";
export { cliResources } from "./cli-catalogs.js";
export type { Locale } from "@fusion/core";

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"rootDir": ".",
"types": ["node", "vitest/globals"],
"resolveJsonModule": true
},
"include": ["src/**/*", "locales/**/*.json"],
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -0,0 +1,14 @@
import { resolve } from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
// Use @fusion/core's TypeScript source so tests don't require a dist build.
"@fusion/core": resolve(__dirname, "../core/src/index.ts"),
},
},
test: {
include: ["src/**/*.test.ts"],
},
});

929
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff