mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(ui): bootstrap source-only locales (#131015)
Allow source-only locale contributions to load intentional English bootstrap copy until post-merge generated memory exists. Derive refresh targets from canonical locale data while preserving strict validation. Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
@@ -14,6 +14,7 @@ on:
|
||||
- scripts/control-ui-i18n.ts
|
||||
- scripts/control-ui-i18n-verify.ts
|
||||
- scripts/lib/control-ui-i18n-catalog.ts
|
||||
- scripts/lib/control-ui-i18n-config.json
|
||||
- scripts/lib/control-ui-i18n-config.ts
|
||||
- scripts/lib/control-ui-i18n-raw-copy.ts
|
||||
- scripts/lib/control-ui-i18n-sync-plan.ts
|
||||
@@ -52,6 +53,7 @@ jobs:
|
||||
(github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
locales: ${{ steps.base.outputs.locales }}
|
||||
sha: ${{ steps.base.outputs.sha }}
|
||||
steps:
|
||||
- name: Resolve source commit
|
||||
@@ -76,6 +78,14 @@ jobs:
|
||||
echo "Unable to resolve the locale refresh source to an exact commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
locale_config="$(
|
||||
timeout --signal=TERM --kill-after=10s 60s \
|
||||
gh api --method GET \
|
||||
-H "Accept: application/vnd.github.raw+json" \
|
||||
"repos/${REPOSITORY}/contents/scripts/lib/control-ui-i18n-config.json?ref=${sha}"
|
||||
)"
|
||||
locales="$(jq -ce '[.[].locale] | select(length > 0 and all(.[]; type == "string" and length > 0))' <<< "${locale_config}")"
|
||||
echo "locales=${locales}" >> "${GITHUB_OUTPUT}"
|
||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
publisher-preflight:
|
||||
@@ -130,29 +140,7 @@ jobs:
|
||||
fail-fast: false
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
locale:
|
||||
[
|
||||
zh-CN,
|
||||
zh-TW,
|
||||
pt-BR,
|
||||
de,
|
||||
es,
|
||||
ja-JP,
|
||||
ko,
|
||||
fr,
|
||||
hi,
|
||||
ar,
|
||||
it,
|
||||
tr,
|
||||
uk,
|
||||
id,
|
||||
pl,
|
||||
th,
|
||||
vi,
|
||||
nl,
|
||||
fa,
|
||||
ru,
|
||||
]
|
||||
locale: ${{ fromJSON(needs.resolve-base.outputs.locales) }}
|
||||
runs-on: ubuntu-latest
|
||||
name: Refresh ${{ matrix.locale }}
|
||||
steps:
|
||||
@@ -341,6 +329,7 @@ jobs:
|
||||
scripts/control-ui-i18n.ts
|
||||
scripts/control-ui-i18n-verify.ts
|
||||
scripts/lib/control-ui-i18n-catalog.ts
|
||||
scripts/lib/control-ui-i18n-config.json
|
||||
scripts/lib/control-ui-i18n-config.ts
|
||||
scripts/lib/control-ui-i18n-raw-copy.ts
|
||||
scripts/lib/control-ui-i18n-sync-plan.ts
|
||||
|
||||
@@ -103,7 +103,7 @@ const DEPRECATION_HYGIENE_PATH_RE =
|
||||
const WRAPPER_SHADOWING_PATH_RE =
|
||||
/^(?:package\.json$|src\/|scripts\/(?:check-(?:export-name-collisions|wrapper-shadowing)\.mts$|lib\/ts-guard-utils\.mts$))/u;
|
||||
const CONTROL_UI_I18N_VERIFY_PATH_RE =
|
||||
/^(?: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;
|
||||
/^(?:package\.json$|ui\/(?:src\/|config\/control-ui-locales\.ts$)|scripts\/(?:control-ui-i18n(?:-(?:report|verify))?\.ts|lib\/(?:control-ui-i18n-[^/]+\.ts|control-ui-i18n-config\.json))$|test\/scripts\/control-ui-i18n[^/]*\.test\.ts$)/u;
|
||||
const SHRINK_RATCHET_OWNER_PATH = "scripts/lib/shrink-ratchet.mts";
|
||||
const CORE_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.core.json";
|
||||
const EXTENSIONS_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.extensions.json";
|
||||
|
||||
@@ -92,7 +92,7 @@ const WINDOWS_WORKER_BUNDLE_SCOPE_RE =
|
||||
const WINDOWS_WORKER_WORKSPACE_SCOPE_RE =
|
||||
/^src\/(?:node-host\/node-worker-transfer-client(?:\.test)?|gateway\/worker-environments\/(?:node-worker-tunnel(?:\.test)?|workspace-sync-(?:scripts|manifest\.test)))\.ts$/;
|
||||
const CONTROL_UI_I18N_SCOPE_RE =
|
||||
/^(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$)/;
|
||||
/^(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|config\.json))$|\.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\/\.i18n\/(?:catalog-fallbacks\.json|[^/]+\.(?:meta\.json|tm\.jsonl))$/;
|
||||
|
||||
@@ -306,15 +306,15 @@ export async function syncControlUiCatalogFallbackBaseline(options: {
|
||||
}
|
||||
|
||||
export async function verifyRuntimeLocaleConfig() {
|
||||
const registryRaw = await readFile(
|
||||
path.join(ROOT, "ui", "src", "i18n", "lib", "registry.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const typesRaw = await readFile(path.join(ROOT, "ui", "src", "i18n", "lib", "types.ts"), "utf8");
|
||||
for (const entry of CONTROL_UI_LOCALE_ENTRIES) {
|
||||
if (!registryRaw.includes(`"${entry.locale}"`) || !typesRaw.includes(`| "${entry.locale}"`)) {
|
||||
throw new Error(`runtime locale config is missing ${entry.locale}`);
|
||||
}
|
||||
const registryPath = path.join(ROOT, "ui", "src", "i18n", "lib", "registry.ts");
|
||||
const registry = (await import(pathToFileURL(registryPath).href)) as {
|
||||
SUPPORTED_LOCALES: readonly string[];
|
||||
};
|
||||
const expectedLocales = ["en", ...CONTROL_UI_LOCALE_ENTRIES.map((entry) => entry.locale)];
|
||||
if (!compareStringArrays(registry.SUPPORTED_LOCALES, expectedLocales)) {
|
||||
throw new Error(
|
||||
`runtime locale config is out of sync: expected ${expectedLocales.join(", ")}, got ${registry.SUPPORTED_LOCALES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const enMap = (await loadControlUiLocaleCatalog(SOURCE_LOCALE_PATH, "en")) ?? {};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{ "locale": "zh-CN", "fileName": "zh-CN.ts", "exportName": "zh_CN", "languageKey": "zhCN" },
|
||||
{ "locale": "zh-TW", "fileName": "zh-TW.ts", "exportName": "zh_TW", "languageKey": "zhTW" },
|
||||
{ "locale": "pt-BR", "fileName": "pt-BR.ts", "exportName": "pt_BR", "languageKey": "ptBR" },
|
||||
{ "locale": "de", "fileName": "de.ts", "exportName": "de", "languageKey": "de" },
|
||||
{ "locale": "es", "fileName": "es.ts", "exportName": "es", "languageKey": "es" },
|
||||
{ "locale": "ja-JP", "fileName": "ja-JP.ts", "exportName": "ja_JP", "languageKey": "jaJP" },
|
||||
{ "locale": "ko", "fileName": "ko.ts", "exportName": "ko", "languageKey": "ko" },
|
||||
{ "locale": "fr", "fileName": "fr.ts", "exportName": "fr", "languageKey": "fr" },
|
||||
{ "locale": "hi", "fileName": "hi.ts", "exportName": "hi", "languageKey": "hi" },
|
||||
{ "locale": "ar", "fileName": "ar.ts", "exportName": "ar", "languageKey": "ar" },
|
||||
{ "locale": "it", "fileName": "it.ts", "exportName": "it", "languageKey": "it" },
|
||||
{ "locale": "tr", "fileName": "tr.ts", "exportName": "tr", "languageKey": "tr" },
|
||||
{ "locale": "uk", "fileName": "uk.ts", "exportName": "uk", "languageKey": "uk" },
|
||||
{ "locale": "id", "fileName": "id.ts", "exportName": "id", "languageKey": "id" },
|
||||
{ "locale": "pl", "fileName": "pl.ts", "exportName": "pl", "languageKey": "pl" },
|
||||
{ "locale": "th", "fileName": "th.ts", "exportName": "th", "languageKey": "th" },
|
||||
{ "locale": "vi", "fileName": "vi.ts", "exportName": "vi", "languageKey": "vi" },
|
||||
{ "locale": "nl", "fileName": "nl.ts", "exportName": "nl", "languageKey": "nl" },
|
||||
{ "locale": "fa", "fileName": "fa.ts", "exportName": "fa", "languageKey": "fa" },
|
||||
{ "locale": "ru", "fileName": "ru.ts", "exportName": "ru", "languageKey": "ru" }
|
||||
]
|
||||
@@ -1,24 +1,4 @@
|
||||
import controlUiLocaleEntries from "./control-ui-i18n-config.json" with { type: "json" };
|
||||
import type { LocaleEntry } from "./control-ui-i18n-sync-plan.ts";
|
||||
|
||||
export const CONTROL_UI_LOCALE_ENTRIES: readonly LocaleEntry[] = [
|
||||
{ locale: "zh-CN", fileName: "zh-CN.ts", exportName: "zh_CN", languageKey: "zhCN" },
|
||||
{ locale: "zh-TW", fileName: "zh-TW.ts", exportName: "zh_TW", languageKey: "zhTW" },
|
||||
{ locale: "pt-BR", fileName: "pt-BR.ts", exportName: "pt_BR", languageKey: "ptBR" },
|
||||
{ locale: "de", fileName: "de.ts", exportName: "de", languageKey: "de" },
|
||||
{ locale: "es", fileName: "es.ts", exportName: "es", languageKey: "es" },
|
||||
{ locale: "ja-JP", fileName: "ja-JP.ts", exportName: "ja_JP", languageKey: "jaJP" },
|
||||
{ locale: "ko", fileName: "ko.ts", exportName: "ko", languageKey: "ko" },
|
||||
{ locale: "fr", fileName: "fr.ts", exportName: "fr", languageKey: "fr" },
|
||||
{ locale: "hi", fileName: "hi.ts", exportName: "hi", languageKey: "hi" },
|
||||
{ locale: "ar", fileName: "ar.ts", exportName: "ar", languageKey: "ar" },
|
||||
{ locale: "it", fileName: "it.ts", exportName: "it", languageKey: "it" },
|
||||
{ locale: "tr", fileName: "tr.ts", exportName: "tr", languageKey: "tr" },
|
||||
{ locale: "uk", fileName: "uk.ts", exportName: "uk", languageKey: "uk" },
|
||||
{ locale: "id", fileName: "id.ts", exportName: "id", languageKey: "id" },
|
||||
{ locale: "pl", fileName: "pl.ts", exportName: "pl", languageKey: "pl" },
|
||||
{ locale: "th", fileName: "th.ts", exportName: "th", languageKey: "th" },
|
||||
{ locale: "vi", fileName: "vi.ts", exportName: "vi", languageKey: "vi" },
|
||||
{ locale: "nl", fileName: "nl.ts", exportName: "nl", languageKey: "nl" },
|
||||
{ locale: "fa", fileName: "fa.ts", exportName: "fa", languageKey: "fa" },
|
||||
{ locale: "ru", fileName: "ru.ts", exportName: "ru", languageKey: "ru" },
|
||||
];
|
||||
export const CONTROL_UI_LOCALE_ENTRIES = controlUiLocaleEntries satisfies readonly LocaleEntry[];
|
||||
|
||||
@@ -15,6 +15,12 @@ it("skips control-ui localization checks for test-only UI source", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("runs control-ui localization checks for the canonical locale config", () => {
|
||||
expect(detectChangedScope(["scripts/lib/control-ui-i18n-config.json"]).runControlUiI18n).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs Chromium UI tests for browser copilot extension changes", () => {
|
||||
expect(detectChangedScope(["extensions/browser/chrome-extension/sidepanel.ts"]).runUiTests).toBe(
|
||||
true,
|
||||
|
||||
@@ -977,6 +977,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/control-ui-i18n-config.json"])).toBe(true);
|
||||
expect(shouldRunControlUiI18nVerify(["scripts/lib/example.ts"])).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
} from "../../scripts/ci-changed-scope.mjs";
|
||||
import { NATIVE_I18N_LOCALES } from "../../scripts/native-i18n-locales.ts";
|
||||
import { resolvePnpmRunner } from "../../scripts/pnpm-runner.mts";
|
||||
import { SUPPORTED_LOCALES } from "../../ui/src/i18n/lib/registry.ts";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
const CHECKOUT_V6 = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1";
|
||||
@@ -2210,11 +2209,12 @@ NODE
|
||||
"${{ github.event_name == 'workflow_dispatch' && inputs.token_preflight_only && format('control-ui-locale-token-preflight-{0}', github.ref) || 'control-ui-locale-refresh' }}",
|
||||
);
|
||||
expect(controlUiWorkflow.jobs.plan).toBeUndefined();
|
||||
expect(controlUiResolveBase.outputs.locales).toBe("${{ steps.base.outputs.locales }}");
|
||||
expect(controlUiWorkflow.jobs.refresh.if).toBe(
|
||||
"needs.resolve-base.result == 'success' && needs.publisher-preflight.result == 'success' && !(github.event_name == 'workflow_dispatch' && inputs.token_preflight_only)",
|
||||
);
|
||||
expect(controlUiWorkflow.jobs.refresh.strategy.matrix.locale).toEqual(
|
||||
SUPPORTED_LOCALES.filter((locale) => locale !== "en"),
|
||||
expect(controlUiWorkflow.jobs.refresh.strategy.matrix.locale).toBe(
|
||||
"${{ fromJSON(needs.resolve-base.outputs.locales) }}",
|
||||
);
|
||||
expect(workflow.concurrency["cancel-in-progress"]).toBe(false);
|
||||
expect(workflow.concurrency.group).toBe("native-app-locale-refresh");
|
||||
@@ -2382,6 +2382,10 @@ NODE
|
||||
'if [[ "${TOKEN_PREFLIGHT_ONLY}" == "true" ]]; then',
|
||||
);
|
||||
expect(controlUiResolveStep.run).toContain('sha="${WORKFLOW_SHA}"');
|
||||
expect(controlUiResolveStep.run).toContain(
|
||||
"contents/scripts/lib/control-ui-i18n-config.json?ref=${sha}",
|
||||
);
|
||||
expect(controlUiResolveStep.run).toContain("jq -ce '[.[].locale]");
|
||||
|
||||
for (const preflight of [controlUiPreflight, nativePreflight]) {
|
||||
expect(preflight.needs).toBe("resolve-base");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Plugin } from "vite";
|
||||
@@ -47,6 +48,11 @@ export function controlUiLocaleModulesPlugin(): Plugin {
|
||||
return null;
|
||||
}
|
||||
const memoryPath = path.join(i18nAssetsDir, `${locale}.tm.jsonl`);
|
||||
// Source PRs omit generated memory until the post-merge refresh runs.
|
||||
// Existing empty or malformed memory stays fatal below so drift cannot hide.
|
||||
if (!existsSync(memoryPath)) {
|
||||
return `export default ${JSON.stringify(sourceCatalog)};`;
|
||||
}
|
||||
this.addWatchFile(memoryPath);
|
||||
const memory = loadControlUiTranslationMemory(memoryPath);
|
||||
if (memory.size === 0) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../../vite.config.ts";
|
||||
|
||||
const childProcessMocks = vi.hoisted(() => ({ execFileSync: vi.fn() }));
|
||||
const fsMocks = vi.hoisted(() => ({ existsSync: vi.fn(), readFileSync: vi.fn() }));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
@@ -22,6 +23,13 @@ vi.mock("node:child_process", async (importOriginal) => {
|
||||
return { ...actual, execFileSync: childProcessMocks.execFileSync };
|
||||
});
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
fsMocks.existsSync.mockImplementation(actual.existsSync);
|
||||
fsMocks.readFileSync.mockImplementation(actual.readFileSync);
|
||||
return { ...actual, existsSync: fsMocks.existsSync, readFileSync: fsMocks.readFileSync };
|
||||
});
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
type ResolveIdHandler = (
|
||||
this: never,
|
||||
@@ -481,4 +489,42 @@ describe("Control UI Vite config", () => {
|
||||
expect(catalog.activity.title).toBeTypeOf("string");
|
||||
expect(addWatchFile).toHaveBeenCalledWith(path.join(repoRoot, "ui/src/i18n/.i18n/fr.tm.jsonl"));
|
||||
});
|
||||
|
||||
it("bootstraps only an absent locale memory from the English catalog", async () => {
|
||||
const loadHook = controlUiLocaleModulesPlugin().load;
|
||||
const load = typeof loadHook === "function" ? loadHook : loadHook?.handler;
|
||||
if (!load) {
|
||||
throw new Error("Expected locale module loader");
|
||||
}
|
||||
const id = "\0virtual:openclaw-control-ui-locale/fr";
|
||||
const addWatchFile = vi.fn();
|
||||
|
||||
await fsMocks.existsSync.withImplementation(
|
||||
() => false,
|
||||
async () => {
|
||||
const result = await load.call({ addWatchFile } as never, id, {} 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("Health");
|
||||
expect(addWatchFile).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
await fsMocks.readFileSync.withImplementation(
|
||||
() => "",
|
||||
async () => {
|
||||
expect(() => load.call({ addWatchFile } as never, id, {} as never)).toThrow(
|
||||
"Control UI fr translation memory is missing or empty",
|
||||
);
|
||||
},
|
||||
);
|
||||
await fsMocks.readFileSync.withImplementation(
|
||||
() => "{",
|
||||
async () => {
|
||||
expect(() => load.call({ addWatchFile } as never, id, {} as never)).toThrow(SyntaxError);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,29 +6,6 @@ type LocaleModule = Record<string, TranslationMap>;
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = "en";
|
||||
|
||||
const LAZY_LOCALES: readonly LazyLocale[] = [
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"pt-BR",
|
||||
"de",
|
||||
"es",
|
||||
"ja-JP",
|
||||
"ko",
|
||||
"fr",
|
||||
"hi",
|
||||
"ar",
|
||||
"it",
|
||||
"tr",
|
||||
"uk",
|
||||
"id",
|
||||
"pl",
|
||||
"th",
|
||||
"vi",
|
||||
"nl",
|
||||
"fa",
|
||||
"ru",
|
||||
];
|
||||
|
||||
const LAZY_LOCALE_REGISTRY: Record<LazyLocale, () => Promise<LocaleModule>> = {
|
||||
"zh-CN": () => import("../locales/zh-CN.ts"),
|
||||
"zh-TW": () => import("../locales/zh-TW.ts"),
|
||||
@@ -51,6 +28,8 @@ const LAZY_LOCALE_REGISTRY: Record<LazyLocale, () => Promise<LocaleModule>> = {
|
||||
fa: () => import("../locales/fa.ts"),
|
||||
ru: () => import("../locales/ru.ts"),
|
||||
};
|
||||
// SAFETY: The record contract guarantees every own key is a LazyLocale.
|
||||
const LAZY_LOCALES = Object.keys(LAZY_LOCALE_REGISTRY) as LazyLocale[];
|
||||
|
||||
export const SUPPORTED_LOCALES: ReadonlyArray<Locale> = [DEFAULT_LOCALE, ...LAZY_LOCALES];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user