mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
91aff8089c
commit
7e78de747b
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)};`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1748
-973
File diff suppressed because it is too large
Load Diff
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"fallbacks": {},
|
||||
"sourceHash": "c47dd98cbd34af29c35d314dbb230aeb07988acc9326b280da0701f9f5213714",
|
||||
"sourceHash": "912ad83d5f70ee4463b1370efd9bf9c890017c65833f30501e8c3440da135ee7",
|
||||
"version": 1
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1802
-947
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1744
-996
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1824
-945
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1697
-1025
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1034
-1598
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1797
-982
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1830
-970
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1832
-904
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1806
-946
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1723
-1000
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1785
-990
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1810
-950
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1034
-1600
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1743
-934
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1845
-948
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1796
-978
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1805
-958
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1799
-953
File diff suppressed because it is too large
Load Diff
Generated
+4
-4
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1797
-953
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-5785
File diff suppressed because it is too large
Load Diff
+1
-5777
File diff suppressed because it is too large
Load Diff
+1
-5677
File diff suppressed because it is too large
Load Diff
+1
-5819
File diff suppressed because it is too large
Load Diff
+1
-5622
File diff suppressed because it is too large
Load Diff
+1
-5693
File diff suppressed because it is too large
Load Diff
+1
-5779
File diff suppressed because it is too large
Load Diff
+1
-5724
File diff suppressed because it is too large
Load Diff
+1
-5655
File diff suppressed because it is too large
Load Diff
+1
-5740
File diff suppressed because it is too large
Load Diff
+1
-5750
File diff suppressed because it is too large
Load Diff
+1
-5740
File diff suppressed because it is too large
Load Diff
+1
-5743
File diff suppressed because it is too large
Load Diff
+1
-5579
File diff suppressed because it is too large
Load Diff
+1
-5723
File diff suppressed because it is too large
Load Diff
+1
-5723
File diff suppressed because it is too large
Load Diff
+1
-5690
File diff suppressed because it is too large
Load Diff
+1
-5527
File diff suppressed because it is too large
Load Diff
+1
-5539
File diff suppressed because it is too large
Load Diff
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "virtual:openclaw-control-ui-locale/*" {
|
||||
const translations: import("./lib/types.ts").TranslationMap;
|
||||
export default translations;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user