mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-14 02:40:45 +08:00
feat(i18n): add ui locale runtime
This commit is contained in:
95
i18n/en-US.js
Normal file
95
i18n/en-US.js
Normal 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
30
i18n/glossary.js
Normal 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
173
i18n/index.js
Normal 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
95
i18n/zh-CN.js
Normal 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": "尚未执行向量任务",
|
||||||
|
};
|
||||||
@@ -28,6 +28,10 @@
|
|||||||
"test:indexeddb-migration": "node tests/indexeddb-migration.mjs",
|
"test:indexeddb-migration": "node tests/indexeddb-migration.mjs",
|
||||||
"test:native-layout-parity": "node tests/native-layout-parity.mjs",
|
"test:native-layout-parity": "node tests/native-layout-parity.mjs",
|
||||||
"test:trivial-input": "node tests/trivial-user-input.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:graph-layout": "node tests/perf/graph-layout-bench.mjs",
|
||||||
"bench:persist-delta": "node tests/perf/persist-delta-bench.mjs",
|
"bench:persist-delta": "node tests/perf/persist-delta-bench.mjs",
|
||||||
"bench:persist-load": "node tests/perf/persist-load-bench.mjs",
|
"bench:persist-load": "node tests/perf/persist-load-bench.mjs",
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export const defaultSettings = {
|
|||||||
backgroundMaintenanceMaxQueueItems: 24,
|
backgroundMaintenanceMaxQueueItems: 24,
|
||||||
|
|
||||||
// UI 面板
|
// UI 面板
|
||||||
|
uiLocale: "auto",
|
||||||
noticeDisplayMode: "normal",
|
noticeDisplayMode: "normal",
|
||||||
panelTheme: "crimson",
|
panelTheme: "crimson",
|
||||||
graphLocalStorageMode: "auto",
|
graphLocalStorageMode: "auto",
|
||||||
|
|||||||
138
tests/i18n-boundary.mjs
Normal file
138
tests/i18n-boundary.mjs
Normal 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
47
tests/i18n-catalog.mjs
Normal 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
138
tests/i18n-dom.mjs
Normal 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");
|
||||||
Reference in New Issue
Block a user