feat(state): 增加响应式多语言机制(by AI)

This commit is contained in:
Star 2026-08-15 21:55:18 +08:00
parent fd6a4c4e23
commit fa76660e75
9 changed files with 228 additions and 42 deletions

View File

@ -1,5 +1,14 @@
# CHANGELOG
## 未发布
### 新增
- **响应式多语言**: 新增 `Cookie``Lang` 内置状态,`SetTranslator` 支持同步字符串与异步 Promise并通过原有 State 依赖机制刷新翻译 binding。
- **翻译 binding**: 支持静态文本、静态属性和动态表达式结果中的 `{#...#}`,并恢复重新插入文本节点的订阅。
### 安全性
- **异步隔离**: 翻译请求按语言和原文去重,丢弃旧语言返回值,并隔离 translator 的同步异常。
## v1.0.26 (2026-08-14)
### 修复

View File

@ -14,6 +14,13 @@
* `obj.__watch(key, cb)`: 建立 key -> callback 的直接依赖。
* `obj.__unwatch(key, cb)`: 解除依赖。
### 内置状态
- `Hash`: 与 URL Hash 同步的响应式状态。
- `LocalStorage`: 与 `localStorage` 同步的响应式状态。
- `Cookie`: 与同路径 Cookie 同步的响应式状态,默认写入 `Path=/` 并保留 100 年。`Cookie.language` 默认使用浏览器首选语言。
- `Lang`: 翻译结果的响应式缓存,以原文为键。修改 `Lang[text]` 会自动更新依赖该翻译的 binding。
---
## 2. 指令映射全集 (AI-Ready)
@ -69,7 +76,12 @@ AI 必须根据不同的元素类型执行以下逻辑:
## 5. 国际化 (I18n) 逻辑
- **语法结构**`{# Key{param} || paramValue #}`
- **处理链路**:正则匹配 `{# ... #}` -> 提取 Key -> 注入 `||` 后的参数值 -> 调用全局 `_translator` 函数。
- **处理链路**:正则匹配 `{# ... #}` -> 提取 Key -> 注入 `||` 后的参数值 -> 读取 `Lang[Key]` -> 缓存缺失时调用 translator。
- **注册翻译器**`SetTranslator((text, args) => string | Promise<string>)`
- **同步结果**:立即显示 translator 返回的字符串。
- **异步结果**Promise 完成前显示原文;完成后写入 `Lang[text]`,由现有 State 依赖机制自动重新执行 binding。
- **请求去重**:异步翻译按 `Cookie.language + 原文` 去重;语言切换后,旧语言请求不会写入当前 `Lang`
- **表达式结果**`$text` 等指令先按原有规则求值,最终结果为包含 `{#` 的字符串时才翻译。
---

88
dist/state.js vendored
View File

@ -2,7 +2,7 @@
typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define(["exports"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.ApigoState = global.ApigoState || {}));
})(this, function(exports2) {
"use strict";
var _a, _b;
var _a, _b, _c;
const Util = {
clone: (obj) => JSON.parse(JSON.stringify(obj)),
base64: (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str))),
@ -158,11 +158,35 @@
if (oldStr === newStr || oldStr === null && newStr === void 0) return;
v === void 0 ? localStorage.removeItem(k) : localStorage.setItem(k, newStr);
});
const readCookies = () => Object.fromEntries(document.cookie.split(";").map((item) => {
const index = item.indexOf("=");
if (index < 0) return [item.trim(), ""];
return [item.slice(0, index).trim(), item.slice(index + 1)];
}).filter(([key]) => key));
const Cookie = NewState({}, (k) => {
const value = readCookies()[k];
if (value === void 0) return void 0;
try {
return Util.safeJson(decodeURIComponent(value));
} catch (_) {
return void 0;
}
}, (k, v) => {
const value = v === void 0 ? "" : encodeURIComponent(JSON.stringify(v));
const maxAge = v === void 0 ? 0 : 31536e5;
document.cookie = `${k}=${value}; Path=/; Max-Age=${maxAge}; SameSite=Lax`;
});
const Lang = NewState({});
if (Cookie.language === void 0 && typeof navigator !== "undefined") {
Cookie.language = ((_c = navigator.languages) == null ? void 0 : _c[0]) || navigator.language || "en-US";
}
const State = NewState({
exitBlocks: 0
});
globalThis.Hash = Hash;
globalThis.LocalStorage = LocalStorage;
globalThis.Cookie = Cookie;
globalThis.Lang = Lang;
globalThis.State = State;
let _disableRunCodeError = false;
const setDisableRunCodeError = (value) => {
@ -300,6 +324,34 @@
return text.replace(/\{(.+?)\}/g, (match, key) => args.hasOwnProperty(key) ? args[key] : match);
};
const SetTranslator = (fn) => _translator = fn;
const _translationLoads = /* @__PURE__ */ new Map();
const _isPromise = (value) => value && typeof value.then === "function";
const _fillTranslationArgs = (text, args) => {
if (!text || typeof text !== "string") return text;
return text.replace(/\{(.+?)\}/g, (match, key) => Object.prototype.hasOwnProperty.call(args, key) ? args[key] : match);
};
const _getTranslation = (text, args) => {
var _a2;
const cached = Lang[text];
if (Object.prototype.hasOwnProperty.call(Lang, text)) return _fillTranslationArgs(cached, args);
const language = (_a2 = globalThis.Cookie) == null ? void 0 : _a2.language;
const loadKey = `${language}\0${text}`;
if (_translationLoads.has(loadKey)) return text;
let result;
try {
result = _translator(text, args);
} catch (_) {
return text;
}
if (!_isPromise(result)) return typeof result === "string" ? result : text;
const loading = Promise.resolve(result).then((translated) => {
var _a3;
if (((_a3 = globalThis.Cookie) == null ? void 0 : _a3.language) === language && typeof translated === "string") Lang[text] = translated;
return translated;
}).catch(() => text).finally(() => _translationLoads.delete(loadKey));
_translationLoads.set(loadKey, loading);
return text;
};
const _translate = (text) => {
if (!text || typeof text !== "string" || !text.includes("{#")) return text;
return text.replace(/\{#(.+?)#\}/g, (m, content) => {
@ -309,7 +361,7 @@
const matches = parts[0].match(/\{(.+?)\}/g);
if (matches) matches.forEach((match, i) => args[match.substring(1, match.length - 1)] = parts[i + 1] || "");
}
return _translator(parts[0], args);
return _getTranslation(parts[0], args);
});
};
if (typeof document !== "undefined") {
@ -341,6 +393,7 @@
} catch (e) {
}
}
if (typeof result === "string" && result.includes("{#")) result = _translate(result);
_setActiveBinding(null);
binding.lastResult = result;
if (binding.prop) {
@ -528,7 +581,7 @@
if (node.tagName === "TEMPLATE") {
["$if", "$each", "st-if", "st-each", "$$if", "$$each", "st-st-if", "st-st-each"].forEach((n) => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
} else {
attrs = Array.from(node.attributes).filter((a) => (a.name.startsWith("$") || a.name.startsWith("st-")) && !["$if", "$each", "st-if", "st-each", "$$if", "$$each", "st-st-if", "st-st-each"].includes(a.name) || a.name.includes("."));
attrs = Array.from(node.attributes).filter((a) => (a.name.startsWith("$") || a.name.startsWith("st-")) && !["$if", "$each", "st-if", "st-each", "$$if", "$$each", "st-st-if", "st-st-each"].includes(a.name) || a.name.includes(".") || !a.name.startsWith("$") && !a.name.startsWith("st-") && !a.name.startsWith(".") && !a.name.startsWith("on") && a.name !== "bind" && a.value.includes("{#"));
}
if (node._thisObj && scanObj.thisObj && node._thisObj !== scanObj.thisObj) node._thisObj.parent = scanObj.thisObj;
if (!node._thisObj) node._thisObj = scanObj.thisObj || null;
@ -567,10 +620,7 @@
tpl = node.textContent;
node.textContent = "";
}
if (tpl) {
tpl = _translate(tpl);
_initBinding({ node, attr: realAttrName, tpl, exp });
}
if (tpl) _initBinding({ node, attr: realAttrName, tpl, exp });
}
});
if (node._hasOnLoad || node._componentInitialized) Promise.resolve().then(() => node.dispatchEvent(new Event("load", { bubbles: false })));
@ -579,22 +629,18 @@
}
const _scanTree = (node, scanObj = {}) => {
if (node.nodeType === 3) {
if (node._bindings) {
node._states = /* @__PURE__ */ new Set();
node._bindings.forEach((binding) => _updateBinding({ node, ...binding }));
return;
}
if (node._stTranslated) return;
const translated = _translate(node.textContent);
if (translated !== node.textContent) node.textContent = translated;
if (node.textContent.includes("{#")) _initBinding({ node, attr: "text", tpl: node.textContent, exp: 0 });
node._stTranslated = true;
return;
}
if (node.nodeType !== 1) return;
if (!node._stTranslated) {
Array.from(node.attributes).forEach((attr) => {
if (!attr.name.startsWith("$") && !attr.name.startsWith("st-") && !attr.name.startsWith(".")) {
const translated = _translate(attr.value);
if (translated !== attr.value) attr.value = translated;
}
});
node._stTranslated = true;
}
if (!node._stTranslated) node._stTranslated = true;
if (node.tagName !== "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("$each") || node.hasAttribute("st-if") || node.hasAttribute("st-each") || node.hasAttribute("$$if") || node.hasAttribute("$$each") || node.hasAttribute("st-st-if") || node.hasAttribute("st-st-each"))) {
const template = document.createElement("TEMPLATE");
const attrs = Array.from(node.attributes).filter((attr) => ["$if", "$each", "st-if", "st-each", "$$if", "$$each", "st-st-if", "st-st-each"].includes(attr.name) || (node.hasAttribute("$each") || node.hasAttribute("st-each") || node.hasAttribute("$$each") || node.hasAttribute("st-st-each")) && ["as", "index"].includes(attr.name));
@ -654,8 +700,8 @@
nodes.forEach((child) => _scanTree(child, { thisObj: node._thisObj ?? scanObj.thisObj, extendVars: { ...node._ref } }));
};
const _unbindTree = (node) => {
if (node.nodeType !== 1) return;
if (node._hasOnUnload) node.dispatchEvent(new Event("unload", { bubbles: false }));
if (node.nodeType !== 1 && !node._states) return;
if (node.nodeType === 1 && node._hasOnUnload) node.dispatchEvent(new Event("unload", { bubbles: false }));
if (node._states) node._states.forEach((mappings) => {
for (const [key, bindingSet] of mappings) {
for (const binding of bindingSet) {
@ -694,7 +740,9 @@
exports2.$ = $;
exports2.$$ = $$;
exports2.Component = Component;
exports2.Cookie = Cookie;
exports2.Hash = Hash;
exports2.Lang = Lang;
exports2.LocalStorage = LocalStorage;
exports2.NewState = NewState;
exports2.SetTranslator = SetTranslator;

2
dist/state.min.js vendored

File diff suppressed because one or more lines are too long

View File

@ -31,6 +31,29 @@ export const LocalStorage = NewState({}, k => Util.safeJson(localStorage.getItem
v === undefined ? localStorage.removeItem(k) : localStorage.setItem(k, newStr)
})
const readCookies = () => Object.fromEntries(document.cookie.split(';').map(item => {
const index = item.indexOf('=')
if (index < 0) return [item.trim(), '']
return [item.slice(0, index).trim(), item.slice(index + 1)]
}).filter(([key]) => key))
export const Cookie = NewState({}, k => {
const value = readCookies()[k]
if (value === undefined) return undefined
try { return Util.safeJson(decodeURIComponent(value)) }
catch (_) { return undefined }
}, (k, v) => {
const value = v === undefined ? '' : encodeURIComponent(JSON.stringify(v))
const maxAge = v === undefined ? 0 : 3153600000
document.cookie = `${k}=${value}; Path=/; Max-Age=${maxAge}; SameSite=Lax`
})
export const Lang = NewState({})
if (Cookie.language === undefined && typeof navigator !== 'undefined') {
Cookie.language = navigator.languages?.[0] || navigator.language || 'en-US'
}
export const State = NewState({
exitBlocks: 0
});
@ -38,6 +61,8 @@ export const State = NewState({
// 全局挂载
globalThis.Hash = Hash;
globalThis.LocalStorage = LocalStorage;
globalThis.Cookie = Cookie;
globalThis.Lang = Lang;
globalThis.State = State;
let _disableRunCodeError = false;

View File

@ -5,7 +5,7 @@
import { Util, $, $$ } from './utils.js';
import { NewState, _setActiveBinding, _setNoWriteBack, _onNotifyUpdate } from './observer.js';
import { _runCode, _returnCode, setDisableRunCodeError } from './core.js';
import { Lang, _runCode, _returnCode, setDisableRunCodeError } from './core.js';
// --- Component Logic ---
const _components = new Map();
@ -132,6 +132,31 @@ let _translator = (text, args) => {
};
export const SetTranslator = (fn) => _translator = fn;
const _translationLoads = new Map();
const _isPromise = value => value && typeof value.then === 'function';
const _fillTranslationArgs = (text, args) => {
if (!text || typeof text !== 'string') return text;
return text.replace(/\{(.+?)\}/g, (match, key) => Object.prototype.hasOwnProperty.call(args, key) ? args[key] : match);
};
const _getTranslation = (text, args) => {
const cached = Lang[text];
if (Object.prototype.hasOwnProperty.call(Lang, text)) return _fillTranslationArgs(cached, args);
const language = globalThis.Cookie?.language;
const loadKey = `${language}\0${text}`;
if (_translationLoads.has(loadKey)) return text;
let result;
try { result = _translator(text, args); }
catch (_) { return text; }
if (!_isPromise(result)) return typeof result === 'string' ? result : text;
const loading = Promise.resolve(result).then(translated => {
if (globalThis.Cookie?.language === language && typeof translated === 'string') Lang[text] = translated;
return translated;
}).catch(() => text).finally(() => _translationLoads.delete(loadKey));
_translationLoads.set(loadKey, loading);
return text;
};
const _translate = (text) => {
if (!text || typeof text !== 'string' || !text.includes('{#')) return text;
return text.replace(/\{#(.+?)#\}/g, (m, content) => {
@ -141,7 +166,7 @@ const _translate = (text) => {
const matches = parts[0].match(/\{(.+?)\}/g);
if (matches) matches.forEach((match, i) => args[match.substring(1, match.length - 1)] = parts[i + 1] || '');
}
return _translator(parts[0], args);
return _getTranslation(parts[0], args);
});
};
@ -174,6 +199,7 @@ export function _updateBinding(binding) {
if (binding.exp === 2 && typeof result === 'string') {
try { result = _returnCode(result, { thisNode: node }, node._thisObj || node, node._ref || null); } catch (e) { }
}
if (typeof result === 'string' && result.includes('{#')) result = _translate(result);
_setActiveBinding(null);
binding.lastResult = result;
@ -361,7 +387,10 @@ export function _parseNode(node, scanObj) {
if (node.tagName === 'TEMPLATE') {
['$if', '$each', 'st-if', 'st-each', '$$if', '$$each', 'st-st-if', 'st-st-each'].forEach(n => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
} else {
attrs = Array.from(node.attributes).filter(a => (a.name.startsWith('$') || a.name.startsWith('st-')) && !['$if', '$each', 'st-if', 'st-each', '$$if', '$$each', 'st-st-if', 'st-st-each'].includes(a.name) || a.name.includes('.'));
attrs = Array.from(node.attributes).filter(a =>
(a.name.startsWith('$') || a.name.startsWith('st-')) && !['$if', '$each', 'st-if', 'st-each', '$$if', '$$each', 'st-st-if', 'st-st-each'].includes(a.name) ||
a.name.includes('.') ||
!a.name.startsWith('$') && !a.name.startsWith('st-') && !a.name.startsWith('.') && !a.name.startsWith('on') && a.name !== 'bind' && a.value.includes('{#'));
}
@ -399,7 +428,7 @@ export function _parseNode(node, scanObj) {
setDisableRunCodeError(false); _setNoWriteBack(null);
});
} else if (realAttrName === 'text' && !tpl) { tpl = node.textContent; node.textContent = ''; }
if (tpl) { tpl = _translate(tpl); _initBinding({ node, attr: realAttrName, tpl, exp }); }
if (tpl) _initBinding({ node, attr: realAttrName, tpl, exp });
}
});
@ -410,23 +439,19 @@ export function _parseNode(node, scanObj) {
export const _scanTree = (node, scanObj = {}) => {
if (node.nodeType === 3) {
if (node._bindings) {
node._states = new Set();
node._bindings.forEach(binding => _updateBinding({ node, ...binding }));
return;
}
if (node._stTranslated) return;
const translated = _translate(node.textContent);
if (translated !== node.textContent) node.textContent = translated;
if (node.textContent.includes('{#')) _initBinding({ node, attr: 'text', tpl: node.textContent, exp: 0 });
node._stTranslated = true;
return;
}
if (node.nodeType !== 1) return;
if (!node._stTranslated) {
Array.from(node.attributes).forEach(attr => {
if (!attr.name.startsWith('$') && !attr.name.startsWith('st-') && !attr.name.startsWith('.')) {
const translated = _translate(attr.value);
if (translated !== attr.value) attr.value = translated;
}
});
node._stTranslated = true;
}
if (!node._stTranslated) node._stTranslated = true;
if (node.tagName !== 'TEMPLATE' && (node.hasAttribute('$if') || node.hasAttribute('$each') || node.hasAttribute('st-if') || node.hasAttribute('st-each') || node.hasAttribute('$$if') || node.hasAttribute('$$each') || node.hasAttribute('st-st-if') || node.hasAttribute('st-st-each'))) {
const template = document.createElement('TEMPLATE');
@ -493,8 +518,8 @@ export const _scanTree = (node, scanObj = {}) => {
};
export const _unbindTree = (node) => {
if (node.nodeType !== 1) return;
if (node._hasOnUnload) node.dispatchEvent(new Event('unload', { bubbles: false }));
if (node.nodeType !== 1 && !node._states) return;
if (node.nodeType === 1 && node._hasOnUnload) node.dispatchEvent(new Event('unload', { bubbles: false }));
if (node._states) node._states.forEach(mappings => {
for (const [key, bindingSet] of mappings) {
for (const binding of bindingSet) { if (binding.node === node) bindingSet.delete(binding); }

View File

@ -38,5 +38,5 @@ if (typeof document !== 'undefined') {
export { NewState, _setActiveBinding as setActiveBinding, _onNotifyUpdate as onNotifyUpdate } from './observer.js';
export { Component, SetTranslator } from './engine.js';
export { $, $$, Util } from './utils.js';
export { Hash, LocalStorage, State, _runCode, _returnCode } from './core.js';
export { Cookie, Hash, Lang, LocalStorage, State, _runCode, _returnCode } from './core.js';
export const __unsafeRefreshState = _scanTree;

View File

@ -1,6 +1,6 @@
// test/core.test.js
window.testCore = async function() {
const { _runCode, _returnCode } = ApigoState;
const { Cookie, _runCode, _returnCode } = ApigoState;
console.log('Testing core.js...');
const vars = { a: 1, b: 2 };
const extendVars = { c: 3 };
@ -11,6 +11,12 @@ window.testCore = async function() {
// _returnCode
if (_returnCode('a + b', vars, {}) !== 3) throw new Error('_returnCode simple failed');
if (_returnCode('${a} + ${b}', vars, {}) !== '1 + 2') throw new Error('_returnCode template failed');
Cookie.stateCookieTest = 'works';
if (Cookie.stateCookieTest !== 'works') throw new Error('Cookie state read/write failed');
Cookie.stateCookieTest = undefined;
if (Cookie.stateCookieTest !== undefined) throw new Error('Cookie state removal failed');
if (!Cookie.language) throw new Error('Cookie.language default was not initialized');
console.log('core.js tests passed');
return true;

View File

@ -1,6 +1,6 @@
// test/dom.test.js
window.testDom = async function() {
const { __unsafeRefreshState, $, $$, NewState, Component } = ApigoState;
const { __unsafeRefreshState, $, $$, Lang, NewState, SetTranslator, Component } = ApigoState;
console.log('Testing dom.js...');
const wait = () => new Promise(r => setTimeout(r, 10));
@ -145,6 +145,67 @@ window.testDom = async function() {
await wait();
if (!$('#nested-inner')) throw new Error('nested $$if failed: should be visible after update');
// 8. Translation bindings use synchronous source text while loading, then
// update through the reactive Lang state without a second binding system.
const translations = { Hello: '你好', Tooltip: '提示', Yes: '是', No: '否', Result: '结果', Shared: '共享' };
const translationCalls = {};
SetTranslator(text => {
translationCalls[text] = (translationCalls[text] || 0) + 1;
return new Promise(resolve => setTimeout(() => resolve(translations[text] || text), 5));
});
state.flag = true;
state.label = '{#Result#}';
window.state = state;
document.documentElement._thisObj = { state };
document.body.innerHTML = `
<strong id="translated-text">{#Hello#}</strong>
<button id="translated-attr" title="{#Tooltip#}"></button>
<div id="translated-expression" $text="state.flag ? '{#Yes#}' : '{#No#}'"></div>
<div id="translated-result" $text="state.label"></div>
<span class="translated-shared">{#Shared#}</span><span class="translated-shared">{#Shared#}</span>
`;
__unsafeRefreshState(document.documentElement);
if ($('#translated-text').textContent !== 'Hello') throw new Error('async static translation must initially use source text');
if ($('#translated-expression').textContent !== 'Yes') throw new Error('async expression translation must initially use source text');
await wait();
if ($('#translated-text').textContent !== '你好') throw new Error('static text translation did not react to Lang');
if ($('#translated-attr').title !== '提示') throw new Error('static attribute translation did not react to Lang');
if ($('#translated-expression').textContent !== '是') throw new Error('expression translation did not react to Lang');
if ($('#translated-result').textContent !== '结果') throw new Error('expression result translation did not react to Lang');
if (translationCalls.Shared !== 1) throw new Error('concurrent translation requests must be deduplicated');
Lang.Yes = '对';
await wait();
if ($('#translated-expression').textContent !== '对') throw new Error('direct Lang updates must refresh existing bindings');
// A translated text node must restore its Lang subscription when the same
// node is removed and inserted again.
const translatedTextNode = $('#translated-text').firstChild;
translatedTextNode.remove();
await wait();
$('#translated-text').appendChild(translatedTextNode);
await wait();
Lang.Hello = '您好';
await wait();
if ($('#translated-text').textContent !== '您好') throw new Error('reinserted translation text node did not restore its Lang binding');
// A synchronous translator failure must fall back to source text and leave
// dependency tracking usable for subsequent bindings.
SetTranslator(() => { throw new Error('translator failure'); });
document.body.innerHTML = '<span id="failed-translation">{#Failure#}</span><span id="tracked-translation">{#Tracked#}</span>';
__unsafeRefreshState(document.documentElement);
if ($('#failed-translation').textContent !== 'Failure') throw new Error('synchronous translator failure must use source text');
Lang.Tracked = '已跟踪';
await wait();
if ($('#tracked-translation').textContent !== '已跟踪') throw new Error('translator failure polluted active binding tracking');
// Native event and plain bind attributes are not translation bindings.
document.body.innerHTML = '<div id="translation-attr-boundary" oncustom="{#Event#}" bind="{#Bind#}" aria-label="{#Label#}"></div>';
__unsafeRefreshState(document.documentElement);
const attrBoundary = $('#translation-attr-boundary');
if (attrBoundary.getAttribute('oncustom') !== '{#Event#}') throw new Error('plain event attribute was consumed as a translation binding');
if (attrBoundary.getAttribute('bind') !== '{#Bind#}') throw new Error('plain bind attribute was consumed as a translation binding');
if (attrBoundary.getAttribute('aria-label') !== 'Label') throw new Error('ordinary static attribute translation failed');
console.log('dom.js tests passed');
return true;
}