feat(i18n): add ui locale runtime

This commit is contained in:
youzini
2026-06-05 10:34:58 +00:00
parent 5f595779a9
commit c19b5d76ae
9 changed files with 721 additions and 0 deletions

95
i18n/en-US.js Normal file
View File

@@ -0,0 +1,95 @@
// ST-BME UI-only i18n catalog: English.
// Keep this catalog for frontend chrome/status text only. Do not use it for
// prompt construction, graph node content, persisted memories, or LLM schemas.
export default {
"common.appName": "ST-BME",
"common.cancel": "Cancel",
"common.close": "Close",
"common.confirm": "Confirm",
"common.delete": "Delete",
"common.edit": "Edit",
"common.empty": "No data",
"common.loading": "Loading…",
"common.retry": "Retry",
"common.save": "Save",
"common.status": "Status",
"common.unknown": "Unknown",
"error.auditFailed": "Audit failed",
"error.deleteBlocked": "The node was removed from the graph, but write-back may be blocked. Check graph state.",
"error.deleteFailed": "Delete failed",
"error.nodeNotFound": "Node no longer exists",
"error.saveBlocked": "Content updated, but write-back to chat metadata may be blocked. Check graph state.",
"error.saveFailed": "Save failed",
"error.syncFailed": "Sync failed",
"graph.scope.characterPov": "Character POV · {name}",
"graph.scope.objective": "Objective Layer",
"graph.scope.unknownCharacter": "Unknown character",
"graph.scope.userPov": "User POV",
"i18n.locale.auto": "Auto",
"i18n.locale.enUS": "English",
"i18n.locale.setting.help": "Only affects ST-BME panel text, notices, and status messages. It does not translate chat content, memory nodes, or prompts.",
"i18n.locale.setting.label": "Interface Language",
"i18n.locale.zhCN": "Simplified Chinese",
"notice.completed": "{stage} completed",
"notice.failed": "{stage} failed",
"notice.generic": "ST-BME",
"notice.loading": "{stage} in progress…",
"notice.partial": "{stage} partially succeeded",
"panel.entry.floatingTooltip": "BME Memory Graph",
"panel.entry.menuLabel": "Memory Graph",
"panel.entry.openFailed": "Memory Graph panel failed to load. Check the console for details.",
"panel.title": "ST-BME Memory Graph",
"panel.tab.actions": "Actions",
"panel.tab.cognition": "Cognition",
"panel.tab.config": "Settings",
"panel.tab.dashboard": "Overview",
"panel.tab.extraction": "Extraction",
"panel.tab.persistence": "Persistence State",
"panel.tab.pipeline": "Pipeline Overview",
"panel.tab.recall": "Recall",
"panel.tab.settings": "Settings",
"panel.tab.tasks": "Tasks",
"panel.tab.timeline": "Task Timeline",
"panel.tab.trace": "Message Trace",
"panel.tab.vector": "Vector",
"persistence.loadState.error": "Load failed",
"persistence.loadState.loaded": "Loaded",
"persistence.loadState.loading": "Loading",
"persistence.loadState.noChat": "No chat is currently open",
"persistence.persistState.completed": "Completed",
"persistence.persistState.failed": "Persistence failed",
"persistence.persistState.idle": "Idle",
"persistence.persistState.pending": "Awaiting persistence confirmation",
"recall.card.deleteConfirm": "Delete this persisted recall injection?",
"recall.card.memoryCount": "{count} memories",
"recall.card.title": "Relevant Memory Recall",
"stage.extraction": "Extraction",
"stage.history": "History Recovery",
"stage.recall": "Recall",
"stage.vector": "Vector",
"status.aborted": "Aborted",
"status.failed": "Failed",
"status.idle": "Idle",
"status.loading": "Loading…",
"status.partial": "Partially succeeded",
"status.ready": "Ready",
"status.running": "Running",
"status.skipped": "Skipped",
"status.success": "Succeeded",
"status.waiting": "Waiting",
"status.initial.extraction.detail": "Extraction has not run yet",
"status.initial.recall.detail": "Recall has not run yet",
"status.initial.runtime.detail": "Ready",
"status.initial.vector.detail": "Vector tasks have not run yet",
};

30
i18n/glossary.js Normal file
View File

@@ -0,0 +1,30 @@
// UI terminology reference for ST-BME i18n. This is documentation data for
// translators and tests, not a runtime prompt/schema source.
export const UI_I18N_GLOSSARY = Object.freeze({
"记忆图谱": "Memory Graph",
"记忆节点": "Memory Node",
"召回": "Recall",
"提取": "Extraction",
"注入": "Injection",
"注入预览": "Injection Preview",
"历史恢复": "History Recovery",
"图谱持久化": "Graph Persistence",
"检查点": "Checkpoint",
"副本同步": "Replica Sync",
"向量": "Vector",
"向量重建": "Vector Rebuild",
"向量空间": "Vector Space",
"向量清单": "Vector Manifest",
"嵌入服务商": "Embedding Provider",
"服务端增强": "Server Enhancement",
"纯前端模式": "Frontend-only Mode",
"权威输入": "Authoritative Input",
"角色 POV": "Character POV",
"用户 POV": "User POV",
"客观层": "Objective Layer",
"楼层": "Turn",
"持久召回": "Persisted Recall",
"召回卡片": "Recall Card",
"记忆浏览器": "Memory Browser",
});

173
i18n/index.js Normal file
View File

@@ -0,0 +1,173 @@
import zhCN from "./zh-CN.js";
import enUS from "./en-US.js";
export const SUPPORTED_LOCALES = Object.freeze(["zh-CN", "en-US"]);
export const DEFAULT_LOCALE = "zh-CN";
export const DEFAULT_LOCALE_MODE = "auto";
export const catalogs = Object.freeze({
"zh-CN": zhCN,
"en-US": enUS,
});
let localeMode = DEFAULT_LOCALE_MODE;
let resolvedLocale = DEFAULT_LOCALE;
function normalizeLocaleTag(value) {
const tag = String(value || "").trim();
if (!tag) return "";
const lower = tag.toLowerCase();
if (lower.startsWith("zh")) return "zh-CN";
if (lower.startsWith("en")) return "en-US";
return "";
}
function getNavigatorLanguages() {
const nav = globalThis.navigator;
const languages = Array.isArray(nav?.languages) ? nav.languages : [];
if (nav?.language) return [...languages, nav.language];
return languages;
}
export function resolveLocale(mode = DEFAULT_LOCALE_MODE, options = {}) {
const requested = String(mode || DEFAULT_LOCALE_MODE);
if (SUPPORTED_LOCALES.includes(requested)) return requested;
if (requested !== "auto") return DEFAULT_LOCALE;
const hostLocale = normalizeLocaleTag(options.hostLocale);
if (hostLocale) return hostLocale;
const languages = Array.isArray(options.navigatorLanguages)
? options.navigatorLanguages
: getNavigatorLanguages();
for (const language of languages) {
const locale = normalizeLocaleTag(language);
if (locale) return locale;
}
return DEFAULT_LOCALE;
}
export function setLocale(mode = DEFAULT_LOCALE_MODE, options = {}) {
localeMode = SUPPORTED_LOCALES.includes(mode) || mode === "auto"
? mode
: DEFAULT_LOCALE_MODE;
resolvedLocale = resolveLocale(localeMode, options);
if (globalThis.document?.documentElement) {
globalThis.document.documentElement.lang = resolvedLocale;
}
return resolvedLocale;
}
export function getLocale() {
return resolvedLocale;
}
export function getLocaleMode() {
return localeMode;
}
export function hasI18nKey(key, locale = resolvedLocale) {
return Object.prototype.hasOwnProperty.call(catalogs[locale] || {}, key) ||
Object.prototype.hasOwnProperty.call(zhCN, key);
}
function interpolate(template, params = {}) {
return String(template).replace(/\{\{?\s*([A-Za-z_][\w.-]*)\s*\}?\}/g, (match, name) => {
if (Object.prototype.hasOwnProperty.call(params || {}, name)) {
const value = params[name];
return value == null ? "" : String(value);
}
return match;
});
}
export function t(key, params = {}, options = {}) {
const locale = options.locale || resolvedLocale;
const catalog = catalogs[locale] || zhCN;
const fallbackCatalog = catalogs[DEFAULT_LOCALE] || zhCN;
const template = Object.prototype.hasOwnProperty.call(catalog, key)
? catalog[key]
: fallbackCatalog[key];
if (template == null) {
return options.fallback ?? key;
}
return interpolate(template, params);
}
function hydrateAttribute(root, selector, attributeName, setter) {
const elements = [];
if (root?.matches?.(selector)) elements.push(root);
if (typeof root?.querySelectorAll === "function") {
elements.push(...root.querySelectorAll(selector));
}
for (const element of elements) {
const key = element.getAttribute?.(attributeName);
if (!key) continue;
setter(element, t(key));
}
}
export function hydrateI18n(root = globalThis.document) {
if (!root) return;
hydrateAttribute(root, "[data-i18n]", "data-i18n", (element, value) => {
element.textContent = value;
});
hydrateAttribute(root, "[data-i18n-title]", "data-i18n-title", (element, value) => {
element.setAttribute?.("title", value);
});
hydrateAttribute(root, "[data-i18n-placeholder]", "data-i18n-placeholder", (element, value) => {
element.setAttribute?.("placeholder", value);
});
hydrateAttribute(root, "[data-i18n-aria-label]", "data-i18n-aria-label", (element, value) => {
element.setAttribute?.("aria-label", value);
});
}
export function createI18nStatus({
textKey = "",
textParams = {},
textFallback = "",
metaKey = "",
metaParams = {},
metaFallback = "",
level = "idle",
extra = {},
} = {}) {
return {
...extra,
textKey,
textParams,
textFallback,
metaKey,
metaParams,
metaFallback,
text: textKey ? t(textKey, textParams, { fallback: textFallback }) : textFallback,
meta: metaKey ? t(metaKey, metaParams, { fallback: metaFallback }) : metaFallback,
level,
updatedAt: Date.now(),
};
}
export function formatUiStatusText(status) {
if (typeof status === "string") return status;
if (status?.textKey) {
return t(status.textKey, status.textParams || {}, {
fallback: status.textFallback ?? status.text ?? status.textKey,
});
}
return status?.text ?? "";
}
export function formatUiStatusMeta(status) {
if (typeof status === "string") return status;
if (status?.metaKey) {
return t(status.metaKey, status.metaParams || {}, {
fallback: status.metaFallback ?? status.meta ?? status.metaKey,
});
}
return status?.meta ?? "";
}
setLocale(DEFAULT_LOCALE_MODE);

95
i18n/zh-CN.js Normal file
View File

@@ -0,0 +1,95 @@
// ST-BME UI-only i18n catalog: Simplified Chinese.
// Keep this catalog for frontend chrome/status text only. Do not use it for
// prompt construction, graph node content, persisted memories, or LLM schemas.
export default {
"common.appName": "ST-BME",
"common.cancel": "取消",
"common.close": "关闭",
"common.confirm": "确认",
"common.delete": "删除",
"common.edit": "编辑",
"common.empty": "暂无数据",
"common.loading": "加载中…",
"common.retry": "重试",
"common.save": "保存",
"common.status": "状态",
"common.unknown": "未知",
"error.auditFailed": "审计失败",
"error.deleteBlocked": "节点已从图中移除,但写回可能被拦截,请查看图谱状态",
"error.deleteFailed": "删除失败",
"error.nodeNotFound": "节点已不存在",
"error.saveBlocked": "内容已更新,但写回聊天元数据可能被拦截,请查看图谱状态",
"error.saveFailed": "保存失败",
"error.syncFailed": "同步失败",
"graph.scope.characterPov": "角色 POV · {name}",
"graph.scope.objective": "客观层",
"graph.scope.unknownCharacter": "未知角色",
"graph.scope.userPov": "用户 POV",
"i18n.locale.auto": "自动",
"i18n.locale.enUS": "English",
"i18n.locale.setting.help": "只影响 ST-BME 面板、提示和状态文案,不会翻译聊天内容、记忆节点或提示词。",
"i18n.locale.setting.label": "界面语言",
"i18n.locale.zhCN": "简体中文",
"notice.completed": "{stage}已完成",
"notice.failed": "{stage}失败",
"notice.generic": "ST-BME",
"notice.loading": "{stage}进行中…",
"notice.partial": "{stage}部分成功",
"panel.entry.floatingTooltip": "BME 记忆图谱",
"panel.entry.menuLabel": "记忆图谱",
"panel.entry.openFailed": "记忆图谱面板加载失败,请查看控制台报错",
"panel.title": "ST-BME 记忆图谱",
"panel.tab.actions": "操作",
"panel.tab.cognition": "角色认知",
"panel.tab.config": "设置",
"panel.tab.dashboard": "总览",
"panel.tab.extraction": "提取 Extraction",
"panel.tab.persistence": "持久化状态",
"panel.tab.pipeline": "管线总览",
"panel.tab.recall": "召回 Recall",
"panel.tab.settings": "配置",
"panel.tab.tasks": "任务",
"panel.tab.timeline": "任务流水",
"panel.tab.trace": "消息追踪",
"panel.tab.vector": "向量 Vector",
"persistence.loadState.error": "加载失败",
"persistence.loadState.loaded": "已加载",
"persistence.loadState.loading": "加载中",
"persistence.loadState.noChat": "当前尚未进入聊天",
"persistence.persistState.completed": "已完成",
"persistence.persistState.failed": "持久化失败",
"persistence.persistState.idle": "待命",
"persistence.persistState.pending": "等待正式持久化确认",
"recall.card.deleteConfirm": "确认删除这条持久召回注入?",
"recall.card.memoryCount": "记忆 {count}",
"recall.card.title": "相关记忆召回",
"stage.extraction": "提取",
"stage.history": "历史恢复",
"stage.recall": "召回",
"stage.vector": "向量",
"status.aborted": "已中止",
"status.failed": "失败",
"status.idle": "待命",
"status.loading": "加载中…",
"status.partial": "部分成功",
"status.ready": "准备就绪",
"status.running": "运行中",
"status.skipped": "已跳过",
"status.success": "成功",
"status.waiting": "等待中",
"status.initial.extraction.detail": "尚未执行提取",
"status.initial.recall.detail": "尚未执行召回",
"status.initial.runtime.detail": "准备就绪",
"status.initial.vector.detail": "尚未执行向量任务",
};

View File

@@ -28,6 +28,10 @@
"test:indexeddb-migration": "node tests/indexeddb-migration.mjs",
"test:native-layout-parity": "node tests/native-layout-parity.mjs",
"test:trivial-input": "node tests/trivial-user-input.mjs",
"test:i18n-catalog": "node tests/i18n-catalog.mjs",
"test:i18n-dom": "node tests/i18n-dom.mjs",
"test:i18n-boundary": "node tests/i18n-boundary.mjs",
"test:i18n": "npm run test:i18n-catalog && npm run test:i18n-dom && npm run test:i18n-boundary",
"bench:graph-layout": "node tests/perf/graph-layout-bench.mjs",
"bench:persist-delta": "node tests/perf/persist-delta-bench.mjs",
"bench:persist-load": "node tests/perf/persist-load-bench.mjs",

View File

@@ -202,6 +202,7 @@ export const defaultSettings = {
backgroundMaintenanceMaxQueueItems: 24,
// UI 面板
uiLocale: "auto",
noticeDisplayMode: "normal",
panelTheme: "crimson",
graphLocalStorageMode: "auto",

138
tests/i18n-boundary.mjs Normal file
View File

@@ -0,0 +1,138 @@
// ST-BME: i18n import boundary enforcement
// Ensures that prompt/data modules (prompting/**, retrieval/injector.js,
// graph/schema.js, sync/**, vector/**) do NOT import from the i18n module.
// This is a static analysis guard: i18n is UI-only and must not leak into
// data-pipeline or prompt-building code.
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
// ─── Configuration ───────────────────────────────────────────────────────────
const PROJECT_ROOT = new URL("..", import.meta.url).pathname;
const BOUNDARY_PATTERNS = [
// i18n module import patterns to forbid
/from\s+["'](?:\.\.\/)+i18n(?:\/[^"']*)?["']/,
/require\s*\(\s*["'](?:\.\.\/)+i18n(?:\/[^"']*)?["']\s*\)/,
/import\s*\(\s*["'](?:\.\.\/)+i18n(?:\/[^"']*)?["']\s*\)/,
];
const BOUNDARY_MODULES = [
// prompting/ — all files in the prompting directory
{ dir: "prompting", files: null },
// retrieval/injector.js specifically
{ dir: "retrieval", files: ["injector.js"] },
// graph/schema.js specifically
{ dir: "graph", files: ["schema.js"] },
// sync/ — all files in the sync directory
{ dir: "sync", files: null },
// vector/ — all files in the vector directory
{ dir: "vector", files: null },
];
// ─── Helpers ─────────────────────────────────────────────────────────────────
function readProjectFile(relativePath) {
return readFileSync(PROJECT_ROOT + relativePath, "utf8");
}
/**
* Check if a source file contains any forbidden i18n import pattern.
* Returns violations found, or an empty array.
*/
function checkI18nImports(source, filePath) {
const violations = [];
for (const pattern of BOUNDARY_PATTERNS) {
const match = source.match(pattern);
if (match) {
const lineNum = source.substring(0, match.index).split("\n").length;
violations.push({ filePath, line: lineNum, match: match[0].trim() });
}
}
return violations;
}
/**
* Get all .js files in a directory (non-recursive for simplicity; the
* project's restricted dirs are flat).
*/
function getJsFilesInDir(dirPath) {
const entries = readdirSync(PROJECT_ROOT + dirPath, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".js"))
.map((entry) => entry.name);
}
// ─── Tests ───────────────────────────────────────────────────────────────────
const allViolations = [];
for (const entry of BOUNDARY_MODULES) {
const fileList = entry.files || getJsFilesInDir(entry.dir);
for (const filename of fileList) {
const relativePath = `${entry.dir}/${filename}`;
let source;
try {
source = readProjectFile(relativePath);
} catch {
console.warn(`[i18n-boundary] SKIP — file not found: ${relativePath}`);
continue;
}
const violations = checkI18nImports(source, relativePath);
allViolations.push(...violations);
}
}
if (allViolations.length > 0) {
const report = allViolations
.map(
(v) => ` ${v.filePath}:${v.line}${v.match}`,
)
.join("\n");
assert.fail(
`i18n import boundary violation(s) found in ${allViolations.length} location(s):\n${report}\n\n` +
"i18n is UI-only. Prompt/data modules (prompting/**, retrieval/injector.js, " +
"graph/schema.js, sync/**, vector/**) must not import i18n.",
);
}
// Optional: verify that the boundary modules themselves are valid JS by
// checking for obvious syntax issues (e.g., unmatched braces, obviously
// malformed import statements that might hide i18n imports).
// This is a lightweight sanity check, not a full parse.
const MINIMAL_FILE_COUNT = {
prompting: 9,
retrieval: 1, // injector.js only
graph: 1, // schema.js only
sync: 13,
vector: 6,
};
for (const entry of BOUNDARY_MODULES) {
if (entry.files) {
for (const filename of entry.files) {
const relativePath = `${entry.dir}/${filename}`;
try {
const source = readProjectFile(relativePath);
// Ensure the file has at least some content
assert.ok(
source.trim().length > 0,
`${relativePath} is empty — cannot verify boundary compliance`,
);
} catch {
// Already warned above — skip
}
}
} else {
const dirFiles = getJsFilesInDir(entry.dir);
assert.ok(
dirFiles.length >= (MINIMAL_FILE_COUNT[entry.dir] || 1),
`${entry.dir} expected at least ${MINIMAL_FILE_COUNT[entry.dir]} .js files for boundary coverage, got ${dirFiles.length}`,
);
}
}
console.log("i18n boundary enforcement tests passed");
console.log(` Scanned modules: ${BOUNDARY_MODULES.length} directories / files`);
console.log(` Violations found: ${allViolations.length}`);

47
tests/i18n-catalog.mjs Normal file
View File

@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import zhCN from "../i18n/zh-CN.js";
import enUS from "../i18n/en-US.js";
function catalogKeys(catalog) {
return Object.keys(catalog).sort();
}
function extractInterpolationParams(template) {
const params = new Set();
const re = /\{\{?\s*([A-Za-z_][\w.-]*)\s*\}?\}/g;
let match;
while ((match = re.exec(String(template)))) {
params.add(match[1]);
}
return [...params].sort();
}
const zhKeys = catalogKeys(zhCN);
const enKeys = catalogKeys(enUS);
const onlyZh = zhKeys.filter((key) => !Object.prototype.hasOwnProperty.call(enUS, key));
const onlyEn = enKeys.filter((key) => !Object.prototype.hasOwnProperty.call(zhCN, key));
assert.deepEqual(onlyZh, [], `keys in zh-CN but missing in en-US: ${onlyZh.join(", ")}`);
assert.deepEqual(onlyEn, [], `keys in en-US but missing in zh-CN: ${onlyEn.join(", ")}`);
assert.equal(zhKeys.length, enKeys.length, "zh-CN and en-US catalog key counts differ");
for (const key of zhKeys) {
assert.equal(typeof zhCN[key], "string", `zh-CN ${key} must be a string`);
assert.equal(typeof enUS[key], "string", `en-US ${key} must be a string`);
assert.ok(zhCN[key].trim(), `zh-CN ${key} must not be empty`);
assert.ok(enUS[key].trim(), `en-US ${key} must not be empty`);
assert.deepEqual(
extractInterpolationParams(zhCN[key]),
extractInterpolationParams(enUS[key]),
`interpolation params differ for ${key}`,
);
}
assert.ok(zhKeys.length >= 60, "Phase 0 catalog should include a meaningful seed set");
assert.ok(
zhKeys.some((key) => extractInterpolationParams(zhCN[key]).length > 0),
"catalog should include at least one interpolated string",
);
console.log("i18n catalog tests passed");

138
tests/i18n-dom.mjs Normal file
View File

@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import {
createI18nStatus,
formatUiStatusMeta,
formatUiStatusText,
getLocale,
getLocaleMode,
hydrateI18n,
resolveLocale,
setLocale,
t,
} from "../i18n/index.js";
class FakeElement {
constructor(tagName = "div") {
this.tagName = tagName.toUpperCase();
this.children = [];
this.attributes = new Map();
this.textContent = "";
}
appendChild(child) {
this.children.push(child);
return child;
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
getAttribute(name) {
return this.attributes.get(name) ?? null;
}
matches(selector) {
const attr = selector.match(/^\[([\w-]+)\]$/)?.[1];
return Boolean(attr && this.attributes.has(attr));
}
querySelectorAll(selector) {
const attr = selector.match(/^\[([\w-]+)\]$/)?.[1];
if (!attr) return [];
const result = [];
const visit = (node) => {
for (const child of node.children) {
if (child.attributes.has(attr)) result.push(child);
visit(child);
}
};
visit(this);
return result;
}
}
function withNavigatorLanguages(languages, fn) {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
language: languages[0] || "",
languages,
},
});
try {
return fn();
} finally {
if (descriptor) Object.defineProperty(globalThis, "navigator", descriptor);
else delete globalThis.navigator;
}
}
assert.equal(resolveLocale("zh-CN"), "zh-CN");
assert.equal(resolveLocale("en-US"), "en-US");
assert.equal(resolveLocale("nonsense"), "zh-CN");
assert.equal(resolveLocale("auto", { navigatorLanguages: ["en-GB"] }), "en-US");
assert.equal(resolveLocale("auto", { navigatorLanguages: ["zh-Hant"] }), "zh-CN");
assert.equal(resolveLocale("auto", { hostLocale: "en-US", navigatorLanguages: ["zh-CN"] }), "en-US");
assert.equal(resolveLocale("auto", { navigatorLanguages: ["fr-FR"] }), "zh-CN");
withNavigatorLanguages(["en-AU"], () => {
assert.equal(setLocale("auto"), "en-US");
});
assert.equal(getLocaleMode(), "auto");
assert.equal(getLocale(), "en-US");
setLocale("zh-CN");
assert.equal(t("common.save"), "保存");
assert.equal(t("notice.loading", { stage: "提取" }), "提取进行中…");
assert.equal(t("missing.key"), "missing.key");
assert.equal(t("missing.key", {}, { fallback: "兜底" }), "兜底");
assert.equal(t("recall.card.memoryCount", { count: 3 }), "记忆 3");
setLocale("en-US");
assert.equal(t("common.save"), "Save");
assert.equal(t("notice.loading", { stage: "Extraction" }), "Extraction in progress…");
assert.equal(t("recall.card.memoryCount", { count: 3 }), "3 memories");
const root = new FakeElement("section");
const text = new FakeElement("span");
text.setAttribute("data-i18n", "common.cancel");
const title = new FakeElement("button");
title.setAttribute("data-i18n-title", "common.close");
const placeholder = new FakeElement("input");
placeholder.setAttribute("data-i18n-placeholder", "common.loading");
const aria = new FakeElement("button");
aria.setAttribute("data-i18n-aria-label", "common.delete");
root.appendChild(text);
root.appendChild(title);
root.appendChild(placeholder);
root.appendChild(aria);
hydrateI18n(root);
assert.equal(text.textContent, "Cancel");
assert.equal(title.getAttribute("title"), "Close");
assert.equal(placeholder.getAttribute("placeholder"), "Loading…");
assert.equal(aria.getAttribute("aria-label"), "Delete");
setLocale("zh-CN");
hydrateI18n(root);
assert.equal(text.textContent, "取消");
assert.equal(title.getAttribute("title"), "关闭");
const status = createI18nStatus({
textKey: "status.idle",
textFallback: "待命",
metaKey: "status.initial.runtime.detail",
metaFallback: "准备就绪",
level: "idle",
});
assert.equal(formatUiStatusText(status), "待命");
assert.equal(formatUiStatusMeta(status), "准备就绪");
setLocale("en-US");
assert.equal(formatUiStatusText(status), "Idle");
assert.equal(formatUiStatusMeta(status), "Ready");
assert.equal(formatUiStatusText({ text: "legacy" }), "legacy");
console.log("i18n DOM/runtime tests passed");