improve(ui): catch i18n catalog drift locally (#107253)

* ci(ui): verify i18n catalogs locally

* fix(ui): preserve scoped i18n sync errors

* refactor(ui): share i18n raw-copy verifier

* test(ui): cover i18n lint gate

* fix(ui): route lint threads to oxlint
This commit is contained in:
Peter Steinberger
2026-07-14 01:12:36 -07:00
committed by GitHub
parent 3d15bf513e
commit 47d37804a8
17 changed files with 1022 additions and 384 deletions
@@ -11,6 +11,9 @@ on:
- ui/src/i18n/lib/types.ts
- ui/src/i18n/lib/registry.ts
- scripts/control-ui-i18n.ts
- scripts/control-ui-i18n-verify.ts
- scripts/lib/control-ui-i18n-config.ts
- scripts/lib/control-ui-i18n-raw-copy.ts
- .github/actions/create-generated-pr-tokens/action.yml
- .github/actions/publish-generated-pr/action.yml
- .github/workflows/control-ui-locale-refresh.yml
@@ -227,7 +230,7 @@ jobs:
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 > "${artifact_dir}/${LOCALE}.patch"
git diff --cached --binary --full-index -- ui/src/i18n ':(exclude)ui/src/i18n/.i18n/catalog-fallbacks.json' > "${artifact_dir}/${LOCALE}.patch"
- name: Upload locale artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -301,6 +304,9 @@ jobs:
ui/src/i18n/lib/types.ts
ui/src/i18n/lib/registry.ts
scripts/control-ui-i18n.ts
scripts/control-ui-i18n-verify.ts
scripts/lib/control-ui-i18n-config.ts
scripts/lib/control-ui-i18n-raw-copy.ts
.github/actions/create-generated-pr-tokens/action.yml
.github/actions/publish-generated-pr/action.yml
.github/workflows/control-ui-locale-refresh.yml
+4 -1
View File
@@ -1669,7 +1669,7 @@
"ios:version:check": "node --import tsx scripts/ios-sync-versioning.ts --check",
"ios:version:sync": "node --import tsx scripts/ios-sync-versioning.ts --write",
"leak:embedded-run": "node --import tsx --expose-gc scripts/embedded-run-abort-leak.ts",
"lint": "node scripts/run-oxlint-shards.mjs",
"lint": "pnpm lint:ui:i18n && node scripts/run-oxlint-shards.mjs",
"lint:agent:ingress-owner": "node scripts/check-ingress-agent-owner-context.mjs",
"lint:all": "node scripts/run-oxlint.mjs",
"lint:apps": "pnpm lint:swift",
@@ -1707,6 +1707,7 @@
"lint:tmp:session-transcript-reader-boundary": "node scripts/check-session-transcript-reader-boundary.mjs",
"lint:tmp:sqlite-transaction-boundary": "node scripts/check-sqlite-transaction-boundary.mjs",
"lint:tmp:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs",
"lint:ui:i18n": "pnpm ui:i18n:verify",
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
"lint:web-fetch-provider-boundaries": "node scripts/check-web-fetch-provider-boundaries.mjs",
"lint:web-search-provider-boundaries": "node scripts/check-web-search-provider-boundaries.mjs",
@@ -1999,9 +2000,11 @@
"tui:pty:test:watch:local": "node --import tsx scripts/dev/tui-pty-test-watch.ts --mode local",
"ui:build": "node scripts/ui.js build && node scripts/check-control-ui-precompressed-assets.mjs && node scripts/check-control-ui-performance.mjs",
"ui:dev": "node scripts/ui.js dev",
"ui:i18n:baseline": "node --import tsx scripts/control-ui-i18n-verify.ts baseline",
"ui:i18n:check": "node --import tsx scripts/control-ui-i18n.ts check",
"ui:i18n:report": "node --import tsx scripts/control-ui-i18n-report.ts",
"ui:i18n:sync": "node --import tsx scripts/control-ui-i18n.ts sync --write",
"ui:i18n:verify": "node --import tsx scripts/control-ui-i18n-verify.ts verify",
"native:i18n:check": "node --import tsx scripts/native-app-i18n.ts check",
"native:i18n:sync": "node --import tsx scripts/native-app-i18n.ts sync --write",
"android:i18n:check": "node --import tsx scripts/android-app-i18n.ts check",
+1
View File
@@ -41,6 +41,7 @@ export function buildChangedCheckCrabboxArgs(argv?: string[], options?: { cwd?:
export function shouldRunShrinkwrapGuard(paths: string[]): boolean;
export function shouldRunPromptSnapshotCheck(paths: string[]): boolean;
export function shouldRunPromptSnapshotOwnerTest(paths: string[]): boolean;
export function shouldRunControlUiI18nVerify(paths: string[]): boolean;
export function shouldRunRuntimeSidecarBaselineCheck(paths: string[]): boolean;
export function shouldRunSqliteSessionSchemaBaselineCheck(paths: string[]): boolean;
export function shouldRunPluginSdkApiBaselineCheck(paths: string[]): boolean;
+12
View File
@@ -45,6 +45,8 @@ const PLUGIN_SDK_SURFACE_PATH_RE =
/^(?:package\.json$|src\/plugin-sdk\/|scripts\/(?:plugin-sdk-surface-report\.mjs|sync-plugin-sdk-exports\.mjs|lib\/plugin-sdk-(?:declaration-budget\.mjs|deprecated-barrel-subpaths\.json|deprecated-public-subpaths\.json|entries\.mjs|entrypoints\.json|private-local-only-subpaths\.json)))/u;
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;
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";
@@ -245,6 +247,12 @@ export function shouldRunPromptSnapshotOwnerTest(paths) {
return paths.some((changedPath) => PROMPT_SNAPSHOT_OWNER_TEST_PATH_RE.test(changedPath));
}
export function shouldRunControlUiI18nVerify(paths) {
return paths.some((changedPath) =>
CONTROL_UI_I18N_VERIFY_PATH_RE.test(normalizeChangedPath(changedPath)),
);
}
export function shouldRunRuntimeSidecarBaselineCheck(paths) {
return paths.some((changedPath) => RUNTIME_SIDECAR_BASELINE_PATH_RE.test(changedPath));
}
@@ -486,6 +494,10 @@ export function createChangedCheckPlan(result, options = {}) {
};
}
if (shouldRunControlUiI18nVerify(result.paths)) {
addLint("Control UI i18n catalog", ["lint:ui:i18n"]);
}
if (lanes.core) {
addTypecheck("typecheck core", ["tsgo:core"]);
}
+1 -1
View File
@@ -56,7 +56,7 @@ 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\.ts$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/;
/^(ui\/src\/i18n\/|scripts\/(?:control-ui-i18n(?:-verify)?\.ts|lib\/control-ui-i18n-(?:config|raw-copy)\.ts)$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/;
const CONTROL_UI_TEST_SCOPE_RE =
/^(ui\/|test\/vitest\/vitest\.shared\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$)/;
const NATIVE_I18N_SCOPE_RE =
+297
View File
@@ -0,0 +1,297 @@
import { createHash } from "node:crypto";
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 { 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";
export type CatalogFallbackBaseline = {
fallbacks: Record<string, string[]>;
sourceHash: string;
version: number;
};
function fallbackPairs(baseline: CatalogFallbackBaseline): Set<string> {
return new Set(
Object.entries(baseline.fallbacks).flatMap(([key, locales]) =>
locales.map((locale) => `${key}\u0000${locale}`),
),
);
}
export function assertScopedCatalogFallbackUpdate(
current: CatalogFallbackBaseline,
next: CatalogFallbackBaseline,
resolvedLocale: string,
) {
if (current.version !== next.version || current.sourceHash !== next.sourceHash) {
throw new Error("scoped locale sync cannot update a stale catalog fallback baseline");
}
const currentPairs = fallbackPairs(current);
const nextPairs = fallbackPairs(next);
const added = [...nextPairs].filter((pair) => !currentPairs.has(pair));
const unrelatedRemovals = [...currentPairs].filter(
(pair) => !nextPairs.has(pair) && !pair.endsWith(`\u0000${resolvedLocale}`),
);
if (added.length > 0 || unrelatedRemovals.length > 0) {
throw new Error(
`scoped locale sync for ${resolvedLocale} found unrelated catalog fallback drift; run pnpm ui:i18n:baseline first`,
);
}
}
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const LOCALES_DIR = path.join(ROOT, "ui", "src", "i18n", "locales");
const I18N_ASSETS_DIR = path.join(ROOT, "ui", "src", "i18n", ".i18n");
const SOURCE_LOCALE_PATH = path.join(LOCALES_DIR, "en.ts");
const FALLBACK_BASELINE_PATH = path.join(I18N_ASSETS_DIR, "catalog-fallbacks.json");
const FALLBACK_BASELINE_VERSION = 1;
function compareStringArrays(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function toRepoPath(filePath: string): string {
return path.relative(ROOT, filePath).split(path.sep).join("/");
}
async function importLocaleModule<T>(filePath: string): Promise<T> {
const stats = await stat(filePath);
return (await import(`${pathToFileURL(filePath).href}?ts=${stats.mtimeMs}`)) as T;
}
async function loadLocaleMap(filePath: string, exportName: string): Promise<TranslationMap | null> {
if (!existsSync(filePath)) {
return null;
}
const mod = await importLocaleModule<Record<string, TranslationMap>>(filePath);
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)
.toSorted((left, right) => left.localeCompare(right));
}
export function flattenControlUiCatalog(
value: unknown,
label: string,
prefix = "",
out = new Map<string, string>(),
): Map<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label}${prefix ? `:${prefix}` : ""} must be an object`);
}
for (const [key, nested] of Object.entries(value)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof nested === "string") {
out.set(fullKey, nested);
} else if (!nested || typeof nested !== "object" || Array.isArray(nested)) {
throw new Error(`${label}:${fullKey} must be a string or object`);
} else {
flattenControlUiCatalog(nested, label, fullKey, out);
}
}
return out;
}
export function analyzeControlUiCatalogs(
sourceFlat: ReadonlyMap<string, string>,
localeFlats: ReadonlyMap<string, ReadonlyMap<string, string>>,
): { errors: string[]; fallbacks: Record<string, string[]> } {
const errors: string[] = [];
const sourceKeys = [...sourceFlat.keys()];
const sourceKeySet = new Set(sourceKeys);
const fallbackLocalesByKey = new Map<string, string[]>();
for (const [locale, localeFlat] of [...localeFlats.entries()].toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
const localeKeys = [...localeFlat.keys()];
const orphanKeys = localeKeys.filter((key) => !sourceKeySet.has(key));
if (orphanKeys.length > 0) {
errors.push(`${locale}: orphan keys: ${orphanKeys.join(", ")}`);
}
const expectedPresentOrder = sourceKeys.filter((key) => localeFlat.has(key));
const actualPresentOrder = localeKeys.filter((key) => sourceKeySet.has(key));
if (!compareStringArrays(actualPresentOrder, expectedPresentOrder)) {
errors.push(`${locale}: keys are not in English catalog order`);
}
for (const key of sourceKeys) {
const translated = localeFlat.get(key);
if (translated === undefined) {
const locales = fallbackLocalesByKey.get(key) ?? [];
locales.push(locale);
fallbackLocalesByKey.set(key, locales);
continue;
}
const sourcePlaceholders = extractPlaceholders(sourceFlat.get(key) ?? "");
const translatedPlaceholders = extractPlaceholders(translated);
if (!compareStringArrays(sourcePlaceholders, translatedPlaceholders)) {
errors.push(
`${locale}:${key} expected {${sourcePlaceholders.join("},{")}} got {${translatedPlaceholders.join("},{")}}`,
);
}
}
}
const fallbacks: Record<string, string[]> = {};
for (const [key, locales] of [...fallbackLocalesByKey.entries()].toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
fallbacks[key] = locales.toSorted((left, right) => left.localeCompare(right));
}
return { errors, fallbacks };
}
async function buildCatalogFallbackBaseline(): Promise<CatalogFallbackBaseline> {
const sourceRaw = await readFile(SOURCE_LOCALE_PATH, "utf8");
const sourceMap = await loadLocaleMap(SOURCE_LOCALE_PATH, "en");
if (!sourceMap) {
throw new Error("ui/src/i18n/locales/en.ts does not export en");
}
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}`);
}
localeFlats.set(entry.locale, flattenControlUiCatalog(localeMap, entry.locale));
}
const analysis = analyzeControlUiCatalogs(sourceFlat, localeFlats);
if (analysis.errors.length > 0) {
throw new Error(
[
"control-ui catalog verification failed.",
analysis.errors.slice(0, 50).join("\n"),
analysis.errors.length > 50 ? `...and ${analysis.errors.length - 50} more` : "",
]
.filter(Boolean)
.join("\n"),
);
}
return {
fallbacks: analysis.fallbacks,
sourceHash: createHash("sha256").update(sourceRaw).digest("hex"),
version: FALLBACK_BASELINE_VERSION,
};
}
export async function syncControlUiCatalogFallbackBaseline(options: {
checkOnly: boolean;
resolvedLocale?: string;
write: boolean;
}) {
const baseline = await buildCatalogFallbackBaseline();
const expected = `${JSON.stringify(baseline, null, 2)}\n`;
const current = existsSync(FALLBACK_BASELINE_PATH)
? await readFile(FALLBACK_BASELINE_PATH, "utf8")
: "";
if (!options.checkOnly && options.write && current !== expected) {
if (options.resolvedLocale) {
let currentBaseline: CatalogFallbackBaseline;
try {
currentBaseline = JSON.parse(current) as CatalogFallbackBaseline;
assertScopedCatalogFallbackUpdate(currentBaseline, baseline, options.resolvedLocale);
} catch (error) {
throw new Error(
`cannot refresh catalog fallback metadata after scoped locale sync: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
await mkdir(I18N_ASSETS_DIR, { recursive: true });
await writeFile(FALLBACK_BASELINE_PATH, expected, "utf8");
}
if (options.checkOnly && current !== expected) {
throw new Error(
[
"control-ui catalog fallback baseline drift detected.",
`Run \`pnpm ui:i18n:baseline\` and commit ${toRepoPath(FALLBACK_BASELINE_PATH)}.`,
].join("\n"),
);
}
const fallbackPairCount = Object.values(baseline.fallbacks).reduce(
(total, locales) => total + locales.length,
0,
);
process.stdout.write(
`control-ui-i18n: catalog: fallback_keys=${Object.keys(baseline.fallbacks).length} fallback_pairs=${fallbackPairCount}\n`,
);
}
export async function verifyRuntimeLocaleConfig() {
const registryRaw = await readFile(
path.join(ROOT, "ui", "src", "i18n", "lib", "registry.ts"),
"utf8",
);
const typesRaw = await readFile(path.join(ROOT, "ui", "src", "i18n", "lib", "types.ts"), "utf8");
for (const entry of CONTROL_UI_LOCALE_ENTRIES) {
if (!registryRaw.includes(`"${entry.locale}"`) || !typesRaw.includes(`| "${entry.locale}"`)) {
throw new Error(`runtime locale config is missing ${entry.locale}`);
}
}
const enMap = (await loadLocaleMap(SOURCE_LOCALE_PATH, "en")) ?? {};
const languageMap = enMap.languages;
const languageKeys =
languageMap && typeof languageMap === "object"
? Object.keys(languageMap).toSorted((left, right) => left.localeCompare(right))
: [];
const expectedLanguageKeys = [
"en",
...CONTROL_UI_LOCALE_ENTRIES.map((entry) => entry.languageKey),
].toSorted((left, right) => left.localeCompare(right));
if (!compareStringArrays(languageKeys, expectedLanguageKeys)) {
throw new Error(
`ui/src/i18n/locales/en.ts languages block is out of sync: expected ${expectedLanguageKeys.join(", ")}, got ${languageKeys.join(", ")}`,
);
}
}
export async function verifyControlUiCatalogs(options: { checkOnly: boolean; write: boolean }) {
await verifyRuntimeLocaleConfig();
await syncControlUiRawCopyBaseline(options);
await syncControlUiCatalogFallbackBaseline(options);
}
function usage(): never {
console.error("Usage: node --import tsx scripts/control-ui-i18n-verify.ts <verify|baseline>");
process.exit(2);
}
async function main() {
const [command, ...rest] = process.argv.slice(2);
if ((command !== "verify" && command !== "baseline") || rest.length > 0) {
usage();
}
await verifyControlUiCatalogs({
checkOnly: command === "verify",
write: command === "baseline",
});
}
function isCliEntrypoint() {
const entrypoint = process.argv[1];
return Boolean(entrypoint && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href);
}
if (isCliEntrypoint()) {
await main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}
+32 -367
View File
@@ -2,14 +2,20 @@
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { completeSimple, type AssistantMessage, type Model } from "openclaw/plugin-sdk/llm";
import * as ts from "typescript";
import { expectDefined } from "../packages/normalization-core/src/expect.js";
import { formatErrorMessage } from "../src/infra/errors.ts";
import { formatDurationCompact } from "../src/infra/format-time/format-duration.ts";
import {
syncControlUiCatalogFallbackBaseline,
verifyControlUiCatalogs,
verifyRuntimeLocaleConfig,
} from "./control-ui-i18n-verify.ts";
import { CONTROL_UI_LOCALE_ENTRIES } from "./lib/control-ui-i18n-config.ts";
import { syncControlUiRawCopyBaseline } from "./lib/control-ui-i18n-raw-copy.ts";
import {
compareStringArrays,
createControlUiLocaleSyncPlan,
@@ -45,27 +51,6 @@ type RunProcessParentSignalState = {
signal: NodeJS.Signals | null;
};
type RawCopyFinding = {
kind: "html-attribute" | "html-text" | "object-property";
line: number;
name: string;
path: string;
text: string;
};
type RawCopyBaselineEntry = {
count: number;
kind: RawCopyFinding["kind"];
name: string;
path: string;
text: string;
};
type RawCopyBaseline = {
version: number;
entries: RawCopyBaselineEntry[];
};
const CONTROL_UI_I18N_WORKFLOW = 1;
const DEFAULT_OPENAI_MODEL = "gpt-5.6-sol";
const DEFAULT_ANTHROPIC_MODEL = "claude-opus-4-6";
@@ -76,14 +61,6 @@ const LOCALES_DIR = path.join(ROOT, "ui", "src", "i18n", "locales");
const I18N_ASSETS_DIR = path.join(ROOT, "ui", "src", "i18n", ".i18n");
const SOURCE_LOCALE_PATH = path.join(LOCALES_DIR, "en.ts");
const SOURCE_LOCALE = "en";
const CONTROL_UI_RAW_COPY_SOURCE_DIRS = [
path.join(ROOT, "ui", "src", "app"),
path.join(ROOT, "ui", "src", "components"),
path.join(ROOT, "ui", "src", "lib"),
path.join(ROOT, "ui", "src", "pages"),
] as const;
const RAW_COPY_BASELINE_PATH = path.join(I18N_ASSETS_DIR, "raw-copy-baseline.json");
const RAW_COPY_BASELINE_VERSION = 1;
const MAX_BATCH_ITEMS = 20;
const DEFAULT_BATCH_CHAR_BUDGET = 2_000;
const TRANSLATE_MAX_ATTEMPTS = 2;
@@ -126,28 +103,7 @@ const TRANSLATION_PROVIDER_DEFAULTS: Record<TranslationProvider, Omit<Model, "id
},
};
const LOCALE_ENTRIES: readonly LocaleEntry[] = [
{ locale: "zh-CN", fileName: "zh-CN.ts", exportName: "zh_CN", languageKey: "zhCN" },
{ locale: "zh-TW", fileName: "zh-TW.ts", exportName: "zh_TW", languageKey: "zhTW" },
{ locale: "pt-BR", fileName: "pt-BR.ts", exportName: "pt_BR", languageKey: "ptBR" },
{ locale: "de", fileName: "de.ts", exportName: "de", languageKey: "de" },
{ locale: "es", fileName: "es.ts", exportName: "es", languageKey: "es" },
{ locale: "ja-JP", fileName: "ja-JP.ts", exportName: "ja_JP", languageKey: "jaJP" },
{ locale: "ko", fileName: "ko.ts", exportName: "ko", languageKey: "ko" },
{ locale: "fr", fileName: "fr.ts", exportName: "fr", languageKey: "fr" },
{ locale: "hi", fileName: "hi.ts", exportName: "hi", languageKey: "hi" },
{ locale: "ar", fileName: "ar.ts", exportName: "ar", languageKey: "ar" },
{ locale: "it", fileName: "it.ts", exportName: "it", languageKey: "it" },
{ locale: "tr", fileName: "tr.ts", exportName: "tr", languageKey: "tr" },
{ locale: "uk", fileName: "uk.ts", exportName: "uk", languageKey: "uk" },
{ locale: "id", fileName: "id.ts", exportName: "id", languageKey: "id" },
{ locale: "pl", fileName: "pl.ts", exportName: "pl", languageKey: "pl" },
{ locale: "th", fileName: "th.ts", exportName: "th", languageKey: "th" },
{ locale: "vi", fileName: "vi.ts", exportName: "vi", languageKey: "vi" },
{ locale: "nl", fileName: "nl.ts", exportName: "nl", languageKey: "nl" },
{ locale: "fa", fileName: "fa.ts", exportName: "fa", languageKey: "fa" },
{ locale: "ru", fileName: "ru.ts", exportName: "ru", languageKey: "ru" },
];
const LOCALE_ENTRIES: readonly LocaleEntry[] = CONTROL_UI_LOCALE_ENTRIES;
const DEFAULT_GLOSSARY: readonly GlossaryEntry[] = [
{ source: "OpenClaw", target: "OpenClaw" },
@@ -517,285 +473,6 @@ function logProgress(message: string) {
process.stdout.write(`control-ui-i18n: ${message}\n`);
}
function toRepoPath(filePath: string): string {
return path.relative(ROOT, filePath).split(path.sep).join("/");
}
function normalizeRawCopyText(raw: string): string {
return raw
.replace(/\\n/g, " ")
.replace(/\s+/g, " ")
.replace(/&middot;/giu, "·")
.trim();
}
function hasHumanLetters(text: string): boolean {
return /\p{L}/u.test(text);
}
function lineNumberForOffset(source: string, offset: number): number {
let line = 1;
for (let index = 0; index < offset && index < source.length; index += 1) {
if (source.charCodeAt(index) === 10) {
line += 1;
}
}
return line;
}
function parseDoubleQuotedString(raw: string): string {
try {
return JSON.parse(`"${raw}"`) as string;
} catch {
return raw;
}
}
function pushRawCopyFinding(
findings: RawCopyFinding[],
params: Omit<RawCopyFinding, "text"> & { text: string },
) {
const text = normalizeRawCopyText(params.text);
if (!text || !hasHumanLetters(text)) {
return;
}
findings.push({
...params,
text,
});
}
async function walkControlUiSourceFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
if (entry.name === "test-helpers") {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walkControlUiSourceFiles(fullPath)));
continue;
}
if (!entry.isFile() || !/\.tsx?$/u.test(entry.name)) {
continue;
}
if (/\.(?:test|browser\.test|node\.test)\.tsx?$/u.test(entry.name)) {
continue;
}
files.push(fullPath);
}
return files;
}
function collectRawCopyFromSource(params: {
filePath: string;
source: string;
sourceFile: ts.SourceFile;
}): RawCopyFinding[] {
const { filePath, source, sourceFile } = params;
const repoPath = toRepoPath(filePath);
const findings: RawCopyFinding[] = [];
const attrPattern =
/\b(aria-label|placeholder|title)\s*=\s*"((?:(?!\$\{)[^"\\]|\\.)*?\p{L}(?:(?!\$\{)[^"\\]|\\.)*?)"/gu;
for (const match of source.matchAll(attrPattern)) {
const rawText = match[2];
if (!rawText) {
continue;
}
pushRawCopyFinding(findings, {
kind: "html-attribute",
line: lineNumberForOffset(source, match.index ?? 0),
name: match[1] ?? "attribute",
path: repoPath,
text: parseDoubleQuotedString(rawText),
});
}
const propertyPattern =
/\b(label|title|subtitle|description|help|placeholder)\s*:\s*"((?:[^"\\]|\\.)*?\p{L}(?:[^"\\]|\\.)*?)"/gu;
for (const match of source.matchAll(propertyPattern)) {
const rawText = match[2];
if (!rawText) {
continue;
}
pushRawCopyFinding(findings, {
kind: "object-property",
line: lineNumberForOffset(source, match.index ?? 0),
name: match[1] ?? "property",
path: repoPath,
text: parseDoubleQuotedString(rawText),
});
}
const textPattern = />\s*([^<>{}]*?\p{L}[^<>{}]*?)\s*</gu;
const visit = (node: ts.Node) => {
if (ts.isTaggedTemplateExpression(node) && node.tag.getText(sourceFile) === "html") {
const template = node.template;
const chunks: Array<{ offset: number; text: string }> = [];
if (ts.isNoSubstitutionTemplateLiteral(template)) {
chunks.push({
offset: template.getStart(sourceFile) + 1,
text: template.text,
});
} else {
chunks.push({
offset: template.head.getStart(sourceFile) + 1,
text: template.head.text,
});
for (const span of template.templateSpans) {
chunks.push({
offset: span.literal.getStart(sourceFile) + 1,
text: span.literal.text,
});
}
}
for (const chunk of chunks) {
for (const match of chunk.text.matchAll(textPattern)) {
const rawText = match[1];
if (!rawText) {
continue;
}
pushRawCopyFinding(findings, {
kind: "html-text",
line: lineNumberForOffset(source, chunk.offset + (match.index ?? 0)),
name: "text",
path: repoPath,
text: rawText,
});
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return findings;
}
async function collectControlUiRawCopyFindings(): Promise<RawCopyFinding[]> {
const files = (
await Promise.all(CONTROL_UI_RAW_COPY_SOURCE_DIRS.map((dir) => walkControlUiSourceFiles(dir)))
).flat();
const findings: RawCopyFinding[] = [];
for (const filePath of files.toSorted((left, right) => left.localeCompare(right))) {
const source = await readFile(filePath, "utf8");
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
findings.push(...collectRawCopyFromSource({ filePath, source, sourceFile }));
}
return findings;
}
function summarizeRawCopyFindings(findings: RawCopyFinding[]): RawCopyBaselineEntry[] {
const counts = new Map<string, RawCopyBaselineEntry>();
for (const finding of findings) {
const key = [finding.path, finding.kind, finding.name, finding.text].join("\u0000");
const existing = counts.get(key);
if (existing) {
existing.count += 1;
continue;
}
counts.set(key, {
count: 1,
kind: finding.kind,
name: finding.name,
path: finding.path,
text: finding.text,
});
}
return [...counts.values()].toSorted(
(left, right) =>
left.path.localeCompare(right.path) ||
left.kind.localeCompare(right.kind) ||
left.name.localeCompare(right.name) ||
left.text.localeCompare(right.text),
);
}
function formatRawCopyBaseline(entries: RawCopyBaselineEntry[]): string {
return `${JSON.stringify(
{
version: RAW_COPY_BASELINE_VERSION,
entries,
} satisfies RawCopyBaseline,
null,
2,
)}\n`;
}
function formatRawCopyBaselineDiff(
current: RawCopyBaselineEntry[],
expected: RawCopyBaselineEntry[],
) {
const keyFor = (entry: RawCopyBaselineEntry) =>
[entry.path, entry.kind, entry.name, entry.text].join("\u0000");
const currentByKey = new Map(current.map((entry) => [keyFor(entry), entry]));
const expectedByKey = new Map(expected.map((entry) => [keyFor(entry), entry]));
const added = current.filter((entry) => {
const expectedEntry = expectedByKey.get(keyFor(entry));
return !expectedEntry || expectedEntry.count !== entry.count;
});
const removed = expected.filter((entry) => {
const currentEntry = currentByKey.get(keyFor(entry));
return !currentEntry || currentEntry.count !== entry.count;
});
const lines: string[] = [];
for (const entry of added.slice(0, 20)) {
lines.push(
`+ ${entry.path} ${entry.kind}:${entry.name} x${entry.count} ${JSON.stringify(entry.text)}`,
);
}
for (const entry of removed.slice(0, 20)) {
lines.push(
`- ${entry.path} ${entry.kind}:${entry.name} x${entry.count} ${JSON.stringify(entry.text)}`,
);
}
const extra = added.length + removed.length - lines.length;
if (extra > 0) {
lines.push(`... ${extra} more baseline delta(s)`);
}
return lines.join("\n");
}
async function syncControlUiRawCopyBaseline(options: { checkOnly: boolean; write: boolean }) {
const findings = await collectControlUiRawCopyFindings();
const entries = summarizeRawCopyFindings(findings);
const expected = formatRawCopyBaseline(entries);
const current = existsSync(RAW_COPY_BASELINE_PATH)
? await readFile(RAW_COPY_BASELINE_PATH, "utf8")
: "";
if (!options.checkOnly && options.write && current !== expected) {
await mkdir(I18N_ASSETS_DIR, { recursive: true });
await writeFile(RAW_COPY_BASELINE_PATH, expected, "utf8");
}
if (options.checkOnly && current !== expected) {
let currentEntries: RawCopyBaselineEntry[];
try {
const parsed = JSON.parse(current) as Partial<RawCopyBaseline>;
currentEntries = Array.isArray(parsed.entries) ? parsed.entries : [];
} catch {
currentEntries = [];
}
const diff = formatRawCopyBaselineDiff(entries, currentEntries);
throw new Error(
[
"control-ui raw-copy baseline drift detected.",
diff,
"Move user-facing strings into ui/src/i18n/locales/en.ts, or update the baseline with `node --import tsx scripts/control-ui-i18n.ts sync --write` when the raw string is intentional.",
]
.filter(Boolean)
.join("\n"),
);
}
logProgress(`raw-copy: baseline entries=${entries.length}`);
}
function isPromptTimeoutError(error: Error): boolean {
return error.message.toLowerCase().includes("timed out");
}
@@ -1450,7 +1127,7 @@ type SyncOutcome = {
async function syncLocale(
entry: LocaleEntry,
options: { checkOnly: boolean; force: boolean; write: boolean },
options: { allowTranslate: boolean; checkOnly: boolean; force: boolean; write: boolean },
context: LocaleRunContext,
) {
const localeLabel = formatLocaleLabel(entry.locale, context);
@@ -1466,7 +1143,7 @@ async function syncLocale(
const glossaryFilePath = glossaryPath(entry);
const glossary = await loadGlossary(glossaryFilePath);
const tm = await loadTranslationMemory(tmPath(entry));
const allowTranslate = hasTranslationProvider();
const allowTranslate = options.allowTranslate;
const plan = createControlUiLocaleSyncPlan({
allowTranslate,
cacheKeyFor: (key, textHash) => cacheKey(key, textHash, entry.locale),
@@ -1622,41 +1299,19 @@ async function syncLocale(
} satisfies SyncOutcome;
}
async function verifyRuntimeLocaleConfig() {
const registryRaw = await readFile(
path.join(ROOT, "ui", "src", "i18n", "lib", "registry.ts"),
"utf8",
);
const typesRaw = await readFile(path.join(ROOT, "ui", "src", "i18n", "lib", "types.ts"), "utf8");
const expectedLocaleSnippets = LOCALE_ENTRIES.map((entry) => entry.locale);
for (const locale of expectedLocaleSnippets) {
if (!registryRaw.includes(`"${locale}"`) || !typesRaw.includes(`| "${locale}"`)) {
throw new Error(`runtime locale config is missing ${locale}`);
}
}
const enMap = (await loadLocaleMap(SOURCE_LOCALE_PATH, "en")) ?? {};
const languageMap = enMap.languages;
const languageKeys =
languageMap && typeof languageMap === "object"
? Object.keys(languageMap).toSorted((left, right) => left.localeCompare(right))
: [];
const expectedLanguageKeys = ["en", ...LOCALE_ENTRIES.map((entry) => entry.languageKey)].toSorted(
(left, right) => left.localeCompare(right),
);
if (!compareStringArrays(languageKeys, expectedLanguageKeys)) {
throw new Error(
`ui/src/i18n/locales/en.ts languages block is out of sync: expected ${expectedLanguageKeys.join(", ")}, got ${languageKeys.join(", ")}`,
);
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
await verifyRuntimeLocaleConfig();
if (args.command === "check" || (args.command === "sync" && args.write && !args.localeFilter)) {
if (args.command === "check") {
await verifyControlUiCatalogs({
checkOnly: true,
write: false,
});
} else {
await verifyRuntimeLocaleConfig();
}
if (args.command === "sync" && args.write && !args.localeFilter) {
await syncControlUiRawCopyBaseline({
checkOnly: args.command === "check",
checkOnly: false,
write: args.write,
});
}
@@ -1669,14 +1324,16 @@ async function main() {
throw new Error(`unknown locale: ${args.localeFilter}`);
}
const allowTranslate = args.command === "sync" && hasTranslationProvider();
logProgress(
`command=${args.command} locales=${entries.length} provider=${hasTranslationProvider() ? resolveConfiguredProvider() : "fallback-only"} model=${hasTranslationProvider() ? resolveConfiguredModel() : "n/a"} thinking=${hasTranslationProvider() ? resolveThinkingLevel() : "n/a"} timeout=${formatDuration(resolvePromptTimeoutMs())} batch_chars=${resolveBatchCharBudget()}`,
`command=${args.command} locales=${entries.length} provider=${allowTranslate ? resolveConfiguredProvider() : "disabled"} model=${allowTranslate ? resolveConfiguredModel() : "n/a"} thinking=${allowTranslate ? resolveThinkingLevel() : "n/a"} timeout=${formatDuration(resolvePromptTimeoutMs())} batch_chars=${resolveBatchCharBudget()}`,
);
const outcomes: SyncOutcome[] = [];
for (const [index, entry] of entries.entries()) {
const outcome = await syncLocale(
entry,
{
allowTranslate,
checkOnly: args.command === "check",
force: args.force,
write: args.write,
@@ -1698,6 +1355,14 @@ async function main() {
.join("\n");
process.stdout.write(`${summary}\n`);
if (args.command === "sync" && args.write) {
await syncControlUiCatalogFallbackBaseline({
checkOnly: false,
resolvedLocale: args.localeFilter ?? undefined,
write: true,
});
}
if (args.command === "check" && changed.length > 0) {
throw new Error(
[
+24
View File
@@ -0,0 +1,24 @@
import type { LocaleEntry } from "./control-ui-i18n-sync-plan.ts";
export const CONTROL_UI_LOCALE_ENTRIES: readonly LocaleEntry[] = [
{ locale: "zh-CN", fileName: "zh-CN.ts", exportName: "zh_CN", languageKey: "zhCN" },
{ locale: "zh-TW", fileName: "zh-TW.ts", exportName: "zh_TW", languageKey: "zhTW" },
{ locale: "pt-BR", fileName: "pt-BR.ts", exportName: "pt_BR", languageKey: "ptBR" },
{ locale: "de", fileName: "de.ts", exportName: "de", languageKey: "de" },
{ locale: "es", fileName: "es.ts", exportName: "es", languageKey: "es" },
{ locale: "ja-JP", fileName: "ja-JP.ts", exportName: "ja_JP", languageKey: "jaJP" },
{ locale: "ko", fileName: "ko.ts", exportName: "ko", languageKey: "ko" },
{ locale: "fr", fileName: "fr.ts", exportName: "fr", languageKey: "fr" },
{ locale: "hi", fileName: "hi.ts", exportName: "hi", languageKey: "hi" },
{ locale: "ar", fileName: "ar.ts", exportName: "ar", languageKey: "ar" },
{ locale: "it", fileName: "it.ts", exportName: "it", languageKey: "it" },
{ locale: "tr", fileName: "tr.ts", exportName: "tr", languageKey: "tr" },
{ locale: "uk", fileName: "uk.ts", exportName: "uk", languageKey: "uk" },
{ locale: "id", fileName: "id.ts", exportName: "id", languageKey: "id" },
{ locale: "pl", fileName: "pl.ts", exportName: "pl", languageKey: "pl" },
{ locale: "th", fileName: "th.ts", exportName: "th", languageKey: "th" },
{ locale: "vi", fileName: "vi.ts", exportName: "vi", languageKey: "vi" },
{ locale: "nl", fileName: "nl.ts", exportName: "nl", languageKey: "nl" },
{ locale: "fa", fileName: "fa.ts", exportName: "fa", languageKey: "fa" },
{ locale: "ru", fileName: "ru.ts", exportName: "ru", languageKey: "ru" },
];
+308
View File
@@ -0,0 +1,308 @@
import { existsSync } from "node:fs";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as ts from "typescript";
type RawCopyFinding = {
kind: "html-attribute" | "html-text" | "object-property";
line: number;
name: string;
path: string;
text: string;
};
type RawCopyBaselineEntry = {
count: number;
kind: RawCopyFinding["kind"];
name: string;
path: string;
text: string;
};
type RawCopyBaseline = {
entries: RawCopyBaselineEntry[];
version: number;
};
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const I18N_ASSETS_DIR = path.join(ROOT, "ui", "src", "i18n", ".i18n");
const SOURCE_DIRS = [
path.join(ROOT, "ui", "src", "app"),
path.join(ROOT, "ui", "src", "components"),
path.join(ROOT, "ui", "src", "lib"),
path.join(ROOT, "ui", "src", "pages"),
] as const;
const BASELINE_PATH = path.join(I18N_ASSETS_DIR, "raw-copy-baseline.json");
const BASELINE_VERSION = 1;
const INTERPOLATION_MARKER = "\u0000";
function toRepoPath(filePath: string): string {
return path.relative(ROOT, filePath).split(path.sep).join("/");
}
function normalizeRawCopyText(raw: string): string {
return raw
.replace(/\\n/g, " ")
.replace(/\s+/g, " ")
.replace(/&middot;/giu, "·")
.trim();
}
function lineNumberForOffset(source: string, offset: number): number {
let line = 1;
for (let index = 0; index < offset && index < source.length; index += 1) {
if (source.charCodeAt(index) === 10) {
line += 1;
}
}
return line;
}
function parseDoubleQuotedString(raw: string): string {
try {
return JSON.parse(`"${raw}"`) as string;
} catch {
return raw;
}
}
function pushRawCopyFinding(
findings: RawCopyFinding[],
params: Omit<RawCopyFinding, "text"> & { text: string },
) {
const text = normalizeRawCopyText(params.text);
if (!text || !/\p{L}/u.test(text)) {
return;
}
findings.push({ ...params, text });
}
function pushRawCopySegments(
findings: RawCopyFinding[],
params: Omit<RawCopyFinding, "text"> & { text: string },
) {
for (const text of params.text.split(INTERPOLATION_MARKER)) {
pushRawCopyFinding(findings, { ...params, text });
}
}
async function walkSourceFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
if (entry.name === "test-helpers") {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walkSourceFiles(fullPath)));
continue;
}
if (
entry.isFile() &&
/\.tsx?$/u.test(entry.name) &&
!/\.(?:test|browser\.test|node\.test)\.tsx?$/u.test(entry.name)
) {
files.push(fullPath);
}
}
return files;
}
export function collectControlUiRawCopyFromSource(params: {
filePath: string;
source: string;
sourceFile: ts.SourceFile;
}): RawCopyFinding[] {
const { filePath, source, sourceFile } = params;
const repoPath = toRepoPath(filePath);
const findings: RawCopyFinding[] = [];
const staticAttrPattern =
/\b(aria-label|placeholder|title)\s*=\s*"((?:(?!\$\{)[^"\\]|\\.)*?\p{L}(?:(?!\$\{)[^"\\]|\\.)*?)"/gu;
for (const match of source.matchAll(staticAttrPattern)) {
const rawText = match[2];
if (rawText) {
pushRawCopyFinding(findings, {
kind: "html-attribute",
line: lineNumberForOffset(source, match.index ?? 0),
name: match[1] ?? "attribute",
path: repoPath,
text: parseDoubleQuotedString(rawText),
});
}
}
const propertyPattern =
/\b(label|title|subtitle|description|help|placeholder)\s*:\s*"((?:[^"\\]|\\.)*?\p{L}(?:[^"\\]|\\.)*?)"/gu;
for (const match of source.matchAll(propertyPattern)) {
const rawText = match[2];
if (rawText) {
pushRawCopyFinding(findings, {
kind: "object-property",
line: lineNumberForOffset(source, match.index ?? 0),
name: match[1] ?? "property",
path: repoPath,
text: parseDoubleQuotedString(rawText),
});
}
}
const attrPattern =
/\b(aria-label|placeholder|title)\s*=\s*"((?:[^"\\]|\\.)*?\p{L}(?:[^"\\]|\\.)*?)"/gu;
const textPattern = />\s*([^<>{}]*?\p{L}[^<>{}]*?)\s*</gu;
const visit = (node: ts.Node) => {
if (ts.isTaggedTemplateExpression(node) && node.tag.getText(sourceFile) === "html") {
let logicalText: string;
if (ts.isNoSubstitutionTemplateLiteral(node.template)) {
logicalText = node.template.text;
} else {
logicalText = [
node.template.head.text,
...node.template.templateSpans.map((span) => span.literal.text),
].join(INTERPOLATION_MARKER);
}
const line = lineNumberForOffset(source, node.template.getStart(sourceFile));
for (const match of logicalText.matchAll(attrPattern)) {
const rawText = match[2];
if (rawText?.includes(INTERPOLATION_MARKER)) {
pushRawCopySegments(findings, {
kind: "html-attribute",
line,
name: match[1] ?? "attribute",
path: repoPath,
text: parseDoubleQuotedString(rawText),
});
}
}
for (const match of logicalText.matchAll(textPattern)) {
const rawText = match[1];
if (rawText) {
pushRawCopySegments(findings, {
kind: "html-text",
line,
name: "text",
path: repoPath,
text: rawText,
});
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return findings;
}
async function collectFindings(): Promise<RawCopyFinding[]> {
const files = (await Promise.all(SOURCE_DIRS.map((dir) => walkSourceFiles(dir)))).flat();
const findings: RawCopyFinding[] = [];
for (const filePath of files.toSorted((left, right) => left.localeCompare(right))) {
const source = await readFile(filePath, "utf8");
const sourceFile = ts.createSourceFile(
filePath,
source,
ts.ScriptTarget.Latest,
true,
filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
findings.push(...collectControlUiRawCopyFromSource({ filePath, source, sourceFile }));
}
return findings;
}
function summarize(findings: RawCopyFinding[]): RawCopyBaselineEntry[] {
const counts = new Map<string, RawCopyBaselineEntry>();
for (const finding of findings) {
const key = [finding.path, finding.kind, finding.name, finding.text].join("\u0000");
const existing = counts.get(key);
if (existing) {
existing.count += 1;
} else {
counts.set(key, {
count: 1,
kind: finding.kind,
name: finding.name,
path: finding.path,
text: finding.text,
});
}
}
return [...counts.values()].toSorted(
(left, right) =>
left.path.localeCompare(right.path) ||
left.kind.localeCompare(right.kind) ||
left.name.localeCompare(right.name) ||
left.text.localeCompare(right.text),
);
}
function formatBaseline(entries: RawCopyBaselineEntry[]): string {
return `${JSON.stringify({ version: BASELINE_VERSION, entries } satisfies RawCopyBaseline, null, 2)}\n`;
}
function formatDiff(current: RawCopyBaselineEntry[], expected: RawCopyBaselineEntry[]): string {
const keyFor = (entry: RawCopyBaselineEntry) =>
[entry.path, entry.kind, entry.name, entry.text].join("\u0000");
const currentByKey = new Map(current.map((entry) => [keyFor(entry), entry]));
const expectedByKey = new Map(expected.map((entry) => [keyFor(entry), entry]));
const added = current.filter((entry) => {
const expectedEntry = expectedByKey.get(keyFor(entry));
return !expectedEntry || expectedEntry.count !== entry.count;
});
const removed = expected.filter((entry) => {
const currentEntry = currentByKey.get(keyFor(entry));
return !currentEntry || currentEntry.count !== entry.count;
});
const lines = [
...added
.slice(0, 20)
.map(
(entry) =>
`+ ${entry.path} ${entry.kind}:${entry.name} x${entry.count} ${JSON.stringify(entry.text)}`,
),
...removed
.slice(0, 20)
.map(
(entry) =>
`- ${entry.path} ${entry.kind}:${entry.name} x${entry.count} ${JSON.stringify(entry.text)}`,
),
];
const extra = added.length + removed.length - lines.length;
if (extra > 0) {
lines.push(`... ${extra} more baseline delta(s)`);
}
return lines.join("\n");
}
export async function syncControlUiRawCopyBaseline(options: {
checkOnly: boolean;
write: boolean;
}) {
const entries = summarize(await collectFindings());
const expected = formatBaseline(entries);
const current = existsSync(BASELINE_PATH) ? await readFile(BASELINE_PATH, "utf8") : "";
if (!options.checkOnly && options.write && current !== expected) {
await mkdir(I18N_ASSETS_DIR, { recursive: true });
await writeFile(BASELINE_PATH, expected, "utf8");
}
if (options.checkOnly && current !== expected) {
let currentEntries: RawCopyBaselineEntry[] = [];
try {
const parsed = JSON.parse(current) as Partial<RawCopyBaseline>;
currentEntries = Array.isArray(parsed.entries) ? parsed.entries : [];
} catch {
// Invalid baseline reports as a full delta below.
}
throw new Error(
[
"control-ui raw-copy baseline drift detected.",
formatDiff(entries, currentEntries),
"Move user-facing strings into ui/src/i18n/locales/en.ts, or run `pnpm ui:i18n:baseline` when the raw string is intentional.",
]
.filter(Boolean)
.join("\n"),
);
}
process.stdout.write(`control-ui-i18n: raw-copy: baseline entries=${entries.length}\n`);
}
+17 -11
View File
@@ -857,17 +857,23 @@ describe("detectChangedScope", () => {
runUiTests: true,
});
expect(detectChangedScope(["scripts/control-ui-i18n.ts"])).toEqual({
runNode: true,
runMacos: false,
runIosBuild: false,
runAndroid: false,
runWindows: false,
runSkillsPython: false,
runChangedSmoke: false,
runControlUiI18n: true,
runUiTests: false,
});
for (const scriptPath of [
"scripts/control-ui-i18n.ts",
"scripts/control-ui-i18n-verify.ts",
"scripts/lib/control-ui-i18n-raw-copy.ts",
]) {
expect(detectChangedScope([scriptPath])).toEqual({
runNode: true,
runMacos: false,
runIosBuild: false,
runAndroid: false,
runWindows: false,
runSkillsPython: false,
runChangedSmoke: false,
runControlUiI18n: true,
runUiTests: false,
});
}
});
it.each([
+11
View File
@@ -27,6 +27,7 @@ import {
shouldDelegateChangedCheckToCrabbox,
shouldRunAppcastOwnerTest,
shouldRunCanvasA2uiNativeResourceCheck,
shouldRunControlUiI18nVerify,
shouldRunPromptSnapshotCheck,
shouldRunPromptSnapshotOwnerTest,
shouldRunRuntimeSidecarBaselineCheck,
@@ -811,6 +812,7 @@ describe("scripts/changed-lanes", () => {
});
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:ui");
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:core:test");
expect(plan.commands.map((command) => command.args[0])).toContain("lint:ui:i18n");
expect(plan.commands.map((command) => command.args[0])).not.toContain("tsgo:core");
});
@@ -839,6 +841,15 @@ describe("scripts/changed-lanes", () => {
expect(plan.commands.map((command) => command.args[0])).toContain("tsgo:scripts");
});
it("routes Control UI i18n tooling changes through keyless catalog verification", () => {
const result = detectChangedLanes(["scripts/control-ui-i18n.ts"]);
const plan = createChangedCheckPlan(result);
expect(shouldRunControlUiI18nVerify(result.paths)).toBe(true);
expect(plan.commands.map((command) => command.args[0])).toContain("lint:ui:i18n");
expect(shouldRunControlUiI18nVerify(["scripts/lib/example.ts"])).toBe(false);
});
it.each([
["test/vitest/foo.config.ts", true, true],
["test/vitest/vitest-runtime-helper.d.mts", true, true],
+15
View File
@@ -930,9 +930,24 @@ describe("ci workflow guards", () => {
);
expect(controlUiRefreshStep.env.OPENAI_API_KEY).toBe("${{ secrets.OPENAI_API_KEY }}");
expect(controlUiRefreshStep.env.OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL).toBe("0");
const controlUiArtifactStep = controlUiWorkflow.jobs.refresh.steps.find(
(step: { name?: string }) => step.name === "Prepare locale artifact",
);
expect(controlUiArtifactStep.run).toContain(
":(exclude)ui/src/i18n/.i18n/catalog-fallbacks.json",
);
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["invalidation-paths"]).toContain(
"scripts/control-ui-i18n-verify.ts",
);
expect(controlUiPublishStep.with["invalidation-paths"]).toContain(
"scripts/lib/control-ui-i18n-raw-copy.ts",
);
expect(controlUiFinalize.steps.indexOf(controlUiAggregateStep)).toBeLessThan(
controlUiFinalize.steps.indexOf(controlUiValidationStep),
);
+128 -1
View File
@@ -1,10 +1,16 @@
// Control Ui I18N tests cover control ui i18n script behavior.
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import * as ts from "typescript";
import { describe, expect, it } from "vitest";
import {
analyzeControlUiCatalogs,
assertScopedCatalogFallbackUpdate,
flattenControlUiCatalog,
} from "../../scripts/control-ui-i18n-verify.ts";
import {
appendBoundedProcessOutput,
buildBatchPrompt,
@@ -12,6 +18,7 @@ import {
runProcess,
shouldReuseExistingTranslation,
} from "../../scripts/control-ui-i18n.ts";
import { collectControlUiRawCopyFromSource } from "../../scripts/lib/control-ui-i18n-raw-copy.ts";
import { createTempDirTracker } from "../helpers/temp-dir.js";
function processIsAlive(pid: number): boolean {
@@ -52,6 +59,126 @@ async function waitForChildClose(
}
describe("control-ui-i18n process runner", () => {
it("builds a deterministic fallback list without accepting catalog drift", () => {
const source = flattenControlUiCatalog(
{ group: { first: "First {count}", second: "Second" } },
"en",
);
const missingAnalysis = analyzeControlUiCatalogs(
source,
new Map([
["de", new Map([["group.first", "Erste {count}"]])],
["fr", new Map([["group.first", "Premiere {count}"]])],
]),
);
expect(missingAnalysis).toEqual({
errors: [],
fallbacks: { "group.second": ["de", "fr"] },
});
const driftAnalysis = analyzeControlUiCatalogs(
source,
new Map([
[
"fr",
new Map([
["group.second", "Deuxieme"],
["group.first", "Premiere"],
["group.orphan", "Orpheline"],
]),
],
]),
);
expect(driftAnalysis.errors).toEqual([
"fr: orphan keys: group.orphan",
"fr: keys are not in English catalog order",
"fr:group.first expected {count} got {}",
]);
expect(driftAnalysis.fallbacks).toEqual({});
});
it("rejects invalid catalog leaf values", () => {
expect(() => flattenControlUiCatalog({ group: { title: 42 } }, "fr")).toThrow(
"fr:group.title must be a string or object",
);
});
it("allows scoped sync to remove only that locale's approved fallbacks", () => {
const current = {
fallbacks: { "group.second": ["de", "fr"] },
sourceHash: "source",
version: 1,
};
expect(() =>
assertScopedCatalogFallbackUpdate(
current,
{ ...current, fallbacks: { "group.second": ["fr"] } },
"de",
),
).not.toThrow();
expect(() =>
assertScopedCatalogFallbackUpdate(
current,
{ ...current, fallbacks: { "group.second": ["de"] } },
"de",
),
).toThrow("unrelated catalog fallback drift");
expect(() =>
assertScopedCatalogFallbackUpdate(
current,
{ ...current, fallbacks: { "group.second": ["de", "es", "fr"] } },
"de",
),
).toThrow("unrelated catalog fallback drift");
});
it("finds raw text and attributes split by template interpolation", () => {
const source =
'const jsx = <button aria-label="Archive" />; const view = html`<button title="Delete ${name}">Delete ${name}</button>`;';
const sourceFile = ts.createSourceFile(
"ui/src/pages/example.ts",
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
expect(
collectControlUiRawCopyFromSource({
filePath: path.resolve("ui/src/pages/example.ts"),
source,
sourceFile,
}).map(({ kind, text }) => ({ kind, text })),
).toEqual([
{ kind: "html-attribute", text: "Archive" },
{ kind: "html-attribute", text: "Delete" },
{ kind: "html-text", text: "Delete" },
]);
});
it("keeps verification keyless even when provider credentials exist", () => {
const result = spawnSync(
process.execPath,
["--import", "tsx", "scripts/control-ui-i18n-verify.ts", "verify"],
{
cwd: process.cwd(),
encoding: "utf8",
env: {
...process.env,
ANTHROPIC_API_KEY: "redacted",
OPENAI_API_KEY: "redacted",
},
},
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("catalog:");
expect(result.stdout).not.toContain("provider=openai");
expect(result.stdout).not.toContain("provider=anthropic");
});
it("rejects placeholder-corrupt batch replies before they leave the retry loop", () => {
const items = [
{
+3 -1
View File
@@ -69,7 +69,9 @@ describe("run-oxlint", () => {
const shardedLintRunner = readFileSync("scripts/run-oxlint-shards.mjs", "utf8");
expect(packageJson.scripts.check).toBe("node scripts/check.mjs");
expect(packageJson.scripts.lint).toBe("node scripts/run-oxlint-shards.mjs");
expect(packageJson.scripts.lint).toBe(
"pnpm lint:ui:i18n && node scripts/run-oxlint-shards.mjs",
);
expect(packageJson.scripts["lint:core"]).toBe(
"node scripts/run-oxlint-shards.mjs --only=core --split-core",
);
+3 -1
View File
@@ -10,7 +10,9 @@ This directory owns Control UI-specific guidance that should not live in the rep
- `scripts/control-ui-i18n.ts`
- `ui/src/i18n/lib/types.ts`
- `ui/src/i18n/lib/registry.ts`
- Pipeline: update English strings and locale wiring here and commit `en.ts` only; the `control-ui-locale-refresh` workflow translates and opens a generated PR after merge. Do not run `pnpm ui:i18n:sync` without provider auth (`ANTHROPIC_API_KEY`/`OPENAI_API_KEY`): CI rejects recorded English fallbacks, and sync fails closed on new untranslated keys. Run authenticated sync (and commit bundles plus `.i18n` metadata) only when the change needs it immediately, e.g. a raw-copy-baseline refresh after moving files with hardcoded strings.
- Contributor flow: update English strings and locale wiring, run keyless `pnpm ui:i18n:baseline`, and commit `en.ts` plus changed baseline files. The command records intentional runtime fallbacks without rewriting foreign-language bundles.
- `pnpm ui:i18n:verify` is deterministic and keyless. `pnpm lint` and the changed-check UI lane run it. It validates catalog shape, English-key coverage or explicit fallback listing, orphan keys, placeholder parity, canonical ordering, runtime locale wiring, and raw-copy baseline drift.
- Translation flow: the `control-ui-locale-refresh` workflow translates after merge and opens a generated PR. `pnpm ui:i18n:sync` remains the authenticated maintainer path; do not run it without provider auth when new keys exist. `pnpm ui:i18n:check` is the strict generated-output/release gate.
- 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, regenerate them. Do not manually translate or hand-maintain generated locale files by default.
+5
View File
@@ -0,0 +1,5 @@
{
"fallbacks": {},
"sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240",
"version": 1
}
+154
View File
@@ -78,6 +78,13 @@
"path": "ui/src/components/lobster-pet.ts",
"text": "Z"
},
{
"count": 1,
"kind": "html-attribute",
"name": "placeholder",
"path": "ui/src/components/login-gate.ts",
"text": "OPENCLAW_GATEWAY_TOKEN ("
},
{
"count": 1,
"kind": "html-attribute",
@@ -99,6 +106,13 @@
"path": "ui/src/components/settings-sidebar.ts",
"text": "esc"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/components/sidebar-update-card.ts",
"text": "v"
},
{
"count": 1,
"kind": "object-property",
@@ -288,6 +302,41 @@
"path": "ui/src/lib/chat/commands.ts",
"text": "tools"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/about/view.ts",
"text": "v"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/agents/memory/view.ts",
"text": "assistant"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/agents/memory/view.ts",
"text": "chats"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/agents/memory/view.ts",
"text": "Full vault breakdown:"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/agents/memory/view.ts",
"text": "Imported chats clustered around"
},
{
"count": 1,
"kind": "html-text",
@@ -302,6 +351,34 @@
"path": "ui/src/pages/agents/memory/view.ts",
"text": "plugins.entries.memory-wiki.enabled = true"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/agents/memory/view.ts",
"text": "Selected section:"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/agents/memory/view.ts",
"text": "Showing the first chunk of this page"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/agents/memory/view.ts",
"text": "user ·"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/agents/memory/view.ts",
"text": "Vault ·"
},
{
"count": 2,
"kind": "html-text",
@@ -365,6 +442,20 @@
"path": "ui/src/pages/channels/view.nostr.ts",
"text": "NIP-05"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-composer.ts",
"text": "Compacting context..."
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-composer.ts",
"text": "Context compacted"
},
{
"count": 2,
"kind": "html-text",
@@ -379,6 +470,13 @@
"path": "ui/src/pages/chat/components/chat-composer.ts",
"text": "Esc"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-composer.ts",
"text": "Replying to"
},
{
"count": 2,
"kind": "html-text",
@@ -386,6 +484,13 @@
"path": "ui/src/pages/chat/components/chat-composer.ts",
"text": "Tab"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-message.ts",
"text": "% ctx"
},
{
"count": 2,
"kind": "html-text",
@@ -393,6 +498,27 @@
"path": "ui/src/pages/chat/components/chat-message.ts",
"text": "JSON"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-message.ts",
"text": "R"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-message.ts",
"text": "W"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-thread.ts",
"text": "pinned"
},
{
"count": 1,
"kind": "html-text",
@@ -442,6 +568,13 @@
"path": "ui/src/pages/connection/view.ts",
"text": "OPENCLAW_GATEWAY_TOKEN"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/cron/view-runs.ts",
"text": "ms"
},
{
"count": 1,
"kind": "html-attribute",
@@ -463,6 +596,13 @@
"path": "ui/src/pages/debug/view.ts",
"text": "openclaw security audit --deep"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/memory-import/view.ts",
"text": "/memory/imports/"
},
{
"count": 1,
"kind": "html-text",
@@ -484,6 +624,13 @@
"path": "ui/src/pages/plugins/view.ts",
"text": "https://mcp.example.com/mcp · npx some-mcp-server"
},
{
"count": 4,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/plugins/view.ts",
"text": "v"
},
{
"count": 1,
"kind": "html-text",
@@ -491,6 +638,13 @@
"path": "ui/src/pages/profile/profile-page.ts",
"text": "OpenClaw"
},
{
"count": 2,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/skill-workshop/view.ts",
"text": "v"
},
{
"count": 1,
"kind": "object-property",