refactor(ui): replace duplicated locale bundles with canonical translation memory (#117157)

* refactor(ui): generate locale bundles from canonical translation memory

* fix(ui): keep server-only locale config out of browser test helpers
This commit is contained in:
Peter Steinberger
2026-07-31 19:40:27 -07:00
committed by GitHub
parent 91aff8089c
commit 7e78de747b
93 changed files with 34916 additions and 135035 deletions
-2
View File
@@ -2,5 +2,3 @@
CLAUDE.md -text
src/gateway/server-methods/CLAUDE.md -text
ui/src/i18n/.i18n/* linguist-generated
ui/src/i18n/locales/*.ts linguist-generated
ui/src/i18n/locales/en.ts -linguist-generated
@@ -10,10 +10,13 @@ on:
- ui/src/i18n/.i18n/*
- ui/src/i18n/lib/types.ts
- ui/src/i18n/lib/registry.ts
- ui/config/control-ui-locales.ts
- scripts/control-ui-i18n.ts
- scripts/control-ui-i18n-verify.ts
- scripts/lib/control-ui-i18n-catalog.ts
- scripts/lib/control-ui-i18n-config.ts
- scripts/lib/control-ui-i18n-raw-copy.ts
- scripts/lib/control-ui-i18n-sync-plan.ts
- .github/actions/create-generated-pr-tokens/action.yml
- .github/actions/publish-generated-pr/action.yml
- .github/workflows/control-ui-locale-refresh.yml
@@ -250,8 +253,10 @@ jobs:
set -euo pipefail
artifact_dir="${RUNNER_TEMP}/control-ui-locale-${LOCALE}"
mkdir -p "${artifact_dir}"
git add -A ui/src/i18n
git diff --cached --binary --full-index -- ui/src/i18n ':(exclude)ui/src/i18n/.i18n/catalog-fallbacks.json' > "${artifact_dir}/${LOCALE}.patch"
locale_memory="ui/src/i18n/.i18n/${LOCALE}.tm.jsonl"
locale_metadata="ui/src/i18n/.i18n/${LOCALE}.meta.json"
git add -A -- "${locale_memory}" "${locale_metadata}"
git diff --cached --binary --full-index -- "${locale_memory}" "${locale_metadata}" ':(exclude)ui/src/i18n/.i18n/catalog-fallbacks.json' > "${artifact_dir}/${LOCALE}.patch"
- name: Upload locale artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -318,17 +323,25 @@ jobs:
commit-message: "chore(ui): refresh control ui locales"
pr-title: "chore(ui): refresh control ui locales"
auto-merge: "true"
generated-paths: ui/src/i18n
generated-paths: |
ui/src/i18n/.i18n/*.tm.jsonl
ui/src/i18n/.i18n/*.meta.json
ui/src/i18n/.i18n/catalog-fallbacks.json
invalidation-paths: |
ui/src/i18n/locales/*.ts
ui/src/i18n/locales/en.ts
ui/src/i18n/locales/en-agents.ts
ui/src/i18n/.i18n/glossary.*.json
ui/src/i18n/.i18n/raw-copy-baseline.json
ui/src/i18n/lib/types.ts
ui/src/i18n/lib/registry.ts
ui/config/control-ui-locales.ts
scripts/control-ui-i18n.ts
scripts/control-ui-i18n-verify.ts
scripts/lib/control-ui-i18n-catalog.ts
scripts/lib/control-ui-i18n-config.ts
scripts/lib/control-ui-i18n-raw-copy.ts
scripts/lib/control-ui-i18n-sync-plan.ts
.github/actions/create-generated-pr-tokens/action.yml
.github/actions/publish-generated-pr/action.yml
.github/workflows/control-ui-locale-refresh.yml
+1 -1
View File
@@ -50,7 +50,7 @@ const DEPRECATION_HYGIENE_PATH_RE =
const CANVAS_A2UI_NATIVE_RESOURCE_PATH_RE =
/^(?:pnpm-lock\.yaml$|apps\/shared\/OpenClawKit\/Sources\/OpenClawKit\/Resources\/CanvasA2UI\/|extensions\/canvas\/(?:package\.json$|scripts\/bundle-a2ui\.mjs$|src\/host\/a2ui(?:\/(?:index\.html|a2ui\.bundle\.js|\.bundle\.hash)$|-app\/))|scripts\/(?:bundle-a2ui|sync-native-a2ui)\.mjs$)/u;
const CONTROL_UI_I18N_VERIFY_PATH_RE =
/^(?:package\.json$|ui\/src\/|scripts\/(?:control-ui-i18n(?:-(?:report|verify))?\.ts|lib\/control-ui-i18n-[^/]+\.ts)$|test\/scripts\/control-ui-i18n[^/]*\.test\.ts$)/u;
/^(?:package\.json$|ui\/(?:src\/|config\/control-ui-locales\.ts$)|scripts\/(?:control-ui-i18n(?:-(?:report|verify))?\.ts|lib\/control-ui-i18n-[^/]+\.ts)$|test\/scripts\/control-ui-i18n[^/]*\.test\.ts$)/u;
const CORE_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.core.json";
const EXTENSIONS_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.extensions.json";
const SCRIPTS_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.scripts.json";
+60 -4
View File
@@ -1,6 +1,6 @@
// Determines CI scope from changed paths.
import { execFileSync } from "node:child_process";
import { appendFileSync } from "node:fs";
import { appendFileSync, readFileSync, readdirSync } from "node:fs";
import { getChangedPathFacts } from "./lib/changed-path-facts.mjs";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import { resolveMergeHeadDiffBase } from "./lib/merge-head-diff-base.mjs";
@@ -56,10 +56,10 @@ const WINDOWS_TEST_SCOPE_RE =
const WINDOWS_DAEMON_SCOPE_RE =
/^src\/daemon\/(?:schtasks(?:[-.][^/]+)?|runtime-hints\.windows-paths(?:\.test)?|test-helpers\/schtasks-(?:base-mocks|fixtures))\.ts$/;
const CONTROL_UI_I18N_SCOPE_RE =
/^(ui\/src\/i18n\/|scripts\/(?:control-ui-i18n(?:-verify)?\.ts|lib\/control-ui-i18n-(?:config|raw-copy)\.ts)$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/;
/^(ui\/src\/i18n\/|ui\/config\/control-ui-locales\.ts$|scripts\/(?:control-ui-i18n(?:-verify)?\.ts|lib\/control-ui-i18n-(?:catalog|config|raw-copy|sync-plan)\.ts)$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/;
const CONTROL_UI_RAW_COPY_SOURCE_RE = /^ui\/src\/(?:app|components|lib|pages)\/.*\.tsx?$/;
const CONTROL_UI_HARD_GENERATED_I18N_RE =
/^(?:ui\/src\/i18n\/locales\/(?!en(?:-agents)?\.ts$)[^/]+\.ts|ui\/src\/i18n\/\.i18n\/(?:catalog-fallbacks\.json|[^/]+\.(?:meta\.json|tm\.jsonl)))$/;
/^ui\/src\/i18n\/\.i18n\/(?:catalog-fallbacks\.json|[^/]+\.(?:meta\.json|tm\.jsonl))$/;
const RELEASE_BRANCH_RE = /^release\/\d{4}\.\d+\.\d+$/;
export class ControlUiGeneratedArtifactsMixedError extends Error {}
@@ -222,16 +222,72 @@ export function assertControlUiGeneratedArtifactsIsolated(changedPaths, branchNa
if (sourcePaths.length === 0) {
return;
}
if (isControlUiCanonicalMemoryMigration(changedPaths, generatedPaths)) {
return;
}
throw new ControlUiGeneratedArtifactsMixedError(
[
"Control UI generated locale artifacts must be isolated from source changes.",
"Commit English/source changes only; the locale refresh workflow owns generated bundles and metadata.",
"Commit English/source changes only; the locale refresh workflow owns generated translation memory and metadata.",
...generatedPaths.map((filePath) => `- generated: ${filePath}`),
...sourcePaths.map((filePath) => `- source: ${filePath}`),
].join("\n"),
);
}
function isControlUiCanonicalMemoryMigration(changedPaths, generatedPaths) {
const requiredOwners = [
".gitattributes",
"scripts/ci-changed-scope.mjs",
"scripts/control-ui-i18n.ts",
"scripts/control-ui-i18n-verify.ts",
"scripts/lib/control-ui-i18n-catalog.ts",
"scripts/lib/control-ui-i18n-sync-plan.ts",
"ui/AGENTS.md",
"ui/config/control-ui-locales.ts",
"ui/vite.config.ts",
];
if (!requiredOwners.every((owner) => changedPaths.includes(owner))) {
return false;
}
const assetsDir = new URL("../ui/src/i18n/.i18n/", import.meta.url);
const locales = readdirSync(assetsDir)
.filter((fileName) => fileName.endsWith(".tm.jsonl"))
.map((fileName) => fileName.slice(0, -".tm.jsonl".length));
const requiredGeneratedPaths = [
"ui/src/i18n/.i18n/catalog-fallbacks.json",
...locales.flatMap((locale) => [
`ui/src/i18n/.i18n/${locale}.tm.jsonl`,
`ui/src/i18n/.i18n/${locale}.meta.json`,
]),
];
if (
generatedPaths.length !== requiredGeneratedPaths.length ||
!requiredGeneratedPaths.every((filePath) => generatedPaths.includes(filePath))
) {
return false;
}
return locales.every((locale) => {
const adapterPath = `ui/src/i18n/locales/${locale}.ts`;
if (!changedPaths.includes(adapterPath)) {
return false;
}
let source;
try {
source = readFileSync(new URL(`../${adapterPath}`, import.meta.url), "utf8").trim();
} catch {
return false;
}
const exportName = locale.replaceAll("-", "_");
return (
source ===
`export { default as ${exportName} } from "virtual:openclaw-control-ui-locale/${locale}";`
);
});
}
export function shouldStrictControlUiI18n(changedPaths) {
return (
changedPaths === null ||
+11 -8
View File
@@ -3,6 +3,10 @@ import { existsSync } from "node:fs";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
loadControlUiTranslationMemory,
materializeControlUiLocaleCatalog,
} from "./lib/control-ui-i18n-catalog.ts";
import { CONTROL_UI_LOCALE_ENTRIES } from "./lib/control-ui-i18n-config.ts";
import { syncControlUiRawCopyBaseline } from "./lib/control-ui-i18n-raw-copy.ts";
import type { TranslationMap } from "./lib/control-ui-i18n-sync-plan.ts";
@@ -48,10 +52,6 @@ async function loadLocaleMap(filePath: string, exportName: string): Promise<Tran
return mod[exportName] ?? null;
}
function localeFilePath(fileName: string): string {
return path.join(LOCALES_DIR, fileName);
}
function extractPlaceholders(text: string): string[] {
return [...new Set([...text.matchAll(/\{(\w+)\}/g)].map((match) => match[1] ?? ""))]
.filter(Boolean)
@@ -144,11 +144,14 @@ async function buildCatalogFallbackBaseline(
const sourceFlat = flattenControlUiCatalog(sourceMap, "en");
const localeFlats = new Map<string, Map<string, string>>();
for (const entry of CONTROL_UI_LOCALE_ENTRIES) {
const filePath = localeFilePath(entry.fileName);
const localeMap = await loadLocaleMap(filePath, entry.exportName);
if (!localeMap) {
throw new Error(`${toRepoPath(filePath)} does not export ${entry.exportName}`);
const memoryPath = path.join(I18N_ASSETS_DIR, `${entry.locale}.tm.jsonl`);
if (!existsSync(memoryPath)) {
throw new Error(`${toRepoPath(memoryPath)} does not contain ${entry.locale} translations`);
}
const localeMap = materializeControlUiLocaleCatalog(
sourceFlat,
loadControlUiTranslationMemory(memoryPath),
);
localeFlats.set(entry.locale, flattenControlUiCatalog(localeMap, entry.locale));
}
+10 -95
View File
@@ -14,6 +14,11 @@ import {
verifyControlUiGeneratedCatalogs,
verifyRuntimeLocaleConfig,
} from "./control-ui-i18n-verify.ts";
import {
hashControlUiTranslationText,
loadControlUiTranslationMemory,
materializeControlUiLocaleCatalog,
} from "./lib/control-ui-i18n-catalog.ts";
import { CONTROL_UI_LOCALE_ENTRIES } from "./lib/control-ui-i18n-config.ts";
import { syncControlUiRawCopyBaseline } from "./lib/control-ui-i18n-raw-copy.ts";
import {
@@ -26,26 +31,12 @@ import {
type LocaleMeta,
type TranslationBatchItem,
type TranslationMap,
type TranslationMemoryEntry,
} from "./lib/control-ui-i18n-sync-plan.ts";
import { sleep } from "./lib/sleep.mjs";
import { resolveWindowsTaskkillPath } from "./lib/windows-taskkill.mjs";
export { shouldReuseExistingTranslation } from "./lib/control-ui-i18n-sync-plan.ts";
const { formatGeneratedModule } = (await import(
new URL("./lib/format-generated-module.mjs", import.meta.url).href
)) as {
formatGeneratedModule: (
source: string,
options: {
errorLabel: string;
outputPath: string;
repoRoot: string;
},
) => string;
};
type RunProcessParentSignalState = {
done: boolean;
signal: NodeJS.Signals | null;
@@ -256,18 +247,10 @@ function resolveKnownTranslationProvider(): TranslationProvider {
throw new Error(`Unsupported translation provider: ${provider}`);
}
function normalizeText(text: string): string {
return text.trim().split(/\s+/).join(" ");
}
function sha256(input: string | Uint8Array): string {
return createHash("sha256").update(input).digest("hex");
}
function hashText(text: string): string {
return sha256(normalizeText(text));
}
function cacheNamespace(): string {
return [
`wf=${CONTROL_UI_I18N_WORKFLOW}`,
@@ -281,10 +264,6 @@ function cacheKey(segmentId: string, textHash: string, targetLocale: string): st
return sha256([cacheNamespace(), SOURCE_LOCALE, targetLocale, segmentId, textHash].join("|"));
}
function localeFilePath(entry: LocaleEntry): string {
return path.join(LOCALES_DIR, entry.fileName);
}
function glossaryPath(entry: LocaleEntry): string {
return path.join(I18N_ASSETS_DIR, `glossary.${entry.locale}.json`);
}
@@ -408,27 +387,6 @@ async function loadMeta(filePath: string): Promise<LocaleMeta | null> {
return JSON.parse(raw) as LocaleMeta;
}
async function loadTranslationMemory(
filePath: string,
): Promise<Map<string, TranslationMemoryEntry>> {
const entries = new Map<string, TranslationMemoryEntry>();
if (!existsSync(filePath)) {
return entries;
}
const raw = await readFile(filePath, "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const parsed = JSON.parse(trimmed) as TranslationMemoryEntry;
if (parsed.cache_key && parsed.translated.trim()) {
entries.set(parsed.cache_key, parsed);
}
}
return entries;
}
function buildGlossaryPrompt(glossary: readonly GlossaryEntry[]): string {
if (glossary.length === 0) {
return "";
@@ -801,43 +759,6 @@ export async function runProcess(
});
}
async function formatGeneratedTypeScript(filePath: string, source: string): Promise<string> {
const formatted = formatGeneratedModule(source, {
errorLabel: "control ui locale",
outputPath: filePath,
repoRoot: ROOT,
});
return restoreReplacementCorruptedStringLiterals(source, formatted);
}
function restoreReplacementCorruptedStringLiterals(source: string, formatted: string): string {
if (!formatted.includes("\uFFFD") || source.includes("\uFFFD")) {
return formatted;
}
const stringLiteralPattern = /"(?:\\.|[^"\\])*"/gu;
const sourceLiterals = [...source.matchAll(stringLiteralPattern)];
const formattedLiterals = [...formatted.matchAll(stringLiteralPattern)];
if (sourceLiterals.length !== formattedLiterals.length) {
return formatted;
}
let output = "";
let cursor = 0;
for (const [index, formattedLiteral] of formattedLiterals.entries()) {
const replacement = sourceLiterals[index]?.[0];
const literal = formattedLiteral[0];
const start = formattedLiteral.index;
if (replacement === undefined || start === undefined) {
return formatted;
}
output += formatted.slice(cursor, start);
output += literal.includes("\uFFFD") && !replacement.includes("\uFFFD") ? replacement : literal;
cursor = start + literal.length;
}
return `${output}${formatted.slice(cursor)}`;
}
type LocaleRunContext = {
localeCount: number;
localeIndex: number;
@@ -1109,10 +1030,10 @@ export async function translateNativeEntries(
throw new Error("native app translation requires OPENAI_API_KEY or ANTHROPIC_API_KEY");
}
const pending = entries.map((entry) => ({
cacheKey: cacheKey(entry.id, hashText(entry.source), targetLocale),
cacheKey: cacheKey(entry.id, hashControlUiTranslationText(entry.source), targetLocale),
key: entry.id,
text: entry.source,
textHash: hashText(entry.source),
textHash: hashControlUiTranslationText(entry.source),
}));
const batches = buildTranslationBatches(pending);
const clientAccess = createTranslationClientAccess(targetLocale, glossary);
@@ -1171,8 +1092,8 @@ async function syncLocale(
const sourceHash = sha256(sourceRaw);
const sourceMap = (await loadLocaleMap(SOURCE_LOCALE_PATH, "en")) ?? {};
const sourceFlat = flattenTranslations(sourceMap);
const existingPath = localeFilePath(entry);
const existingMap = (await loadLocaleMap(existingPath, entry.exportName)) ?? {};
const tm = loadControlUiTranslationMemory(tmPath(entry));
const existingMap = materializeControlUiLocaleCatalog(sourceFlat, tm);
const existingFlat = flattenTranslations(existingMap);
// Placeholder changes invalidate the old translation even when the key stays
// stable. Treat it as pending so the locale bot can repair source-only PRs.
@@ -1180,7 +1101,6 @@ async function syncLocale(
const previousMeta = await loadMeta(metaPath(entry));
const glossaryFilePath = glossaryPath(entry);
const glossary = await loadGlossary(glossaryFilePath);
const tm = await loadTranslationMemory(tmPath(entry));
const allowTranslate = options.allowTranslate;
const plan = createControlUiLocaleSyncPlan({
allowTranslate,
@@ -1188,7 +1108,7 @@ async function syncLocale(
entry,
existingFlat: reusableExistingFlat,
force: options.force,
hashText,
hashText: hashControlUiTranslationText,
previousMeta,
sourceFlat,
sourceHash,
@@ -1277,12 +1197,10 @@ async function syncLocale(
});
assertPlaceholderParity(sourceFlat, artifacts.nextFlat, entry.locale);
const expectedLocale = await formatGeneratedTypeScript(existingPath, artifacts.localeModule);
const expectedMeta = artifacts.meta;
const expectedGlossary = artifacts.glossary;
const expectedTm = artifacts.translationMemory;
const currentLocale = existsSync(existingPath) ? await readFile(existingPath, "utf8") : "";
const currentMeta = existsSync(metaPath(entry)) ? await readFile(metaPath(entry), "utf8") : "";
const currentGlossary = existsSync(glossaryFilePath)
? await readFile(glossaryFilePath, "utf8")
@@ -1290,7 +1208,6 @@ async function syncLocale(
const currentTm = existsSync(tmPath(entry)) ? await readFile(tmPath(entry), "utf8") : "";
const changed =
currentLocale !== expectedLocale ||
currentMeta !== expectedMeta ||
currentGlossary !== expectedGlossary ||
currentTm !== expectedTm;
@@ -1314,9 +1231,7 @@ async function syncLocale(
}
if (!options.checkOnly && options.write) {
await mkdir(LOCALES_DIR, { recursive: true });
await mkdir(I18N_ASSETS_DIR, { recursive: true });
await writeFile(existingPath, expectedLocale, "utf8");
await writeFile(metaPath(entry), expectedMeta, "utf8");
await writeFile(glossaryFilePath, expectedGlossary, "utf8");
if (expectedTm) {
+65
View File
@@ -0,0 +1,65 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import type { TranslationMap, TranslationMemoryEntry } from "./control-ui-i18n-sync-plan.ts";
export function hashControlUiTranslationText(text: string): string {
return createHash("sha256").update(text.trim().split(/\s+/).join(" ")).digest("hex");
}
export function loadControlUiTranslationMemory(
filePath: string,
): Map<string, TranslationMemoryEntry> {
const entries = new Map<string, TranslationMemoryEntry>();
if (!existsSync(filePath)) {
return entries;
}
for (const line of readFileSync(filePath, "utf8").split("\n")) {
if (!line.trim()) {
continue;
}
const entry = JSON.parse(line) as TranslationMemoryEntry;
if (entry.cache_key && entry.translated.trim()) {
entries.set(entry.cache_key, entry);
}
}
return entries;
}
function setControlUiCatalogValue(catalog: TranslationMap, key: string, value: string): void {
const parts = key.split(".");
let current = catalog;
for (const part of parts.slice(0, -1)) {
const existing = current[part];
if (!existing || typeof existing === "string") {
current[part] = {};
}
current = current[part] as TranslationMap;
}
current[parts[parts.length - 1]!] = value;
}
export function materializeControlUiLocaleCatalog(
sourceFlat: ReadonlyMap<string, string>,
memory: ReadonlyMap<string, TranslationMemoryEntry>,
): TranslationMap {
const translations = new Map<string, string>();
for (const entry of memory.values()) {
for (const key of [entry.segment_id, ...(entry.segment_ids ?? [])]) {
const source = sourceFlat.get(key);
if (source === undefined || entry.text_hash !== hashControlUiTranslationText(source)) {
continue;
}
translations.set(key, entry.translated);
}
}
const catalog: TranslationMap = {};
for (const key of sourceFlat.keys()) {
const translated = translations.get(key);
if (translated !== undefined) {
setControlUiCatalogValue(catalog, key, translated);
}
}
return catalog;
}
+59 -55
View File
@@ -1,5 +1,3 @@
import { expectDefined } from "../../packages/normalization-core/src/expect.js";
export interface TranslationMap {
[key: string]: string | TranslationMap;
}
@@ -21,6 +19,8 @@ export type TranslationMemoryEntry = {
model: string;
provider: string;
segment_id: string;
// Aliases share their primary segment's source hash and translated text.
segment_ids?: string[];
source_path: string;
src_lang: string;
text: string;
@@ -102,6 +102,11 @@ export function createControlUiLocaleSyncPlan(input: {
}) {
const previousFallbackKeys = new Set(input.previousMeta?.fallbackKeys ?? []);
const translationMemory = new Map(input.translationMemory);
const translationMemoryBySegment = new Map(
[...translationMemory.values()].flatMap((entry) =>
[entry.segment_id, ...(entry.segment_ids ?? [])].map((key) => [key, entry] as const),
),
);
const translationMemoryByTextHash = new Map(
[...translationMemory.values()]
.filter(
@@ -117,7 +122,10 @@ export function createControlUiLocaleSyncPlan(input: {
for (const [key, text] of input.sourceFlat.entries()) {
const textHash = input.hashText(text);
const segmentCacheKey = input.cacheKeyFor(key, textHash);
const cached = translationMemory.get(segmentCacheKey);
const exactSegment = translationMemoryBySegment.get(key);
const cached =
translationMemory.get(segmentCacheKey) ??
(exactSegment?.text_hash === textHash ? exactSegment : undefined);
const cachedByText = translationMemoryByTextHash.get(textHash);
const existing = input.existingFlat.get(key);
const shouldRefreshFallback = previousFallbackKeys.has(key);
@@ -137,8 +145,9 @@ export function createControlUiLocaleSyncPlan(input: {
if (cachedByText && (shouldRefreshFallback || existing === undefined)) {
nextFlat.set(key, cachedByText.translated);
const { segment_ids: _aliases, ...cachedTranslation } = cachedByText;
translationMemory.set(segmentCacheKey, {
...cachedByText,
...cachedTranslation,
cache_key: segmentCacheKey,
segment_id: key,
source_path: `ui/src/i18n/locales/${input.entry.fileName}`,
@@ -241,20 +250,19 @@ export function createControlUiLocaleSyncPlan(input: {
translatedKeys,
workflow: options.workflow,
};
const nextMap: TranslationMap = {};
for (const [key, value] of input.sourceFlat.entries()) {
setNestedValue(nextMap, key, nextFlat.get(key) ?? value);
}
return {
fallbackCount: sortedFallbackKeys.length,
glossary: renderJson(
options.glossary.length === 0 ? options.defaultGlossary : options.glossary,
),
localeModule: renderLocaleModule(input.entry, nextMap),
meta: renderJson(nextMeta),
nextFlat,
translationMemory: renderTranslationMemory(translationMemory),
translationMemory: renderTranslationMemory(
translationMemory,
input.sourceFlat,
nextFlat,
input.hashText,
),
};
},
};
@@ -264,54 +272,50 @@ export function compareStringArrays(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function setNestedValue(root: TranslationMap, dottedKey: string, value: string): void {
const parts = dottedKey.split(".");
let cursor: TranslationMap = root;
for (let index = 0; index < parts.length - 1; index += 1) {
const key = expectDefined(parts[index], `translation key segment at index ${index}`);
const next = cursor[key];
if (!next || typeof next === "string") {
const replacement: TranslationMap = {};
cursor[key] = replacement;
cursor = replacement;
function renderTranslationMemory(
entries: ReadonlyMap<string, TranslationMemoryEntry>,
sourceFlat: ReadonlyMap<string, string>,
translatedFlat: ReadonlyMap<string, string>,
hashText: (text: string) => string,
): string {
const bySegment = new Map(
[...entries.values()].flatMap((entry) =>
[entry.segment_id, ...(entry.segment_ids ?? [])].map((key) => [key, entry] as const),
),
);
const byTranslation = new Map(
[...entries.values()].map((entry) => [
JSON.stringify([entry.text_hash, entry.translated]),
entry,
]),
);
const canonical = new Map<string, TranslationMemoryEntry>();
for (const [key, text] of sourceFlat) {
const translated = translatedFlat.get(key);
if (translated === undefined) {
continue;
}
cursor = next;
}
cursor[expectDefined(parts.at(-1), "translation leaf key")] = value;
}
function renderTranslationValue(value: string | TranslationMap, indent = 0): string {
if (typeof value === "string") {
return JSON.stringify(value);
const textHash = hashText(text);
const groupKey = JSON.stringify([textHash, translated]);
const existing = canonical.get(groupKey);
if (existing) {
(existing.segment_ids ??= []).push(key);
continue;
}
const direct = bySegment.get(key);
const source =
direct?.text_hash === textHash && direct.translated === translated
? direct
: byTranslation.get(groupKey);
if (!source) {
continue;
}
const { segment_ids: _aliases, ...record } = source;
canonical.set(groupKey, { ...record, segment_id: key, text, text_hash: textHash });
}
const entries = Object.entries(value);
if (entries.length === 0) {
return "{}";
}
const pad = " ".repeat(indent);
const innerPad = " ".repeat(indent + 1);
return `{\n${entries
.map(([key, nested]) => {
const renderedKey = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
return `${innerPad}${renderedKey}: ${renderTranslationValue(nested, indent + 1)},`;
})
.join("\n")}\n${pad}}`;
}
function renderLocaleModule(entry: LocaleEntry, value: TranslationMap): string {
return `// Generated locale bundle for Control UI translations.
// Run \`pnpm ui:i18n:sync\` instead of editing this file directly.
import type { TranslationMap } from "../lib/types.ts";
export const ${entry.exportName}: TranslationMap = ${renderTranslationValue(value)};
`;
}
function renderTranslationMemory(entries: ReadonlyMap<string, TranslationMemoryEntry>): string {
const ordered = [...entries.values()].toSorted((left, right) =>
const ordered = [...canonical.values()].toSorted((left, right) =>
left.cache_key.localeCompare(right.cache_key),
);
return ordered.length === 0
+1
View File
@@ -1940,6 +1940,7 @@ const SOURCE_TEST_TARGETS = new Map([
["src/plugins/runtime-sidecar-paths-baseline.ts", RUNTIME_SIDECAR_BASELINE_OWNER_TEST_TARGETS],
["src/plugins/runtime-sidecar-paths.ts", RUNTIME_SIDECAR_PATH_CONSUMER_TEST_TARGETS],
["ui/config/control-ui-chunking.ts", ["ui/src/app/control-ui-chunking.test.ts"]],
["ui/config/control-ui-locales.ts", ["ui/src/app/vite-config.node.test.ts"]],
[
"src/plugin-sdk/test-helpers/directory-ids.ts",
[
-124
View File
@@ -1,124 +0,0 @@
// I18n registry tests cover locale bundle lookup and fallback behavior.
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
type TranslationTree = {
readonly [key: string]: string | TranslationTree | undefined;
};
type LocaleRegistry = {
DEFAULT_LOCALE: string;
SUPPORTED_LOCALES: readonly string[];
loadLazyLocaleTranslation(locale: string): Promise<TranslationTree | null>;
resolveNavigatorLocale(locale: string): string;
};
const registryModuleUrl = new URL("../../ui/src/i18n/lib/registry.ts", import.meta.url);
const describeWhenUiI18nPresent = fs.existsSync(fileURLToPath(registryModuleUrl))
? describe
: describe.skip;
const registry =
describeWhenUiI18nPresent === describe
? ((await import("../../ui/src/i18n/lib/registry.ts")) as LocaleRegistry)
: undefined;
function getRegistry(): LocaleRegistry {
if (registry === undefined) {
throw new Error("expected UI i18n registry to be present");
}
return registry;
}
function getNestedTranslation(map: TranslationTree | null, ...path: string[]): string | undefined {
let value: string | TranslationTree | undefined = map ?? undefined;
for (const key of path) {
if (value === undefined || typeof value === "string") {
return undefined;
}
value = value[key];
}
return typeof value === "string" ? value : undefined;
}
describeWhenUiI18nPresent("ui i18n locale registry", () => {
it("lists supported locales", () => {
const localeRegistry = getRegistry();
expect(localeRegistry.SUPPORTED_LOCALES).toEqual([
"en",
"zh-CN",
"zh-TW",
"pt-BR",
"de",
"es",
"ja-JP",
"ko",
"fr",
"hi",
"ar",
"it",
"tr",
"uk",
"id",
"pl",
"th",
"vi",
"nl",
"fa",
"ru",
]);
expect(localeRegistry.DEFAULT_LOCALE).toBe("en");
});
it("resolves browser locale fallbacks", () => {
const localeRegistry = getRegistry();
expect(localeRegistry.resolveNavigatorLocale("de-DE")).toBe("de");
expect(localeRegistry.resolveNavigatorLocale("es-ES")).toBe("es");
expect(localeRegistry.resolveNavigatorLocale("es-MX")).toBe("es");
expect(localeRegistry.resolveNavigatorLocale("pt-PT")).toBe("pt-BR");
expect(localeRegistry.resolveNavigatorLocale("zh-HK")).toBe("zh-TW");
expect(localeRegistry.resolveNavigatorLocale("en-US")).toBe("en");
expect(localeRegistry.resolveNavigatorLocale("ja-JP")).toBe("ja-JP");
expect(localeRegistry.resolveNavigatorLocale("ko-KR")).toBe("ko");
expect(localeRegistry.resolveNavigatorLocale("fr-CA")).toBe("fr");
expect(localeRegistry.resolveNavigatorLocale("hi-IN")).toBe("hi");
expect(localeRegistry.resolveNavigatorLocale("ar-EG")).toBe("ar");
expect(localeRegistry.resolveNavigatorLocale("it-IT")).toBe("it");
expect(localeRegistry.resolveNavigatorLocale("tr-TR")).toBe("tr");
expect(localeRegistry.resolveNavigatorLocale("uk-UA")).toBe("uk");
expect(localeRegistry.resolveNavigatorLocale("id-ID")).toBe("id");
expect(localeRegistry.resolveNavigatorLocale("pl-PL")).toBe("pl");
expect(localeRegistry.resolveNavigatorLocale("th-TH")).toBe("th");
expect(localeRegistry.resolveNavigatorLocale("vi-VN")).toBe("vi");
expect(localeRegistry.resolveNavigatorLocale("nl-NL")).toBe("nl");
expect(localeRegistry.resolveNavigatorLocale("fa-IR")).toBe("fa");
expect(localeRegistry.resolveNavigatorLocale("ru-RU")).toBe("ru");
});
it("loads lazy locale translations from the registry", async () => {
const localeRegistry = getRegistry();
const [de, es, ptBR, zhCN, hi, th, ru, en] = await Promise.all([
localeRegistry.loadLazyLocaleTranslation("de"),
localeRegistry.loadLazyLocaleTranslation("es"),
localeRegistry.loadLazyLocaleTranslation("pt-BR"),
localeRegistry.loadLazyLocaleTranslation("zh-CN"),
localeRegistry.loadLazyLocaleTranslation("hi"),
localeRegistry.loadLazyLocaleTranslation("th"),
localeRegistry.loadLazyLocaleTranslation("ru"),
localeRegistry.loadLazyLocaleTranslation("en"),
]);
expect(getNestedTranslation(de, "common", "health")).toBe("Status");
expect(getNestedTranslation(es, "common", "health")).toBe("Estado");
expect(getNestedTranslation(es, "languages", "de")).toBe("Deutsch (Alemán)");
expect(getNestedTranslation(ptBR, "languages", "es")).toBe("Español (Espanhol)");
expect(getNestedTranslation(zhCN, "common", "health")).toBe("\u5065\u5eb7\u72b6\u51b5");
expect(getNestedTranslation(hi, "languages", "en")).toBe("English (अंग्रेज़ी)");
expect(getNestedTranslation(th, "languages", "en")).toBe("อังกฤษ");
expect(getNestedTranslation(ru, "languages", "en")).toBe("Английский");
expect(en).toBeNull();
});
});
+6 -13
View File
@@ -838,7 +838,7 @@ describe("detectChangedScope", () => {
});
it("runs control-ui locale check only for control-ui i18n surfaces", () => {
expect(detectChangedScope(["ui/src/i18n/locales/en.ts"])).toEqual({
const expected = {
runNode: true,
runMacos: false,
runIosBuild: false,
@@ -848,24 +848,17 @@ describe("detectChangedScope", () => {
runChangedSmoke: false,
runControlUiI18n: true,
runUiTests: true,
});
};
expect(detectChangedScope(["ui/src/i18n/locales/en.ts"])).toEqual(expected);
for (const scriptPath of [
"scripts/control-ui-i18n.ts",
"scripts/control-ui-i18n-verify.ts",
"scripts/lib/control-ui-i18n-catalog.ts",
"scripts/lib/control-ui-i18n-raw-copy.ts",
"scripts/lib/control-ui-i18n-sync-plan.ts",
]) {
expect(detectChangedScope([scriptPath])).toEqual({
runNode: true,
runMacos: false,
runIosBuild: false,
runAndroid: false,
runWindows: false,
runSkillsPython: false,
runChangedSmoke: false,
runControlUiI18n: true,
runUiTests: false,
});
expect(detectChangedScope([scriptPath])).toEqual({ ...expected, runUiTests: false });
}
});
+1
View File
@@ -953,6 +953,7 @@ describe("scripts/changed-lanes", () => {
expect(shouldRunControlUiI18nVerify(result.paths)).toBe(true);
expect(plan.commands.map((command) => command.args[0])).toContain("lint:ui:i18n");
expect(shouldRunControlUiI18nVerify(["ui/config/control-ui-locales.ts"])).toBe(true);
expect(shouldRunControlUiI18nVerify(["scripts/lib/example.ts"])).toBe(false);
});
+18
View File
@@ -1214,12 +1214,30 @@ describe("ci workflow guards", () => {
expect(controlUiArtifactStep.run).toContain(
":(exclude)ui/src/i18n/.i18n/catalog-fallbacks.json",
);
expect(controlUiArtifactStep.run).toContain("ui/src/i18n/.i18n/${LOCALE}.tm.jsonl");
expect(controlUiArtifactStep.run).toContain("ui/src/i18n/.i18n/${LOCALE}.meta.json");
expect(controlUiArtifactStep.run).not.toContain("git add -A ui/src/i18n");
expect(controlUiAggregateStep.run).toBe(
"node --import tsx scripts/control-ui-i18n.ts sync --write",
);
const controlUiPublishStep = controlUiFinalize.steps.find(
(step: { name?: string }) => step.name === "Open or update generated locale PR",
);
expect(controlUiPublishStep.with["generated-paths"].trim().split("\n")).toEqual([
"ui/src/i18n/.i18n/*.tm.jsonl",
"ui/src/i18n/.i18n/*.meta.json",
"ui/src/i18n/.i18n/catalog-fallbacks.json",
]);
expect(controlUiPublishStep.with["invalidation-paths"]).toContain(
"scripts/lib/control-ui-i18n-catalog.ts",
);
expect(controlUiPublishStep.with["invalidation-paths"]).toContain(
"scripts/lib/control-ui-i18n-sync-plan.ts",
);
expect(controlUiPublishStep.with["invalidation-paths"]).toContain("ui/src/i18n/locales/*.ts");
expect(controlUiPublishStep.with["invalidation-paths"]).toContain(
"ui/src/i18n/locales/en-agents.ts",
);
expect(controlUiPublishStep.with["invalidation-paths"]).toContain(
"scripts/control-ui-i18n-verify.ts",
);
+49 -19
View File
@@ -1,4 +1,8 @@
import { describe, expect, it } from "vitest";
import {
hashControlUiTranslationText,
materializeControlUiLocaleCatalog,
} from "../../scripts/lib/control-ui-i18n-catalog.ts";
import {
createControlUiLocaleSyncPlan,
flattenTranslations,
@@ -121,23 +125,6 @@ describe("createControlUiLocaleSyncPlan", () => {
workflow: 1,
});
expect(artifacts.localeModule).toBe(
[
"// Generated locale bundle for Control UI translations.",
"// Run `pnpm ui:i18n:sync` instead of editing this file directly.",
'import type { TranslationMap } from "../lib/types.ts";',
"",
"export const fr: TranslationMap = {",
" group: {",
' cached: "En cache",',
' existing: "Existant",',
' pending: "Pending source",',
' reused: "Partage",',
" },",
"};",
"",
].join("\n"),
);
expect(artifacts.meta).toBe(
`${JSON.stringify(
{
@@ -158,19 +145,62 @@ describe("createControlUiLocaleSyncPlan", () => {
expect(artifacts.glossary).toBe(
`${JSON.stringify([{ source: "OpenClaw", target: "OpenClaw" }], null, 2)}\n`,
);
const clonedCache = {
const reusedCache = {
...sharedCache,
cache_key: cacheKeyFor("group.reused", hashText("Shared")),
segment_id: "group.reused",
};
expect(artifacts.translationMemory).toBe(
`${[clonedCache, exactCache, sharedCache]
`${[reusedCache, exactCache]
.toSorted((left, right) => left.cache_key.localeCompare(right.cache_key))
.map((value) => JSON.stringify(value))
.join("\n")}\n`,
);
});
it("reuses grouped segment aliases only while their source text still matches", () => {
const sourceFlat = flattenTranslations({ group: { alias: "Shared" } });
const grouped = memoryEntry({ segment_ids: ["group.alias"] });
const createPlan = (source: ReadonlyMap<string, string>) =>
createControlUiLocaleSyncPlan({
allowTranslate: false,
cacheKeyFor,
entry,
existingFlat: new Map(),
force: false,
hashText,
previousMeta: localeMeta(),
sourceFlat: source,
sourceHash: "source",
translationMemory: new Map([[grouped.cache_key, grouped]]),
});
expect(createPlan(sourceFlat).pending).toEqual([]);
expect(createPlan(new Map([["group.alias", "Changed"]])).pending).toHaveLength(1);
});
it("materializes grouped aliases in source order and discards stale or retired segments", () => {
const grouped = memoryEntry({
segment_id: "group.first",
segment_ids: ["group.second", "removed"],
text_hash: hashControlUiTranslationText("Shared"),
translated: "Partagé",
});
const source = flattenTranslations({ group: { first: "Shared", second: "Shared" } });
expect(
materializeControlUiLocaleCatalog(source, new Map([[grouped.cache_key, grouped]])),
).toEqual({
group: { first: "Partagé", second: "Partagé" },
});
expect(
materializeControlUiLocaleCatalog(
new Map([["group.first", "Changed"]]),
new Map([[grouped.cache_key, grouped]]),
),
).toEqual({});
});
it("refreshes recorded fallbacks and records translated replacements", () => {
const sourceFlat = flattenTranslations({ title: "New English" });
const previousMeta = localeMeta({
+42 -3
View File
@@ -40,7 +40,6 @@ describe("control-ui-i18n generated ownership", () => {
expect(() =>
assertControlUiGeneratedArtifactsIsolated([
"ui/src/i18n/locales/de.ts",
"ui/src/i18n/.i18n/catalog-fallbacks.json",
"ui/src/i18n/.i18n/de.meta.json",
"ui/src/i18n/.i18n/de.tm.jsonl",
@@ -52,7 +51,7 @@ describe("control-ui-i18n generated ownership", () => {
"ui/src/i18n/locales/de.ts",
"ui/src/i18n/.i18n/glossary.de.json",
]),
).toThrow("Control UI generated locale artifacts must be isolated from source changes");
).not.toThrow();
expect(() =>
assertControlUiGeneratedArtifactsIsolated([
@@ -81,11 +80,51 @@ describe("control-ui-i18n generated ownership", () => {
),
).not.toThrow();
expect(shouldStrictControlUiI18n(["ui/src/i18n/locales/de.ts"])).toBe(true);
expect(shouldStrictControlUiI18n(["ui/src/i18n/locales/de.ts"])).toBe(false);
expect(shouldStrictControlUiI18n(["ui/src/i18n/.i18n/de.tm.jsonl"])).toBe(true);
expect(shouldStrictControlUiI18n(["ui/src/i18n/locales/en.ts"])).toBe(false);
expect(shouldStrictControlUiI18n(null)).toBe(true);
});
it("allows only a complete canonical translation-memory ownership migration", () => {
const locales = readdirSync(path.resolve("ui/src/i18n/.i18n"))
.filter((fileName) => fileName.endsWith(".tm.jsonl"))
.map((fileName) => fileName.slice(0, -".tm.jsonl".length));
const owners = [
".gitattributes",
"scripts/ci-changed-scope.mjs",
"scripts/control-ui-i18n.ts",
"scripts/control-ui-i18n-verify.ts",
"scripts/lib/control-ui-i18n-catalog.ts",
"scripts/lib/control-ui-i18n-sync-plan.ts",
"ui/AGENTS.md",
"ui/config/control-ui-locales.ts",
"ui/vite.config.ts",
];
const adapters = locales.map((locale) => `ui/src/i18n/locales/${locale}.ts`);
const generated = [
"ui/src/i18n/.i18n/catalog-fallbacks.json",
...locales.flatMap((locale) => [
`ui/src/i18n/.i18n/${locale}.tm.jsonl`,
`ui/src/i18n/.i18n/${locale}.meta.json`,
]),
];
const migration = [...owners, ...adapters, ...generated];
expect(() => assertControlUiGeneratedArtifactsIsolated(migration)).not.toThrow();
expect(() => assertControlUiGeneratedArtifactsIsolated(migration.slice(1))).toThrow(
"Control UI generated locale artifacts must be isolated",
);
expect(() =>
assertControlUiGeneratedArtifactsIsolated(
migration.filter((filePath) => filePath !== generated[1]),
),
).toThrow("Control UI generated locale artifacts must be isolated");
expect(() =>
assertControlUiGeneratedArtifactsIsolated([...migration, "ui/src/i18n/.i18n/other.tm.jsonl"]),
).toThrow("Control UI generated locale artifacts must be isolated");
});
it("allows generated release output on trusted release and main runs only", () => {
const trustedActions = {
GITHUB_ACTIONS: "true",
+6 -3
View File
@@ -3864,16 +3864,19 @@ describe("scripts/test-projects changed-target routing", () => {
]);
});
it("routes changed ui build helpers to their importing tests", () => {
it.each([
["ui/config/control-ui-chunking.ts", "ui/src/app/control-ui-chunking.test.ts"],
["ui/config/control-ui-locales.ts", "ui/src/app/vite-config.node.test.ts"],
])("routes changed ui build helper %s to its owner test", (changedPath, testPath) => {
const plans = buildVitestRunPlans(["--changed", "origin/main"], process.cwd(), () => [
"ui/config/control-ui-chunking.ts",
changedPath,
]);
expect(plans).toEqual([
{
config: "test/vitest/vitest.ui.config.ts",
forwardedArgs: [],
includePatterns: ["ui/src/app/control-ui-chunking.test.ts"],
includePatterns: [testPath],
watchMode: false,
},
]);
+6 -1
View File
@@ -5,7 +5,12 @@
"noUnusedParameters": true,
"tsBuildInfoFile": ".artifacts/tsgo-cache/test-root.tsbuildinfo"
},
"include": ["../../test/**/*", "../../src/**/*.d.ts", "../../packages/**/*.d.ts"],
"include": [
"../../test/**/*",
"../../src/**/*.d.ts",
"../../packages/**/*.d.ts",
"../../ui/**/*.d.ts"
],
"exclude": [
"../../node_modules",
"../../dist",
+3 -1
View File
@@ -2,12 +2,13 @@
// The shared ui shard runs non-isolated for speed, but tests that spy on module
// internals and assert the component uses that spy must not share a module cache
// with stateful predecessor files (see uiIsolatedTestFiles).
import { controlUiLocaleModulesPlugin } from "../../ui/config/control-ui-locales.ts";
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
import { jsdomOptimizedDeps } from "./vitest.shared.config.ts";
import { uiIsolatedTestFiles } from "./vitest.ui-isolated-paths.mjs";
export function createUiIsolatedVitestConfig(env?: Record<string, string | undefined>) {
return createScopedVitestConfig(uiIsolatedTestFiles, {
const config = createScopedVitestConfig(uiIsolatedTestFiles, {
deps: jsdomOptimizedDeps,
environment: "jsdom",
env,
@@ -18,6 +19,7 @@ export function createUiIsolatedVitestConfig(env?: Record<string, string | undef
setupFiles: ["ui/src/test-helpers/lit-warnings.setup.ts"],
useNonIsolatedRunner: false,
});
return { ...config, plugins: [...(config.plugins ?? []), controlUiLocaleModulesPlugin()] };
}
export default createUiIsolatedVitestConfig();
+3 -1
View File
@@ -1,4 +1,5 @@
// Vitest ui config wires the ui test shard.
import { controlUiLocaleModulesPlugin } from "../../ui/config/control-ui-locales.ts";
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
import { jsdomOptimizedDeps } from "./vitest.shared.config.ts";
import { uiIsolatedTestFiles } from "./vitest.ui-isolated-paths.mjs";
@@ -10,7 +11,7 @@ export function createUiVitestConfig(
const includePatterns = options?.includePatterns ?? ["ui/src/**/*.test.ts"];
// Isolated files must never enter the shared module graph, including scoped runs.
const exclude = ["ui/src/**/*.e2e.test.ts", ...uiIsolatedTestFiles];
return createScopedVitestConfig(includePatterns, {
const config = createScopedVitestConfig(includePatterns, {
deps: jsdomOptimizedDeps,
environment: "jsdom",
env,
@@ -22,6 +23,7 @@ export function createUiVitestConfig(
setupFiles: ["ui/src/test-helpers/lit-warnings.setup.ts"],
useNonIsolatedRunner: true,
});
return { ...config, plugins: [...(config.plugins ?? []), controlUiLocaleModulesPlugin()] };
}
export default createUiVitestConfig();
-2
View File
@@ -25,7 +25,6 @@ const unitFastCandidateGlobs = [
"src/compat/**/*.test.ts",
"src/config/**/*.test.ts",
"src/daemon/**/*.test.ts",
"src/i18n/**/*.test.ts",
"src/hooks/**/*.test.ts",
"src/image-generation/**/*.test.ts",
"src/infra/**/*.test.ts",
@@ -104,7 +103,6 @@ export const forcedUnitFastTestFiles = [
"src/entry.test.ts",
"src/flows/doctor-startup-channel-maintenance.test.ts",
"src/flows/search-setup.test.ts",
"src/i18n/registry.test.ts",
"src/image-generation/openai-compatible-image-provider.test.ts",
"src/image-generation/provider-registry.test.ts",
"src/install-sh-version.test.ts",
+8 -5
View File
@@ -4,18 +4,21 @@ This directory owns Control UI-specific guidance that should not live in the rep
## i18n Rules
- Foreign-language locale bundles in `ui/src/i18n/locales/*.ts` are generated output.
- Do not hand-edit non-English locale bundles or `ui/src/i18n/.i18n/*` unless a targeted generated-output fix is explicitly requested.
- The source of truth is `ui/src/i18n/locales/en.ts` plus the generator/runtime wiring in:
- Foreign-language files in `ui/src/i18n/locales/*.ts` are stable, source-owned lazy-module adapters; their translations are generated from canonical grouped memory in `ui/src/i18n/.i18n/*.tm.jsonl`.
- Do not hand-edit translation memory, locale metadata, or fallback metadata unless a targeted generated-output fix is explicitly requested.
- The source of truth is `ui/src/i18n/locales/en.ts` and `en-agents.ts` plus the generator/runtime wiring in:
- `scripts/control-ui-i18n.ts`
- `scripts/lib/control-ui-i18n-catalog.ts`
- `scripts/lib/control-ui-i18n-sync-plan.ts`
- `ui/config/control-ui-locales.ts`
- `ui/src/i18n/lib/types.ts`
- `ui/src/i18n/lib/registry.ts`
- Contributor flow: update English strings and locale wiring, run keyless `pnpm ui:i18n:baseline`, and commit source files plus any changed raw-copy baseline. Do not include foreign bundles, catalog fallback metadata, locale metadata, or translation memory in a source PR; CI rejects mixed source/generated diffs outside canonical `release/YYYY.M.PATCH` branches.
- Contributor flow: update English strings and locale adapters/wiring, run keyless `pnpm ui:i18n:baseline`, and commit source files plus any changed raw-copy baseline. Do not include catalog fallback metadata, locale metadata, or translation memory in a source PR; CI rejects mixed source/generated diffs outside canonical `release/YYYY.M.PATCH` branches or an explicitly detected complete canonical-memory ownership migration.
- `pnpm ui:i18n:verify` is deterministic and keyless. `pnpm lint` and the changed-check UI lane run it. It validates English catalog shape, runtime locale wiring, and raw-copy baseline drift; foreign catalog parity belongs to the post-merge bot and strict generated-output gate.
- Translation flow: the serialized `control-ui-locale-refresh` workflow translates after merge, opens an isolated generated PR, and enables auto-merge for its exact head. `pnpm ui:i18n:sync` remains the authenticated maintainer/release repair path; do not run it without provider auth when new keys exist.
- `pnpm release:prep` runs the locale sync before release freeze, then `pnpm ui:i18n:check` remains the strict generated-output/release gate with zero fallbacks.
- Prioritization report: `pnpm ui:i18n:report [--surface <name>] [--locale <locale>] [--top <n>]` shows current hardcoded-copy focus areas and locale fallback metadata. It is not a drift gate; use `pnpm ui:i18n:check` for that.
- If locale outputs drift, let the workflow reconcile them or run release prep. Do not manually translate, merge, or hand-maintain generated locale files.
- If locale outputs drift, let the workflow reconcile them or run release prep. Do not manually translate, merge, or hand-maintain generated translation memory or locale metadata.
## CSS / Template Linting
+49
View File
@@ -0,0 +1,49 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Plugin } from "vite";
import {
loadControlUiTranslationMemory,
materializeControlUiLocaleCatalog,
} from "../../scripts/lib/control-ui-i18n-catalog.ts";
import { CONTROL_UI_LOCALE_ENTRIES } from "../../scripts/lib/control-ui-i18n-config.ts";
import { flattenTranslations } from "../../scripts/lib/control-ui-i18n-sync-plan.ts";
import { en } from "../src/i18n/locales/en.ts";
const localeModulePrefix = "virtual:openclaw-control-ui-locale/";
const resolvedLocaleModulePrefix = `\0${localeModulePrefix}`;
// Vitest rewrites new URL(relative, import.meta.url) to browser self.location.
const i18nAssetsDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../src/i18n/.i18n",
);
const locales = new Set(CONTROL_UI_LOCALE_ENTRIES.map(({ locale }) => locale));
export function controlUiLocaleModulesPlugin(): Plugin {
return {
name: "control-ui-locale-modules",
enforce: "pre",
resolveId(id) {
if (id.startsWith(localeModulePrefix) && locales.has(id.slice(localeModulePrefix.length))) {
return `\0${id}`;
}
return null;
},
load(id) {
if (!id.startsWith(resolvedLocaleModulePrefix)) {
return null;
}
const locale = id.slice(resolvedLocaleModulePrefix.length);
if (!locales.has(locale)) {
return null;
}
const memoryPath = path.join(i18nAssetsDir, `${locale}.tm.jsonl`);
this.addWatchFile(memoryPath);
const memory = loadControlUiTranslationMemory(memoryPath);
if (memory.size === 0) {
throw new Error(`Control UI ${locale} translation memory is missing or empty`);
}
const catalog = materializeControlUiLocaleCatalog(flattenTranslations(en), memory);
return `export default ${JSON.stringify(catalog)};`;
},
};
}
+27
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { brotliDecompressSync, gunzipSync } from "node:zlib";
import { describe, expect, it, vi } from "vitest";
import { controlUiLocaleModulesPlugin } from "../../config/control-ui-locales.ts";
import {
controlUiBrowserOnlySharedModuleAliases,
createControlUiPrecompressedAssetVariants,
@@ -378,4 +379,30 @@ describe("Control UI Vite config", () => {
expect(resolved).toBe(path.join(repoRoot, "ui/src/lib/browser-redact.ts"));
}
});
it("materializes lazy locale modules from their watched canonical translation memory", async () => {
const plugin = controlUiLocaleModulesPlugin();
const resolveHook = plugin.resolveId;
const resolveId = typeof resolveHook === "function" ? resolveHook : resolveHook?.handler;
const loadHook = plugin.load;
const load = typeof loadHook === "function" ? loadHook : loadHook?.handler;
if (!resolveId || !load) {
throw new Error("Expected locale module resolver and loader");
}
const id = "virtual:openclaw-control-ui-locale/fr";
const resolved = await resolveId.call({} as never, id, undefined, {} as never);
expect(resolved).toBe(`\0${id}`);
expect(
await resolveId.call({} as never, `${id}/../../secret`, undefined, {} as never),
).toBeNull();
const addWatchFile = vi.fn();
const result = await load.call({ addWatchFile } as never, resolved as string, {} as never);
if (typeof result !== "string") {
throw new Error("Expected locale module loader to return generated source");
}
const catalog = JSON.parse(result.replace(/^export default /, "").replace(/;$/, ""));
expect(catalog.common.health).toBe("Santé");
expect(addWatchFile).toHaveBeenCalledWith(path.join(repoRoot, "ui/src/i18n/.i18n/fr.tm.jsonl"));
});
});
@@ -1,6 +1,7 @@
// Control UI tests prove locale chunk recovery through a real browser reconnect.
import { chromium, type Browser, type BrowserContext, type Page, type Route } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { SUPPORTED_LOCALES } from "../i18n/lib/registry.ts";
import {
canRunPlaywrightChromium,
installMockGateway,
@@ -70,6 +71,38 @@ describeControlUiE2e("Control UI offline locale retry", () => {
await server?.close();
});
it("lazy-loads each registered locale only after the operator selects it", async () => {
const context = await createContext();
const page = await context.newPage();
await installMockGateway(page);
const requests = new Map<string, number>();
page.on("request", (request) => {
const match = new URL(request.url()).pathname.match(/\/src\/i18n\/locales\/([^/]+)\.ts$/);
if (match?.[1] && match[1] !== "en" && match[1] !== "en-agents") {
requests.set(match[1], (requests.get(match[1]) ?? 0) + 1);
}
});
try {
await page.goto(`${server.baseUrl}settings/appearance`);
const picker = page.locator("#settings-language wa-select");
await picker.waitFor();
expect(requests.size).toBe(0);
for (const locale of SUPPORTED_LOCALES.slice(1)) {
await picker.evaluate((element, value) => {
(element as HTMLElement & { value: string }).value = value;
element.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
}, locale);
await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe(locale);
expect(requests.get(locale)).toBe(1);
}
expect(requests.size).toBe(20);
} finally {
await context.close();
}
});
it("applies a locale whose first chunk request failed after the Gateway reconnects", async () => {
const context = await createContext();
const page = await context.newPage();
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:06:58.321Z",
"generatedAt": "2026-07-31T19:25:53.084Z",
"locale": "ar",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1748 -973
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
{
"fallbacks": {},
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"version": 1
}
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T10:57:42.373Z",
"generatedAt": "2026-07-31T19:22:38.653Z",
"locale": "de",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1802 -947
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:01:32.940Z",
"generatedAt": "2026-07-31T19:23:58.238Z",
"locale": "es",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1744 -996
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:17:55.240Z",
"generatedAt": "2026-07-31T19:29:46.799Z",
"locale": "fa",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1824 -945
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:01:51.965Z",
"generatedAt": "2026-07-31T19:24:20.628Z",
"locale": "fr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1697 -1025
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:07:00.115Z",
"generatedAt": "2026-07-31T19:25:52.284Z",
"locale": "hi",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1034 -1598
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:11:22.777Z",
"generatedAt": "2026-07-31T19:27:21.378Z",
"locale": "id",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1797 -982
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:06:11.260Z",
"generatedAt": "2026-07-31T19:26:03.104Z",
"locale": "it",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1830 -970
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:01:56.794Z",
"generatedAt": "2026-07-31T19:24:14.245Z",
"locale": "ja-JP",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1832 -904
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:02:21.725Z",
"generatedAt": "2026-07-31T19:24:20.209Z",
"locale": "ko",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1806 -946
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:16:09.967Z",
"generatedAt": "2026-07-31T19:29:23.630Z",
"locale": "nl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1723 -1000
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:11:50.449Z",
"generatedAt": "2026-07-31T19:27:46.530Z",
"locale": "pl",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1785 -990
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T10:57:24.690Z",
"generatedAt": "2026-07-31T19:22:13.129Z",
"locale": "pt-BR",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1810 -950
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:19:04.877Z",
"generatedAt": "2026-07-31T19:29:58.902Z",
"locale": "ru",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1034 -1600
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:14:47.751Z",
"generatedAt": "2026-07-31T19:28:01.318Z",
"locale": "th",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1743 -934
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:07:29.221Z",
"generatedAt": "2026-07-31T19:26:07.651Z",
"locale": "tr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1845 -948
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:11:13.574Z",
"generatedAt": "2026-07-31T19:27:41.486Z",
"locale": "uk",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1796 -978
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T11:16:20.202Z",
"generatedAt": "2026-07-31T19:29:53.873Z",
"locale": "vi",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1805 -958
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T10:57:10.307Z",
"generatedAt": "2026-07-31T19:22:31.396Z",
"locale": "zh-CN",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1799 -953
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-29T10:57:26.599Z",
"generatedAt": "2026-07-31T19:22:28.709Z",
"locale": "zh-TW",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
"totalKeys": 4588,
"translatedKeys": 4588,
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
"totalKeys": 4652,
"translatedKeys": 4652,
"workflow": 1
}
+1797 -953
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -11,11 +11,11 @@ import { en } from "./locales/en.ts";
// schedule {expr}", cron expression help) intentionally keep the word cron
// and are excluded: that is syntax terminology, not the feature name.
//
// Non-English catalogs are generated: CI forbids committing locale bundles
// Non-English catalogs are generated: CI forbids committing translation memory
// alongside source changes, and the post-merge control-ui-locale-refresh
// workflow retranslates renamed keys from this English source. These
// assertions therefore pin the English source of truth; the workflow-owned
// bundles inherit them on refresh.
// catalogs inherit them on refresh.
const RENAMED_FEATURE_KEYS = [
"sessionsView.showCronSessions",
"sessionsView.subagentPrefix",
+31 -1
View File
@@ -1,7 +1,12 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { resolveNavigatorLocale } from "./registry.ts";
import {
DEFAULT_LOCALE,
loadLazyLocaleTranslation,
resolveNavigatorLocale,
SUPPORTED_LOCALES,
} from "./registry.ts";
describe("resolveNavigatorLocale", () => {
it.each([
@@ -49,3 +54,28 @@ describe("resolveNavigatorLocale", () => {
expect(resolveNavigatorLocale(browserLanguage)).toBe(expectedLocale);
});
});
describe("lazy locale registry", () => {
it("keeps English as the default and materializes every registered foreign catalog", async () => {
expect(DEFAULT_LOCALE).toBe("en");
expect(SUPPORTED_LOCALES).toHaveLength(21);
expect(await loadLazyLocaleTranslation("en")).toBeNull();
const catalogs = await Promise.all(
SUPPORTED_LOCALES.slice(1).map(
async (locale) => [locale, await loadLazyLocaleTranslation(locale)] as const,
),
);
for (const [locale, catalog] of catalogs) {
expect(catalog?.common, locale).toHaveProperty("health");
}
const byLocale = Object.fromEntries(catalogs);
expect(byLocale.de).toMatchObject({ common: { health: "Status" } });
expect(byLocale.es).toMatchObject({ languages: { de: "Deutsch (Alemán)" } });
expect(byLocale["pt-BR"]).toMatchObject({ languages: { es: "Español (Espanhol)" } });
expect(byLocale["zh-CN"]).toMatchObject({ common: { health: "健康状况" } });
expect(byLocale.hi).toMatchObject({ languages: { en: "English (अंग्रेज़ी)" } });
expect(byLocale.th).toMatchObject({ languages: { en: "อังกฤษ" } });
expect(byLocale.ru).toMatchObject({ languages: { en: "Английский" } });
});
});
+27 -144
View File
@@ -4,11 +4,6 @@ import type { Locale, TranslationMap } from "./types.ts";
type LazyLocale = Exclude<Locale, "en">;
type LocaleModule = Record<string, TranslationMap>;
type LazyLocaleRegistration = {
exportName: string;
loader: () => Promise<LocaleModule>;
};
export const DEFAULT_LOCALE: Locale = "en";
const LAZY_LOCALES: readonly LazyLocale[] = [
@@ -34,87 +29,27 @@ const LAZY_LOCALES: readonly LazyLocale[] = [
"ru",
];
const LAZY_LOCALE_REGISTRY: Record<LazyLocale, LazyLocaleRegistration> = {
"zh-CN": {
exportName: "zh_CN",
loader: () => import("../locales/zh-CN.ts"),
},
"zh-TW": {
exportName: "zh_TW",
loader: () => import("../locales/zh-TW.ts"),
},
"pt-BR": {
exportName: "pt_BR",
loader: () => import("../locales/pt-BR.ts"),
},
de: {
exportName: "de",
loader: () => import("../locales/de.ts"),
},
es: {
exportName: "es",
loader: () => import("../locales/es.ts"),
},
"ja-JP": {
exportName: "ja_JP",
loader: () => import("../locales/ja-JP.ts"),
},
ko: {
exportName: "ko",
loader: () => import("../locales/ko.ts"),
},
fr: {
exportName: "fr",
loader: () => import("../locales/fr.ts"),
},
hi: {
exportName: "hi",
loader: () => import("../locales/hi.ts"),
},
ar: {
exportName: "ar",
loader: () => import("../locales/ar.ts"),
},
it: {
exportName: "it",
loader: () => import("../locales/it.ts"),
},
tr: {
exportName: "tr",
loader: () => import("../locales/tr.ts"),
},
uk: {
exportName: "uk",
loader: () => import("../locales/uk.ts"),
},
id: {
exportName: "id",
loader: () => import("../locales/id.ts"),
},
pl: {
exportName: "pl",
loader: () => import("../locales/pl.ts"),
},
th: {
exportName: "th",
loader: () => import("../locales/th.ts"),
},
vi: {
exportName: "vi",
loader: () => import("../locales/vi.ts"),
},
nl: {
exportName: "nl",
loader: () => import("../locales/nl.ts"),
},
fa: {
exportName: "fa",
loader: () => import("../locales/fa.ts"),
},
ru: {
exportName: "ru",
loader: () => import("../locales/ru.ts"),
},
const LAZY_LOCALE_REGISTRY: Record<LazyLocale, () => Promise<LocaleModule>> = {
"zh-CN": () => import("../locales/zh-CN.ts"),
"zh-TW": () => import("../locales/zh-TW.ts"),
"pt-BR": () => import("../locales/pt-BR.ts"),
de: () => import("../locales/de.ts"),
es: () => import("../locales/es.ts"),
"ja-JP": () => import("../locales/ja-JP.ts"),
ko: () => import("../locales/ko.ts"),
fr: () => import("../locales/fr.ts"),
hi: () => import("../locales/hi.ts"),
ar: () => import("../locales/ar.ts"),
it: () => import("../locales/it.ts"),
tr: () => import("../locales/tr.ts"),
uk: () => import("../locales/uk.ts"),
id: () => import("../locales/id.ts"),
pl: () => import("../locales/pl.ts"),
th: () => import("../locales/th.ts"),
vi: () => import("../locales/vi.ts"),
nl: () => import("../locales/nl.ts"),
fa: () => import("../locales/fa.ts"),
ru: () => import("../locales/ru.ts"),
};
export const SUPPORTED_LOCALES: ReadonlyArray<Locale> = [DEFAULT_LOCALE, ...LAZY_LOCALES];
@@ -141,68 +76,16 @@ export function resolveNavigatorLocale(browserLanguage: string): Locale {
? "zh-TW"
: "zh-CN";
}
if (navLang.startsWith("pt")) {
return "pt-BR";
}
if (navLang.startsWith("de")) {
return "de";
}
if (navLang.startsWith("es")) {
return "es";
}
if (navLang.startsWith("ja")) {
return "ja-JP";
}
if (navLang.startsWith("ko")) {
return "ko";
}
if (navLang.startsWith("fr")) {
return "fr";
}
if (navLang.startsWith("hi")) {
return "hi";
}
if (navLang.startsWith("ar")) {
return "ar";
}
if (navLang.startsWith("it")) {
return "it";
}
if (navLang.startsWith("tr")) {
return "tr";
}
if (navLang.startsWith("uk")) {
return "uk";
}
if (navLang.startsWith("id")) {
return "id";
}
if (navLang.startsWith("pl")) {
return "pl";
}
if (navLang.startsWith("th")) {
return "th";
}
if (navLang.startsWith("vi")) {
return "vi";
}
if (navLang.startsWith("nl")) {
return "nl";
}
if (navLang.startsWith("fa")) {
return "fa";
}
if (navLang.startsWith("ru")) {
return "ru";
}
return DEFAULT_LOCALE;
return (
LAZY_LOCALES.find((locale) => navLang.startsWith(locale.split("-")[0]!.toLowerCase())) ??
DEFAULT_LOCALE
);
}
export async function loadLazyLocaleTranslation(locale: Locale): Promise<TranslationMap | null> {
if (!isLazyLocale(locale)) {
return null;
}
const registration = LAZY_LOCALE_REGISTRY[locale];
const module = await registration.loader();
return module[registration.exportName] ?? null;
const module = await LAZY_LOCALE_REGISTRY[locale]();
return module[locale.replaceAll("-", "_")] ?? null;
}
+1 -5624
View File
File diff suppressed because it is too large Load Diff
+1 -5785
View File
File diff suppressed because it is too large Load Diff
+1 -5777
View File
File diff suppressed because it is too large Load Diff
+1 -5677
View File
File diff suppressed because it is too large Load Diff
+1 -5819
View File
File diff suppressed because it is too large Load Diff
+1 -5622
View File
File diff suppressed because it is too large Load Diff
+1 -5693
View File
File diff suppressed because it is too large Load Diff
+1 -5779
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -5655
View File
File diff suppressed because it is too large Load Diff
+1 -5740
View File
File diff suppressed because it is too large Load Diff
+1 -5750
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -5743
View File
File diff suppressed because it is too large Load Diff
+1 -5579
View File
File diff suppressed because it is too large Load Diff
+1 -5723
View File
File diff suppressed because it is too large Load Diff
+1 -5723
View File
File diff suppressed because it is too large Load Diff
+1 -5690
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
declare module "virtual:openclaw-control-ui-locale/*" {
const translations: import("./lib/types.ts").TranslationMap;
export default translations;
}
+17 -8
View File
@@ -10,12 +10,6 @@ import type { Locator, Page } from "playwright";
import type { ViteDevServer } from "vite";
import { PROTOCOL_VERSION } from "../../../packages/gateway-protocol/src/version.js";
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../../../src/gateway/control-ui-contract.js";
import {
controlUiBrowserOnlySharedModuleAliases,
resolveExternalPackageAliasesForVite,
resolveSourcePackageAliasesForVite,
resolveTsconfigPathAliasesForVite,
} from "../../vite.config.ts";
import type { ControlUiBuildInfo } from "../build-info.ts";
export function controlUiSessionPath(sessionKey: string, basePath = ""): string {
@@ -312,7 +306,22 @@ export async function startControlUiE2eServer(
buildId: "e2e",
},
): Promise<ControlUiE2eServer> {
const { createServer } = await import("vite");
// Shared browser fixtures import this helper; load filesystem-bound Vite
// configuration only when its Node-owned development server actually starts.
const [
{ createServer },
{ controlUiLocaleModulesPlugin },
{
controlUiBrowserOnlySharedModuleAliases,
resolveExternalPackageAliasesForVite,
resolveSourcePackageAliasesForVite,
resolveTsconfigPathAliasesForVite,
},
] = await Promise.all([
import("vite"),
import("../../config/control-ui-locales.ts"),
import("../../vite.config.ts"),
]);
const repoRoot = resolveRepoRoot();
const uiRoot = path.join(repoRoot, "ui");
const port = await resolveAvailableLoopbackPort();
@@ -334,7 +343,7 @@ export async function startControlUiE2eServer(
],
},
publicDir: path.join(uiRoot, "public"),
plugins: [controlUiBrowserOnlySharedModuleAliases()],
plugins: [controlUiLocaleModulesPlugin(), controlUiBrowserOnlySharedModuleAliases()],
resolve: {
alias: [
{ find: "json5", replacement: json5EsmPath },
+2
View File
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
import { brotliCompressSync, constants as zlibConstants, gzipSync } from "node:zlib";
import type { Plugin, UserConfig } from "vite";
import { controlUiCodeSplitting } from "./config/control-ui-chunking.ts";
import { controlUiLocaleModulesPlugin } from "./config/control-ui-locales.ts";
import { normalizeControlUiBuildInfo } from "./src/build-info-normalizers.ts";
import type { ControlUiBuildInfo } from "./src/build-info.ts";
@@ -469,6 +470,7 @@ export default function controlUiViteConfig(): UserConfig {
strictPort: true,
},
plugins: [
controlUiLocaleModulesPlugin(),
controlUiBrowserOnlySharedModuleAliases(),
controlUiPrecompressedAssetsPlugin(),
controlUiServiceWorkerBuildIdPlugin(buildInfo.buildId),
+5
View File
@@ -11,6 +11,7 @@ import {
resolveDefaultVitestPool,
} from "../test/vitest/vitest.shared.config.ts";
import { uiIsolatedTestFiles } from "../test/vitest/vitest.ui-isolated-paths.mjs";
import { controlUiLocaleModulesPlugin } from "./config/control-ui-locales.ts";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
@@ -140,6 +141,7 @@ export default defineConfig({
...sharedUiTestConfig,
projects: [
defineProject({
plugins: [controlUiLocaleModulesPlugin()],
resolve: {
alias: workspaceSourceAliases,
},
@@ -159,6 +161,7 @@ export default defineConfig({
},
}),
defineProject({
plugins: [controlUiLocaleModulesPlugin()],
resolve: {
alias: workspaceSourceAliases,
},
@@ -175,6 +178,7 @@ export default defineConfig({
},
}),
defineProject({
plugins: [controlUiLocaleModulesPlugin()],
resolve: {
alias: workspaceSourceAliases,
},
@@ -188,6 +192,7 @@ export default defineConfig({
},
}),
defineProject({
plugins: [controlUiLocaleModulesPlugin()],
resolve: {
alias: workspaceSourceAliases,
},
+2
View File
@@ -1,9 +1,11 @@
// Control UI config module wires vitest behavior.
import { defineConfig } from "vitest/config";
import { resolveDefaultVitestPool } from "../test/vitest/vitest.shared.config.ts";
import { controlUiLocaleModulesPlugin } from "./config/control-ui-locales.ts";
// Node-only tests for pure logic (no Playwright/browser dependency).
export default defineConfig({
plugins: [controlUiLocaleModulesPlugin()],
test: {
isolate: false,
pool: resolveDefaultVitestPool(),