Compare commits

..

No commits in common. "main" and "v1.0.23" have entirely different histories.

13 changed files with 71 additions and 441 deletions

View File

@ -1,28 +1,5 @@
# CHANGELOG
## 未发布
### 新增
- **响应式多语言**: 新增 `Cookie``Lang` 内置状态,`SetTranslator` 支持同步字符串与异步 Promise并通过原有 State 依赖机制刷新翻译 binding。
- **翻译 binding**: 支持静态文本、静态属性和动态表达式结果中的 `{#...#}`,并恢复重新插入文本节点的订阅。
### 安全性
- **异步隔离**: 翻译请求按语言和原文去重,丢弃旧语言返回值,并隔离 translator 的同步异常。
### 修复
- **组件绑定事件**: 容器或自定义组件的 `$bind` 不再消费嵌套表单控件冒泡的 `change` 事件,避免父级状态被子控件值覆盖。
## v1.0.26 (2026-08-14)
### 修复
- **条件分支组件生命周期**: `$if` 分支重新显示时创建全新节点,确保已卸载组件会重新初始化,修复编辑器在切换后偶发空白的问题。
- **自定义事件参数兼容**: 原始类型的 `CustomEvent.detail` 不再被展开为事件参数,仍可通过 `event.detail` 正常读取。
## v1.0.25 (2026-07-13)
### 修复
- **Select 双向绑定时序**: 延后绑定值回写,确保模板动态生成选项时保留状态中的目标值,而非被浏览器默认选项覆盖。
## v1.0.23 (2026-07-06)
### 修复

View File

@ -14,13 +14,6 @@
* `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)
@ -76,12 +69,7 @@ AI 必须根据不同的元素类型执行以下逻辑:
## 5. 国际化 (I18n) 逻辑
- **语法结构**`{# Key{param} || paramValue #}`
- **处理链路**:正则匹配 `{# ... #}` -> 提取 Key -> 注入 `||` 后的参数值 -> 读取 `Lang[Key]` -> 缓存缺失时调用 translator。
- **注册翻译器**`SetTranslator((text, args) => string | Promise<string>)`
- **同步结果**:立即显示 translator 返回的字符串。
- **异步结果**Promise 完成前显示原文;完成后写入 `Lang[text]`,由现有 State 依赖机制自动重新执行 binding。
- **请求去重**:异步翻译按 `Cookie.language + 原文` 去重;语言切换后,旧语言请求不会写入当前 `Lang`
- **表达式结果**`$text` 等指令先按原有规则求值,最终结果为包含 `{#` 的字符串时才翻译。
- **处理链路**:正则匹配 `{# ... #}` -> 提取 Key -> 注入 `||` 后的参数值 -> 调用全局 `_translator` 函数。
---

150
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, _c;
var _a, _b;
const Util = {
clone: (obj) => JSON.parse(JSON.stringify(obj)),
base64: (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str))),
@ -158,47 +158,11 @@
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
});
State._dirtySources = /* @__PURE__ */ new Set();
State.setDirtySource = (source, dirty) => {
if (dirty) State._dirtySources.add(String(source));
else State._dirtySources.delete(String(source));
State.exitBlocks = State._dirtySources.size;
};
if (typeof window !== "undefined") window.addEventListener("beforeunload", (event) => {
if (State.exitBlocks > 0) {
event.preventDefault();
event.returnValue = "";
}
});
globalThis.Hash = Hash;
globalThis.LocalStorage = LocalStorage;
globalThis.Cookie = Cookie;
globalThis.Lang = Lang;
globalThis.State = State;
let _disableRunCodeError = false;
const setDisableRunCodeError = (value) => {
@ -228,15 +192,6 @@
}
const _components = /* @__PURE__ */ new Map();
const _pendingTemplates = [];
globalThis.__eventRegistry || (globalThis.__eventRegistry = /* @__PURE__ */ new WeakMap());
const _registerNodeEvent = (node, eventName) => {
let events = globalThis.__eventRegistry.get(node);
if (!events) {
events = /* @__PURE__ */ new Set();
globalThis.__eventRegistry.set(node, events);
}
events.add(eventName);
};
const Component = {
getTemplate: (name) => document.querySelector(`template[component="${name.toUpperCase()}"]`),
register: (name, setupFunc, templateNode = null, ...globalNodes) => {
@ -292,17 +247,9 @@
Array.from(sourceNodes).forEach((child) => target.appendChild(child));
if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists);
}
const _findSlotPlaceholders = (root) => {
const placeholders = [...root.querySelectorAll("[slot-id]")];
root.querySelectorAll("template").forEach((template) => {
placeholders.push(..._findSlotPlaceholders(template.content));
});
return placeholders;
};
function _makeComponent(name, node, scanObj, exists = {}) {
if (exists[name]) return;
exists[name] = true;
const parentThis = scanObj.thisObj;
if (scanObj.thisObj) {
Array.from(node.attributes).forEach((attr) => {
if ((attr.name.startsWith("$") || attr.name.startsWith("st-")) && attr.value.includes("this.")) {
@ -326,7 +273,7 @@
if (tplnode.childNodes.length) {
const rootNode = tplnode.children[0];
if (rootNode) _mergeNode(rootNode, node, scanObj, exists);
_findSlotPlaceholders(node).forEach((placeholder) => {
$$(node, "[slot-id]").forEach((placeholder) => {
const slotName = placeholder.getAttribute("slot-id");
if (slots[slotName]) {
placeholder.removeAttribute("slot-id");
@ -337,42 +284,12 @@
}
}
if (componentFunc) componentFunc(node);
node._thisObj = node;
if (parentThis && parentThis !== node) node._thisObj.parent = parentThis;
}
let _translator = (text, args) => {
if (!text || typeof text !== "string") return text;
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) => {
@ -382,7 +299,7 @@
const matches = parts[0].match(/\{(.+?)\}/g);
if (matches) matches.forEach((match, i) => args[match.substring(1, match.length - 1)] = parts[i + 1] || "");
}
return _getTranslation(parts[0], args);
return _translator(parts[0], args);
});
};
if (typeof document !== "undefined") {
@ -414,7 +331,6 @@
} catch (e) {
}
}
if (typeof result === "string" && result.includes("{#")) result = _translate(result);
_setActiveBinding(null);
binding.lastResult = result;
if (binding.prop) {
@ -444,14 +360,12 @@
if (attr === "if") {
if (result) {
if (!node._renderedNodes || node._renderedNodes.length === 0) {
const rendered = node._children.map((child) => {
const cloned = child.cloneNode(true);
node.parentNode.insertBefore(cloned, node);
cloned._ref = { ...node._ref };
cloned._thisObj = node._thisObj;
return cloned;
node._children.forEach((child) => {
node.parentNode.insertBefore(child, node);
child._ref = { ...node._ref };
child._thisObj = node._thisObj;
});
node._renderedNodes = [rendered];
node._renderedNodes = [node._children];
}
} else {
_clearRenderedNodes(node);
@ -527,7 +441,7 @@
} else if (node.type === "radio") {
if (node.checked !== (node.value === String(result ?? ""))) node.checked = node.value === String(result ?? "");
} else if ("value" in node && node.type !== "file") {
setTimeout(() => {
Promise.resolve().then(() => {
if (node.value !== String(result ?? "")) node.value = result;
});
} else if (node.isContentEditable) {
@ -602,7 +516,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(".") || !a.name.startsWith("$") && !a.name.startsWith("st-") && !a.name.startsWith(".") && !a.name.startsWith("on") && a.name !== "bind" && a.value.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("."));
}
if (node._thisObj && scanObj.thisObj && node._thisObj !== scanObj.thisObj) node._thisObj.parent = scanObj.thisObj;
if (!node._thisObj) node._thisObj = scanObj.thisObj || null;
@ -621,19 +535,12 @@
if (eventName === "update") node._hasOnUpdate = true;
if (eventName === "load" && !["BODY", "IMG", "IFRAME"].includes(node.tagName)) node._hasOnLoad = true;
if (eventName === "unload" && !["BODY", "IMG", "IFRAME"].includes(node.tagName)) node._hasOnUnload = true;
_registerNodeEvent(node, eventName);
node.addEventListener(eventName, (e) => {
const detailVars = e.detail && typeof e.detail === "object" && !Array.isArray(e.detail) ? e.detail : {};
_runCode(tpl, { event: e, thisNode: node, ...detailVars }, scanObj.thisObj || node, node._ref || {});
});
node.addEventListener(eventName, (e) => _runCode(tpl, { event: e, thisNode: node, ...e.detail || {} }, scanObj.thisObj || node, node._ref || {}));
} else {
if (realAttrName === "bind") {
const isTextInput = ["INPUT", "TEXTAREA"].includes(node.tagName) && ["textarea", "text", "password", "email", "number", "search", "url", "tel"].includes(node.type || "text") || node.isContentEditable;
const bindEventName = isTextInput ? "input" : "change";
_registerNodeEvent(node, bindEventName);
node.addEventListener(bindEventName, (e) => {
if (e.target !== node) return;
let newVal = node.isContentEditable ? e.target.innerHTML : node.type === "checkbox" ? e.target.checked : node.type === "file" ? e.target.files : e.target.value ?? e.detail;
node.addEventListener(isTextInput ? "input" : "change", (e) => {
let newVal = node.isContentEditable ? e.target.innerHTML : node.type === "checkbox" ? e.target.checked : e.target.files || e.target.value || e.detail;
_setNoWriteBack(node);
setDisableRunCodeError(true);
if (node.type === "checkbox" && node._checkboxMultiMode) _runCode(`!!checked ? (!${tpl}.includes(val) && ${tpl}.push(val)) : (index = ${tpl}.indexOf(val), index > -1 && ${tpl}.splice(index, 1))`, { val: node.value, checked: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
@ -645,7 +552,10 @@
tpl = node.textContent;
node.textContent = "";
}
if (tpl) _initBinding({ node, attr: realAttrName, tpl, exp });
if (tpl) {
tpl = _translate(tpl);
_initBinding({ node, attr: realAttrName, tpl, exp });
}
}
});
if (node._hasOnLoad || node._componentInitialized) Promise.resolve().then(() => node.dispatchEvent(new Event("load", { bubbles: false })));
@ -654,18 +564,22 @@
}
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;
if (node.textContent.includes("{#")) _initBinding({ node, attr: "text", tpl: node.textContent, exp: 0 });
const translated = _translate(node.textContent);
if (translated !== node.textContent) node.textContent = translated;
node._stTranslated = true;
return;
}
if (node.nodeType !== 1) return;
if (!node._stTranslated) node._stTranslated = true;
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.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));
@ -722,11 +636,11 @@
if (scanObj.extendVars) Object.assign(node._ref, scanObj.extendVars);
_parseNode(node, { ...scanObj });
const nodes = [...node.childNodes || []];
nodes.forEach((child) => _scanTree(child, { thisObj: node._thisObj ?? scanObj.thisObj, extendVars: { ...node._ref } }));
nodes.forEach((child) => _scanTree(child, { thisObj: scanObj.thisObj, extendVars: { ...node._ref } }));
};
const _unbindTree = (node) => {
if (node.nodeType !== 1 && !node._states) return;
if (node.nodeType === 1 && node._hasOnUnload) node.dispatchEvent(new Event("unload", { bubbles: false }));
if (node.nodeType !== 1) return;
if (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) {
@ -765,9 +679,7 @@
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

@ -1,6 +1,6 @@
{
"name": "@apigo.cc/state",
"version": "1.0.27",
"version": "1.0.23",
"type": "module",
"main": "dist/state.js",
"module": "dist/state.js",
@ -9,7 +9,6 @@
],
"scripts": {
"dev": "vite",
"prebuild": "node ../.profile/scripts/sync-readme-versions.mjs",
"build": "vite build",
"test": "playwright test",
"pub": "node scripts/publish.js"

View File

@ -4,11 +4,11 @@ export default defineConfig({
testDir: './test',
testMatch: '**/*.spec.js',
use: {
baseURL: 'http://127.0.0.1:5173',
baseURL: 'http://127.0.0.1:8081',
},
webServer: {
command: 'npx vite --port 5173 --host 127.0.0.1',
url: 'http://127.0.0.1:5173/test/index.html',
command: 'npx vite --port 8081 --host 127.0.0.1',
url: 'http://127.0.0.1:8081/test/index.html',
timeout: 180000,
reuseExistingServer: !process.env.CI,
},

View File

@ -31,29 +31,6 @@ 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
});
@ -61,8 +38,6 @@ 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,23 +5,12 @@
import { Util, $, $$ } from './utils.js';
import { NewState, _setActiveBinding, _setNoWriteBack, _onNotifyUpdate } from './observer.js';
import { Lang, _runCode, _returnCode, setDisableRunCodeError } from './core.js';
import { _runCode, _returnCode, setDisableRunCodeError } from './core.js';
// --- Component Logic ---
const _components = new Map();
const _pendingTemplates = [];
globalThis.__eventRegistry ||= new WeakMap();
const _registerNodeEvent = (node, eventName) => {
let events = globalThis.__eventRegistry.get(node);
if (!events) {
events = new Set();
globalThis.__eventRegistry.set(node, events);
}
events.add(eventName);
};
export const Component = {
getTemplate: name => document.querySelector(`template[component="${name.toUpperCase()}"]`),
register: (name, setupFunc, templateNode = null, ...globalNodes) => {
@ -81,21 +70,9 @@ export function _mergeNode(from, to, scanObj, exists = {}) {
if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists);
}
// Slots may be declared inside a rendering template (for example `$each`).
// `querySelectorAll` does not cross a <template>'s content boundary, so walk
// those fragments explicitly while leaving ordinary DOM traversal unchanged.
const _findSlotPlaceholders = root => {
const placeholders = [...root.querySelectorAll('[slot-id]')]
root.querySelectorAll('template').forEach(template => {
placeholders.push(..._findSlotPlaceholders(template.content))
})
return placeholders
}
export function _makeComponent(name, node, scanObj, exists = {}) {
if (exists[name]) return;
exists[name] = true;
const parentThis = scanObj.thisObj;
if (scanObj.thisObj) {
Array.from(node.attributes).forEach(attr => {
if ((attr.name.startsWith('$') || attr.name.startsWith('st-')) && attr.value.includes('this.')) {
@ -119,7 +96,7 @@ export function _makeComponent(name, node, scanObj, exists = {}) {
if (tplnode.childNodes.length) {
const rootNode = tplnode.children[0];
if (rootNode) _mergeNode(rootNode, node, scanObj, exists);
_findSlotPlaceholders(node).forEach(placeholder => {
$$(node, '[slot-id]').forEach(placeholder => {
const slotName = placeholder.getAttribute('slot-id');
if (slots[slotName]) {
placeholder.removeAttribute('slot-id');
@ -130,10 +107,6 @@ export function _makeComponent(name, node, scanObj, exists = {}) {
}
}
if (componentFunc) componentFunc(node);
// A component always owns `this`; the creating component is available as
// `this.parent`. Dynamic nodes may have inherited their creator's context.
node._thisObj = node;
if (parentThis && parentThis !== node) node._thisObj.parent = parentThis;
}
// --- DOM Engine Logic ---
@ -143,31 +116,6 @@ 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) => {
@ -177,7 +125,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 _getTranslation(parts[0], args);
return _translator(parts[0], args);
});
};
@ -210,7 +158,6 @@ 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;
@ -239,14 +186,12 @@ export function _updateBinding(binding) {
if (attr === 'if') {
if (result) {
if (!node._renderedNodes || node._renderedNodes.length === 0) {
const rendered = node._children.map(child => {
const cloned = child.cloneNode(true);
node.parentNode.insertBefore(cloned, node);
cloned._ref = { ...node._ref };
cloned._thisObj = node._thisObj;
return cloned;
node._children.forEach(child => {
node.parentNode.insertBefore(child, node);
child._ref = { ...node._ref };
child._thisObj = node._thisObj;
});
node._renderedNodes = [rendered];
node._renderedNodes = [node._children];
}
} else {
_clearRenderedNodes(node);
@ -319,10 +264,7 @@ export function _updateBinding(binding) {
} else if (node.type === 'radio') {
if (node.checked !== (node.value === String(result ?? ''))) node.checked = (node.value === String(result ?? ''));
} else if ('value' in node && node.type !== 'file') {
// A select may receive its bound value before template-rendered
// options exist. Run after the current render turn so adding options
// cannot replace the model value with the browser's default option.
setTimeout(() => { if (node.value !== String(result ?? '')) node.value = result; });
Promise.resolve().then(() => { if (node.value !== String(result ?? '')) node.value = result; });
} else if (node.isContentEditable) {
if (node.innerHTML !== String(result ?? '')) node.innerHTML = result;
}
@ -398,10 +340,7 @@ 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('.') ||
!a.name.startsWith('$') && !a.name.startsWith('st-') && !a.name.startsWith('.') && !a.name.startsWith('on') && a.name !== 'bind' && a.value.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('.'));
}
@ -424,26 +363,19 @@ export function _parseNode(node, scanObj) {
if (eventName === 'update') node._hasOnUpdate = true;
if (eventName === 'load' && !['BODY', 'IMG', 'IFRAME'].includes(node.tagName)) node._hasOnLoad = true;
if (eventName === 'unload' && !['BODY', 'IMG', 'IFRAME'].includes(node.tagName)) node._hasOnUnload = true;
_registerNodeEvent(node, eventName);
node.addEventListener(eventName, (e) => {
const detailVars = e.detail && typeof e.detail === 'object' && !Array.isArray(e.detail) ? e.detail : {};
_runCode(tpl, { event: e, thisNode: node, ...detailVars }, scanObj.thisObj || node, node._ref || {});
});
node.addEventListener(eventName, (e) => _runCode(tpl, { event: e, thisNode: node, ...(e.detail || {}) }, scanObj.thisObj || node, node._ref || {}));
} else {
if (realAttrName === 'bind') {
const isTextInput = (['INPUT', 'TEXTAREA'].includes(node.tagName) && ['textarea', 'text', 'password', 'email', 'number', 'search', 'url', 'tel'].includes(node.type || 'text')) || node.isContentEditable;
const bindEventName = isTextInput ? 'input' : 'change';
_registerNodeEvent(node, bindEventName);
node.addEventListener(bindEventName, (e) => {
if (e.target !== node) return;
let newVal = node.isContentEditable ? e.target.innerHTML : (node.type === 'checkbox' ? e.target.checked : (node.type === 'file' ? e.target.files : (e.target.value ?? e.detail)));
node.addEventListener(isTextInput ? 'input' : 'change', (e) => {
let newVal = node.isContentEditable ? e.target.innerHTML : (node.type === 'checkbox' ? e.target.checked : e.target.files || e.target.value || e.detail);
_setNoWriteBack(node); setDisableRunCodeError(true);
if (node.type === 'checkbox' && node._checkboxMultiMode) _runCode(`!!checked ? (!${tpl}.includes(val) && ${tpl}.push(val)) : (index = ${tpl}.indexOf(val), index > -1 && ${tpl}.splice(index, 1))`, { val: node.value, checked: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
else _runCode(`${tpl} = val`, { val: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
setDisableRunCodeError(false); _setNoWriteBack(null);
});
} else if (realAttrName === 'text' && !tpl) { tpl = node.textContent; node.textContent = ''; }
if (tpl) _initBinding({ node, attr: realAttrName, tpl, exp });
if (tpl) { tpl = _translate(tpl); _initBinding({ node, attr: realAttrName, tpl, exp }); }
}
});
@ -454,19 +386,23 @@ 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;
if (node.textContent.includes('{#')) _initBinding({ node, attr: 'text', tpl: node.textContent, exp: 0 });
const translated = _translate(node.textContent);
if (translated !== node.textContent) node.textContent = translated;
node._stTranslated = true;
return;
}
if (node.nodeType !== 1) return;
if (!node._stTranslated) node._stTranslated = true;
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.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');
@ -527,14 +463,12 @@ export const _scanTree = (node, scanObj = {}) => {
_parseNode(node, { ...scanObj });
const nodes = [...(node.childNodes || [])];
// A component's template belongs to that component, not to the context that created it.
// This keeps `this` in nested component templates bound to the nested component.
nodes.forEach(child => _scanTree(child, { thisObj: node._thisObj ?? scanObj.thisObj, extendVars: { ...node._ref } }));
nodes.forEach(child => _scanTree(child, { thisObj: scanObj.thisObj, extendVars: { ...node._ref } }));
};
export const _unbindTree = (node) => {
if (node.nodeType !== 1 && !node._states) return;
if (node.nodeType === 1 && node._hasOnUnload) node.dispatchEvent(new Event('unload', { bubbles: false }));
if (node.nodeType !== 1) return;
if (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 { Cookie, Hash, Lang, LocalStorage, State, _runCode, _returnCode } from './core.js';
export { Hash, LocalStorage, State, _runCode, _returnCode } from './core.js';
export const __unsafeRefreshState = _scanTree;

View File

@ -1,10 +1,10 @@
import { test, expect } from '../../.profile/playwright/fixture.js';
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
test('modular unit tests and benchmark', async ({ page }) => {
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
await page.goto('/test/index.html');
await page.goto('http://localhost:8081/test/index.html');
await page.waitForFunction(() => window.testStatus !== undefined, { timeout: 10000 });
const status = await page.evaluate(() => window.testStatus);

View File

@ -26,51 +26,6 @@ window.testComponent = async function() {
throw new Error('Component rendering failed');
}
// Slots inside a rendering template must be projected before that template
// is instantiated, so every rendered item receives its own slot content.
Component.register('SlotTemplateTest', container => {
container.state.items = ['first', 'second'];
}, document.createRange().createContextualFragment(`
<div>
<template $each="this.state.items" as="item">
<div class="row"><div slot-id="item-actions"></div></div>
</template>
</div>
`).firstElementChild);
const slotComp = document.createElement('SlotTemplateTest');
slotComp.innerHTML = '<div slot="item-actions" class="slot-action" $text="item"></div>';
document.body.appendChild(slotComp);
__unsafeRefreshState(document.documentElement);
await new Promise(r => setTimeout(r, 50));
const slotActions = slotComp.querySelectorAll('.slot-action');
if (slotActions.length !== 2 || [...slotActions].map(node => node.textContent).join(',') !== 'first,second') {
throw new Error('Slots inside rendering templates failed');
}
// A component created from a parent $each must own `this`; its creator is
// available through this.parent. This is the context used by AutoForm
// field components such as TagsInput and IconPicker.
Component.register('NestedContextChild', container => {
container.state.label = 'child';
}, document.createRange().createContextualFragment('<div class="nested-context" $text="this.state.label + \'-\' + this.parent.state.label"></div>').firstChild);
Component.register('NestedContextParent', container => {
container.state.label = 'parent';
container.state.items = [1, 2];
}, document.createRange().createContextualFragment('<div><template $each="this.state.items"><NestedContextChild></NestedContextChild></template></div>').firstChild);
const contextParent = document.createElement('NestedContextParent');
document.body.appendChild(contextParent);
__unsafeRefreshState(document.documentElement);
await new Promise(r => setTimeout(r, 50));
const nestedContexts = contextParent.querySelectorAll('.nested-context');
if (nestedContexts.length !== 2 || [...nestedContexts].some(node => node.textContent !== 'child-parent')) {
throw new Error('Nested component this/parent context failed');
}
console.log('component.js tests passed');
return true;
}

View File

@ -1,6 +1,6 @@
// test/core.test.js
window.testCore = async function() {
const { Cookie, _runCode, _returnCode } = ApigoState;
const { _runCode, _returnCode } = ApigoState;
console.log('Testing core.js...');
const vars = { a: 1, b: 2 };
const extendVars = { c: 3 };
@ -12,12 +12,6 @@ window.testCore = async function() {
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, $, $$, Lang, NewState, SetTranslator, Component } = ApigoState;
const { __unsafeRefreshState, $, $$, NewState } = ApigoState;
console.log('Testing dom.js...');
const wait = () => new Promise(r => setTimeout(r, 10));
@ -27,21 +27,6 @@ window.testDom = async function() {
await wait();
if (!$('#test-if')) throw new Error('$if fail: should be visible');
// A component in a conditional branch must be initialized again after the
// branch is removed and mounted a second time.
let conditionalSetupCount = 0;
Component.register('IfLifecycleProbe', node => { conditionalSetupCount++; node.probeReady = true });
document.body.innerHTML = '<template $if="state.showComponent"><IfLifecycleProbe id="if-lifecycle-probe"></IfLifecycleProbe></template>';
state.showComponent = true;
__unsafeRefreshState(document.documentElement);
await wait();
if (!$('#if-lifecycle-probe')?.probeReady || conditionalSetupCount !== 1) throw new Error('$if component initial mount failed');
state.showComponent = false;
await wait();
state.showComponent = true;
await wait();
if (!$('#if-lifecycle-probe')?.probeReady || conditionalSetupCount !== 2) throw new Error('$if component remount must reinitialize a fresh node');
// 3. $each directive
document.body.innerHTML = '<template $each="state.items"><div class="test-item" $text="item"></div></template>';
state.items = ['A', 'B'];
@ -61,13 +46,6 @@ window.testDom = async function() {
$('#test-click').click();
if (state.count !== 1) throw new Error('$onclick failed');
// Primitive CustomEvent details remain available through event.detail and
// must not be expanded into invalid numeric function argument names.
document.body.innerHTML = '<div id="test-primitive-event" $onchange="state.eventValue=event.detail"></div>';
__unsafeRefreshState(document.documentElement);
$('#test-primitive-event').dispatchEvent(new CustomEvent('change', { detail: 'source text' }));
if (state.eventValue !== 'source text') throw new Error('$on event must accept primitive detail');
// 5. $bind (input)
document.body.innerHTML = '<input id="test-bind" $bind="state.val">';
state.val = 'init';
@ -80,28 +58,7 @@ window.testDom = async function() {
input.dispatchEvent(new Event('input'));
if (state.val !== 'changed') throw new Error('$bind writeback failed');
input.value = '';
input.dispatchEvent(new Event('input'));
if (state.val !== '') throw new Error('$bind must preserve an empty text value');
// A component/container binding must not consume change events bubbling
// from nested form controls.
document.body.innerHTML = '<div id="test-parent-bind" $bind="state.parentValue"><input id="test-nested-change"></div>';
state.parentValue = 'kept';
__unsafeRefreshState(document.documentElement);
$('#test-nested-change').dispatchEvent(new Event('change', { bubbles: true }));
if (state.parentValue !== 'kept') throw new Error('$bind parent consumed a nested control change event');
// 6. A select bound before its template-rendered options must retain the
// model value instead of writing the browser-selected default back.
document.body.innerHTML = '<select id="test-select" $bind="state.selected"><template $each="state.options"><option $text="item" $value="item"></option></template></select>';
state.selected = 'second';
state.options = ['first', 'second'];
__unsafeRefreshState(document.documentElement);
await wait();
if ($('#test-select').value !== 'second' || state.selected !== 'second') throw new Error('$bind select must retain its value after rendering options');
// 7. Double evaluation ($$ prefix)
// 6. Double evaluation ($$ prefix)
console.log('Testing double evaluation ($$)...');
document.body.innerHTML = `
<div id="double-eval-root">
@ -153,67 +110,6 @@ 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;
}