diff --git a/package.json b/package.json index 1610d1be61e..f4e52a7c863 100644 --- a/package.json +++ b/package.json @@ -1743,6 +1743,7 @@ "ui:build": "node scripts/ui.js build", "ui:dev": "node scripts/ui.js dev", "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:install": "node scripts/ui.js install" }, diff --git a/scripts/control-ui-i18n-report.ts b/scripts/control-ui-i18n-report.ts new file mode 100644 index 00000000000..305a21cfcba --- /dev/null +++ b/scripts/control-ui-i18n-report.ts @@ -0,0 +1,421 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { TranslationMap } from "../ui/src/i18n/lib/types.ts"; + +const ROOT = path.resolve(fileURLToPath(new URL("..", import.meta.url))); +const I18N_ASSETS_DIR = path.join(ROOT, "ui/src/i18n/.i18n"); +const LOCALES_DIR = path.join(ROOT, "ui/src/i18n/locales"); +const RAW_COPY_BASELINE_PATH = path.join(I18N_ASSETS_DIR, "raw-copy-baseline.json"); +const SOURCE_LOCALE_PATH = path.join(LOCALES_DIR, "en.ts"); +const DEFAULT_TOP = 10; +const LOCALE_LABELS: Record = { + ar: "Arabic", + de: "German", + es: "Spanish", + fa: "Persian", + fr: "French", + id: "Indonesian", + it: "Italian", + "ja-JP": "Japanese", + ko: "Korean", + nl: "Dutch", + pl: "Polish", + "pt-BR": "Brazilian Portuguese", + th: "Thai", + tr: "Turkish", + uk: "Ukrainian", + vi: "Vietnamese", + "zh-CN": "Simplified Chinese", + "zh-TW": "Traditional Chinese", +}; +const PATH_LABELS: Record = { + "ui/src/ui/chat/chat-queue.ts": "Chat queue", + "ui/src/ui/chat/grouped-render.ts": "Chat message groups", + "ui/src/ui/chat/side-result-render.ts": "Chat tool result panel", + "ui/src/ui/chat/tool-cards.ts": "Chat tool cards", + "ui/src/ui/views/agents-panels-overview.ts": "Agents overview panel", + "ui/src/ui/views/agents-panels-tools-skills.ts": "Agents tools and skills panel", + "ui/src/ui/views/agents-utils.ts": "Agents shared UI helpers", + "ui/src/ui/views/chat.ts": "Chat page", + "ui/src/ui/views/config-form.render.ts": "Config form", + "ui/src/ui/views/config-quick.ts": "Quick config page", + "ui/src/ui/views/config.ts": "Config page", + "ui/src/ui/views/cron.ts": "Cron page", + "ui/src/ui/views/usage-query.ts": "Usage filters", + "ui/src/ui/views/usage-render-details.ts": "Usage detail view", + "ui/src/ui/views/usage-render-overview.ts": "Usage overview", +}; + +type RawCopyKind = "html-attribute" | "html-text" | "object-property"; + +export type RawCopyBaselineEntry = { + count: number; + kind: RawCopyKind; + name: string; + path: string; + text: string; +}; + +type RawCopyBaseline = { + entries: RawCopyBaselineEntry[]; + version: number; +}; + +type LocaleMeta = { + fallbackKeys: string[]; + generatedAt: string; + locale: string; + model: string; + provider: string; + sourceHash: string; + totalKeys: number; + translatedKeys: number; + workflow: number; +}; + +type ReportArgs = { + locale?: string; + surface?: string; + top: number; +}; + +export type RawCopySummary = { + entries: number; + occurrences: number; + topPaths: Array<{ count: number; path: string }>; +}; + +export type LocaleSummary = { + fallbackKeysInScope: string[]; + meta: LocaleMeta; + sameAsEnglishKeys: string[]; +}; + +type ReportInput = { + locale?: LocaleSummary; + rawCopy: RawCopySummary; + surface?: string; +}; + +export function parseArgs(argv: string[]): ReportArgs { + const args: ReportArgs = { top: DEFAULT_TOP }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--surface") { + args.surface = readOptionValue(argv, (index += 1), arg); + continue; + } + if (arg === "--locale") { + args.locale = readOptionValue(argv, (index += 1), arg); + continue; + } + if (arg === "--top") { + const raw = readOptionValue(argv, (index += 1), arg); + const top = Number.parseInt(raw, 10); + if (!Number.isSafeInteger(top) || top < 1) { + throw new Error(`--top must be a positive integer: ${raw}`); + } + args.top = top; + continue; + } + throw new Error(`unknown argument: ${arg}\n${usage()}`); + } + return args; +} + +function readOptionValue(argv: string[], index: number, flag: string) { + const value = argv[index]; + if (!value || value.startsWith("--")) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +export function filterRawCopyEntries(entries: RawCopyBaselineEntry[], surface?: string) { + if (!surface) { + return entries; + } + const normalized = normalizeToken(surface); + return entries.filter((entry) => pathTokens(entry.path).some((token) => token === normalized)); +} + +export function summarizeRawCopy(entries: RawCopyBaselineEntry[], top: number): RawCopySummary { + const byPath = new Map(); + let occurrences = 0; + + for (const entry of entries) { + occurrences += entry.count; + byPath.set(entry.path, (byPath.get(entry.path) ?? 0) + entry.count); + } + + const rankedPaths = [...byPath.entries()] + .map(([entryPath, count]) => ({ count, path: entryPath })) + .toSorted(compareCountThenName((entry) => entry.path)); + + return { + entries: entries.length, + occurrences, + topPaths: rankedPaths.slice(0, top), + }; +} + +function compareCountThenName(nameOf: (value: T) => string) { + return (left: T & { count: number }, right: T & { count: number }) => + right.count - left.count || nameOf(left).localeCompare(nameOf(right)); +} + +function pathTokens(repoPath: string) { + return repoPath + .split("/") + .flatMap((part) => part.replace(/\.[^.]+$/, "").split(/[^A-Za-z0-9]+/)) + .filter(Boolean) + .map(normalizeToken); +} + +function normalizeToken(value: string) { + return value.trim().toLowerCase(); +} + +export function flattenTranslations( + value: TranslationMap, + prefix = "", + out = new Map(), +) { + for (const [key, nested] of Object.entries(value)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof nested === "string") { + out.set(fullKey, nested); + continue; + } + flattenTranslations(nested, fullKey, out); + } + return out; +} + +export function computeSameAsEnglishKeys(source: Map, target: Map) { + return [...target.entries()] + .filter(([key, value]) => source.get(key) === value) + .map(([key]) => key) + .toSorted(); +} + +export function filterTranslationKeysBySurface(keys: string[], surface?: string) { + if (!surface) { + return keys; + } + const normalized = normalizeToken(surface); + return keys.filter((key) => key.split(".").some((part) => normalizeToken(part) === normalized)); +} + +export function formatReport(input: ReportInput) { + const lines = [ + "Control UI i18n baseline report", + `Scope: ${formatSurfaceLabel(input.surface)}, ${ + input.locale ? formatLocaleLabel(input.locale.meta.locale) : "no locale selected" + }`, + "Based on: current raw-copy baseline and locale metadata. Not a drift check.", + "", + "Current i18n state", + ` Hardcoded UI text outside i18n: ${input.rawCopy.entries} pieces in code, ${input.rawCopy.occurrences} total occurrences.`, + ...formatLocaleState(input.locale), + "", + "Current issue", + ...formatIssueLines(input), + "", + "Focus modules", + ...formatTopPathLines(input.rawCopy.topPaths), + "", + "Next steps", + ...formatNextStepLines(input), + ]; + + return `${lines.join("\n")}\n`; +} + +function formatLocaleState(locale?: LocaleSummary) { + if (!locale) { + return [" Existing translation keys: not checked. Add --locale to include them."]; + } + return [ + ` Existing ${locale.meta.locale} translation keys (all Control UI): ${locale.meta.translatedKeys}/${locale.meta.totalKeys} filled, ${formatFallbackCount(locale.meta.fallbackKeys.length)}.`, + ]; +} + +function formatIssueLines(input: ReportInput) { + const scope = input.surface ? formatSurfaceLabel(input.surface) : "Control UI"; + const lines: string[] = []; + + if (input.rawCopy.entries === 0) { + lines.push(` No hardcoded UI text found in ${scope}.`); + } else { + lines.push(` ${scope} still has UI text written directly in code.`); + } + + if (!input.locale) { + lines.push(" Locale-specific gaps were not checked."); + return lines; + } + + if ( + input.locale.fallbackKeysInScope.length === 0 && + input.locale.sameAsEnglishKeys.length === 0 + ) { + lines.push(` No ${input.locale.meta.locale} locale problems found in this scope.`); + return lines; + } + + if (input.locale.fallbackKeysInScope.length > 0) { + lines.push( + ` ${input.locale.meta.locale} has ${formatMissingKeyCount(input.locale.fallbackKeysInScope.length)}.`, + ); + } + if (input.locale.sameAsEnglishKeys.length > 0) { + lines.push( + ` ${formatSameAsEnglishIssue(input.locale.sameAsEnglishKeys.length, input.locale.meta.locale)}.`, + ); + } + return lines; +} + +function formatNextStepLines(input: ReportInput) { + if (input.rawCopy.entries === 0) { + return [" No module work needed for hardcoded text in this scope."]; + } + return [ + " Move text from the focus modules into translation keys.", + " Do not hand-edit generated locale, translation memory, or i18n metadata files.", + " Run pnpm ui:i18n:sync after adding translation keys.", + ]; +} + +function formatTopPathLines(entries: Array<{ count: number; path: string }>) { + if (entries.length === 0) { + return [" none"]; + } + return entries.map((entry) => ` ${entry.count} ${formatPathLabel(entry.path)}: ${entry.path}`); +} + +function formatMissingKeyCount(count: number) { + return `${count} missing ${count === 1 ? "key" : "keys"}`; +} + +function formatFallbackCount(count: number) { + return `${count} ${count === 1 ? "fallback" : "fallbacks"}`; +} + +function formatSameAsEnglishIssue(count: number, locale: string) { + if (count === 1) { + return `1 ${locale} translation still matches English and needs review`; + } + return `${count} ${locale} translations still match English and need review`; +} + +function formatSurfaceLabel(surface?: string) { + if (!surface) { + return "all Control UI"; + } + return toTitleWords(surface); +} + +function formatLocaleLabel(locale: string) { + const label = LOCALE_LABELS[locale]; + return label ? `${label} (${locale})` : locale; +} + +function formatPathLabel(repoPath: string) { + return PATH_LABELS[repoPath] ?? toTitleWords(path.basename(repoPath).replace(/\.[^.]+$/, "")); +} + +function toTitleWords(value: string) { + return value + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +async function loadRawCopyBaseline(): Promise { + const parsed = JSON.parse(await readFile(RAW_COPY_BASELINE_PATH, "utf8")) as RawCopyBaseline; + if (!Array.isArray(parsed.entries)) { + throw new Error("raw-copy baseline is missing entries"); + } + return parsed; +} + +async function loadLocaleMeta(locale: string): Promise { + const metaPath = path.join(I18N_ASSETS_DIR, `${locale}.meta.json`); + if (!existsSync(metaPath)) { + throw new Error(`unknown locale metadata: ${locale}`); + } + return JSON.parse(await readFile(metaPath, "utf8")) as LocaleMeta; +} + +async function loadLocaleMap(locale: string): Promise { + const filePath = locale === "en" ? SOURCE_LOCALE_PATH : path.join(LOCALES_DIR, `${locale}.ts`); + if (!existsSync(filePath)) { + throw new Error(`unknown locale file: ${locale}`); + } + const exportName = locale.replaceAll("-", "_"); + const mod = (await import(pathToFileURL(filePath).href)) as Record; + const map = mod[exportName]; + if (!map) { + throw new Error(`locale ${locale} does not export ${exportName}`); + } + return map; +} + +async function buildReport(args: ReportArgs) { + const baseline = await loadRawCopyBaseline(); + const entries = filterRawCopyEntries(baseline.entries, args.surface); + const input: ReportInput = { + rawCopy: summarizeRawCopy(entries, args.top), + surface: args.surface, + }; + + if (args.locale) { + const [meta, source, target] = await Promise.all([ + loadLocaleMeta(args.locale), + loadLocaleMap("en"), + loadLocaleMap(args.locale), + ]); + input.locale = { + fallbackKeysInScope: filterTranslationKeysBySurface(meta.fallbackKeys, args.surface), + meta, + sameAsEnglishKeys: filterTranslationKeysBySurface( + computeSameAsEnglishKeys(flattenTranslations(source), flattenTranslations(target)), + args.surface, + ), + }; + } + + return formatReport(input); +} + +function usage() { + return [ + "usage: node --import tsx scripts/control-ui-i18n-report.ts [--surface ] [--locale ] [--top ]", + "example: pnpm ui:i18n:report --surface chat --locale zh-CN --top 20", + ].join("\n"); +} + +function isCliEntrypoint() { + const entrypoint = process.argv[1]; + return Boolean(entrypoint && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href); +} + +if (isCliEntrypoint()) { + const cliArgs = process.argv.slice(2); + if (cliArgs.includes("--help") || cliArgs.includes("-h")) { + process.stdout.write(`${usage()}\n`); + process.exit(0); + } + + try { + process.stdout.write(await buildReport(parseArgs(cliArgs))); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/src/scripts/control-ui-i18n-report.test.ts b/src/scripts/control-ui-i18n-report.test.ts new file mode 100644 index 00000000000..d16866933f8 --- /dev/null +++ b/src/scripts/control-ui-i18n-report.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { + computeSameAsEnglishKeys, + filterRawCopyEntries, + filterTranslationKeysBySurface, + flattenTranslations, + formatReport, + summarizeRawCopy, + type RawCopyBaselineEntry, +} from "../../scripts/control-ui-i18n-report.ts"; +import type { TranslationMap } from "../../ui/src/i18n/lib/types.ts"; + +const entries: RawCopyBaselineEntry[] = [ + { + count: 2, + kind: "html-text", + name: "text", + path: "ui/src/ui/chat/render.ts", + text: "Send", + }, + { + count: 1, + kind: "object-property", + name: "label", + path: "ui/src/ui/views/agents-panels-tools.ts", + text: "Tools", + }, + { + count: 4, + kind: "html-attribute", + name: "title", + path: "ui/src/ui/views/config-form.render.ts", + text: "Open config", + }, +]; + +describe("control-ui-i18n report helpers", () => { + it("filters raw-copy entries by path surface token", () => { + expect(filterRawCopyEntries(entries, "agents")).toEqual([entries[1]]); + expect(filterRawCopyEntries(entries, "config")).toEqual([entries[2]]); + expect(filterRawCopyEntries(entries, "missing")).toEqual([]); + }); + + it("summarizes raw-copy occurrences deterministically", () => { + expect(summarizeRawCopy(entries, 2)).toEqual({ + entries: 3, + occurrences: 7, + topPaths: [ + { count: 4, path: "ui/src/ui/views/config-form.render.ts" }, + { count: 2, path: "ui/src/ui/chat/render.ts" }, + ], + }); + }); + + it("flattens locale maps and reports same-as-English keys", () => { + const source: TranslationMap = { + actions: { send: "Send", stop: "Stop" }, + brand: "OpenClaw", + }; + const target: TranslationMap = { + actions: { send: "发送", stop: "Stop" }, + brand: "OpenClaw", + }; + + expect( + computeSameAsEnglishKeys(flattenTranslations(source), flattenTranslations(target)), + ).toEqual(["actions.stop", "brand"]); + }); + + it("filters same-as-English keys by translation key surface token", () => { + expect( + filterTranslationKeysBySurface( + ["agents.tabs.cronJobs", "chat.composer.send", "usage.common.emptyValue"], + "chat", + ), + ).toEqual(["chat.composer.send"]); + }); + + it("formats pasteable report text", () => { + const report = formatReport({ + locale: { + fallbackKeysInScope: ["actions.cancel"], + meta: { + fallbackKeys: ["actions.cancel"], + generatedAt: "2026-05-13T00:00:00.000Z", + locale: "zh-CN", + model: "gpt-5.5", + provider: "openai", + sourceHash: "hash", + totalKeys: 3, + translatedKeys: 2, + workflow: 1, + }, + sameAsEnglishKeys: ["brand"], + }, + rawCopy: summarizeRawCopy(entries, 1), + surface: "chat", + }); + + expect(report).toContain("Control UI i18n baseline report\n"); + expect(report).toContain("Scope: Chat, Simplified Chinese (zh-CN)\n"); + expect(report).toContain( + "Based on: current raw-copy baseline and locale metadata. Not a drift check.\n", + ); + expect(report).toContain( + "Hardcoded UI text outside i18n: 3 pieces in code, 7 total occurrences.\n", + ); + expect(report).toContain( + "Existing zh-CN translation keys (all Control UI): 2/3 filled, 1 fallback.\n", + ); + expect(report).toContain("Chat still has UI text written directly in code.\n"); + expect(report).toContain("zh-CN has 1 missing key.\n"); + expect(report).toContain("1 zh-CN translation still matches English and needs review.\n"); + expect(report).toContain("4 Config form: ui/src/ui/views/config-form.render.ts\n"); + expect(report).toContain("Move text from the focus modules into translation keys.\n"); + expect(report).toContain( + "Do not hand-edit generated locale, translation memory, or i18n metadata files.\n", + ); + expect(report).toContain("Run pnpm ui:i18n:sync after adding translation keys.\n"); + expect(report).not.toContain("raw-copy entries"); + }); + + it("keeps fallback-key issues scoped to the selected surface", () => { + const report = formatReport({ + locale: { + fallbackKeysInScope: [], + meta: { + fallbackKeys: ["usage.common.emptyValue"], + generatedAt: "2026-05-13T00:00:00.000Z", + locale: "zh-CN", + model: "gpt-5.5", + provider: "openai", + sourceHash: "hash", + totalKeys: 3, + translatedKeys: 2, + workflow: 1, + }, + sameAsEnglishKeys: [], + }, + rawCopy: summarizeRawCopy(entries, 1), + surface: "chat", + }); + + expect(report).toContain( + "Existing zh-CN translation keys (all Control UI): 2/3 filled, 1 fallback.\n", + ); + expect(report).toContain("No zh-CN locale problems found in this scope.\n"); + expect(report).not.toContain("zh-CN has 1 missing key.\n"); + }); +});