feat(ui): add i18n baseline report

Signed-off-by: samzong <samzong.lu@gmail.com>
This commit is contained in:
samzong
2026-05-13 14:45:29 +08:00
committed by Peter Steinberger
parent d5abbd29cc
commit ee9d471865
3 changed files with 572 additions and 0 deletions

View File

@@ -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<string, string> = {
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<string, string> = {
"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<string, number>();
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<T>(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<string, string>(),
) {
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<string, string>, target: Map<string, string>) {
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 <code> 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<RawCopyBaseline> {
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<LocaleMeta> {
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<TranslationMap> {
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<string, TranslationMap>;
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 <name>] [--locale <locale>] [--top <n>]",
"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);
}
}