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

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");