## Overview Internationalizes the **developer portal**, which previously had **zero i18n** — every string was hardcoded across ~118 components. Rather than stand up a parallel system, this shares the **editor's** existing i18n setup (same TOML locale format, same Crowdin pipeline), then converts every portal surface to `react-i18next` and adds a CI guard so coverage can't regress. ## What's included ### 🔗 Shared i18n core (`@shared/i18n`) - Extracts the editor's `TomlBackend` (HTTP loader for `public/locales/{lng}/translation.toml`) and language metadata/helpers (the 42-language list, RTL set, `LanguageSource` priority, code normalizers) into `frontend/shared/i18n/`. - The **editor** now imports and re-exports these from `@shared/i18n` — its 20+ consumers are unchanged. Its local `tomlBackend.ts` is deleted. - The **portal** builds its own i18next instance from the shared core, with **en-US as the source of truth** and the same `/locales/{lng}/translation.toml` layout. ### 🌍 Full portal coverage - Every view and component converted to `t()` — all feature areas (home, pipelines, sources, infrastructure, usage, documents, agent-builder, editor-admin, policies, users, docs, catalogue, components view) plus app shell, nav, modals, and the home/domain widgets. - **1108 keys across ~30 namespaces** in `portal/public/locales/en-US/translation.toml`, grouped by feature; shared strings under `[common]`. Plurals use i18next count forms; dynamic labels (nav, settings sections, status badges) use template keys against populated tables. - Data-driven strings (values from `@portal/api/*` mocks, enum/id values, code samples) are intentionally left untranslated — they're data, not UI chrome. ### ✅ CI coverage guard - `portal/scripts/check-i18n.mjs` fails if any static `t("key")` in portal source is missing from the en-US locale. Wired into `frontend:check` and `frontend:check:all`, so missed keys break CI. This mirrors the editor's `missingTranslations` test for the portal, which has no vitest harness of its own. ## Testing - `task frontend:check:all` passes locally (typecheck all variants, lint, format, **portal i18n guard**, builds, tests, storybook). - Every static `t()` key verified to resolve in the locale (1108 keys / 186 source files); all dynamic key prefixes map to populated tables. - Runtime sweep of all 12 portal routes shows **no unresolved keys** on screen; nav labels, plurals, and array-backed copy all render real text. ## Follow-ups (not in this PR) - **Crowdin** — register `frontend/portal/public/locales/` as a source so portal strings flow through the same translation pipeline as the editor (an ops step on the Crowdin side; there's no Crowdin config in the repo). - Only `en-US` is populated; other languages will arrive via the pipeline.
60 lines
1.6 KiB
TypeScript
60 lines
1.6 KiB
TypeScript
import { BackendModule, ReadCallback } from "i18next";
|
|
import { parse } from "smol-toml";
|
|
|
|
/**
|
|
* i18next backend that loads TOML translation files over HTTP. Shared by the
|
|
* editor and portal so both apps consume the same locale format and the same
|
|
* Crowdin-managed `public/locales/{lng}/{ns}.toml` layout.
|
|
*/
|
|
export interface TomlBackendOptions {
|
|
loadPath: string | ((lngs: string[], namespaces: string[]) => string);
|
|
}
|
|
|
|
class TomlBackend implements BackendModule<TomlBackendOptions> {
|
|
static type = "backend" as const;
|
|
type = "backend" as const;
|
|
|
|
constructor(services?: unknown, options?: TomlBackendOptions) {
|
|
this.init(services, options);
|
|
}
|
|
|
|
init(_services?: unknown, options?: TomlBackendOptions): void {
|
|
this.options = options;
|
|
}
|
|
|
|
read(language: string, namespace: string, callback: ReadCallback): void {
|
|
const loadPath = this.options?.loadPath;
|
|
|
|
if (!loadPath) {
|
|
callback(new Error("loadPath is not configured"), null);
|
|
return;
|
|
}
|
|
|
|
const url =
|
|
typeof loadPath === "function"
|
|
? loadPath([language], [namespace])
|
|
: loadPath.replace("{{lng}}", language).replace("{{ns}}", namespace);
|
|
|
|
fetch(url)
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to load translation file: ${url} (${response.status})`,
|
|
);
|
|
}
|
|
return response.text();
|
|
})
|
|
.then((tomlContent) => {
|
|
const parsed = parse(tomlContent);
|
|
callback(null, parsed);
|
|
})
|
|
.catch((error) => {
|
|
callback(error, null);
|
|
});
|
|
}
|
|
|
|
private options?: TomlBackendOptions;
|
|
}
|
|
|
|
export default TomlBackend;
|