@erickxavier/no-js 1.9.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/no.js +7 -7
- package/dist/cjs/no.js.map +3 -3
- package/dist/esm/no.js +7 -7
- package/dist/esm/no.js.map +3 -3
- package/dist/iife/no.js +7 -7
- package/dist/iife/no.js.map +3 -3
- package/package.json +2 -1
- package/src/animations.js +22 -7
- package/src/context.js +9 -1
- package/src/devtools.js +21 -0
- package/src/directives/binding.js +41 -1
- package/src/directives/conditionals.js +8 -2
- package/src/directives/events.js +4 -0
- package/src/directives/http.js +18 -2
- package/src/directives/loops.js +171 -6
- package/src/directives/state.js +17 -6
- package/src/dom.js +40 -6
- package/src/evaluate.js +74 -45
- package/src/globals.js +18 -0
- package/src/index.js +6 -1
- package/src/router.js +22 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erickxavier/no-js",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "The HTML-first reactive framework — build dynamic web apps with just HTML attributes, no JavaScript required",
|
|
5
5
|
"main": "dist/cjs/no.js",
|
|
6
6
|
"module": "dist/esm/no.js",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"test:e2e:headed": "npx playwright test --config e2e/playwright.config.ts --headed",
|
|
33
33
|
"test:e2e:report": "npx playwright show-report e2e/playwright-report",
|
|
34
34
|
"test:all": "npm test && npm run test:e2e",
|
|
35
|
+
"bench": "jest --testMatch='**/__benchmarks__/**/*.test.js' --testPathIgnorePatterns='[]'",
|
|
35
36
|
"prepublishOnly": "npm run build"
|
|
36
37
|
},
|
|
37
38
|
"keywords": [
|
package/src/animations.js
CHANGED
|
@@ -71,7 +71,7 @@ export function _animateOut(el, animName, transitionName, callback, durationMs)
|
|
|
71
71
|
const fallback = durationMs || 2000;
|
|
72
72
|
if (!el.firstElementChild && !el.childNodes.length) {
|
|
73
73
|
callback();
|
|
74
|
-
return;
|
|
74
|
+
return () => {};
|
|
75
75
|
}
|
|
76
76
|
if (animName) {
|
|
77
77
|
const target = el.firstElementChild || el;
|
|
@@ -85,8 +85,12 @@ export function _animateOut(el, animName, transitionName, callback, durationMs)
|
|
|
85
85
|
callback();
|
|
86
86
|
};
|
|
87
87
|
target.addEventListener("animationend", done, { once: true });
|
|
88
|
-
setTimeout(done, fallback); // Fallback
|
|
89
|
-
return
|
|
88
|
+
const timerId = setTimeout(done, fallback); // Fallback
|
|
89
|
+
return () => {
|
|
90
|
+
called = true;
|
|
91
|
+
clearTimeout(timerId);
|
|
92
|
+
target.removeEventListener("animationend", done);
|
|
93
|
+
};
|
|
90
94
|
}
|
|
91
95
|
if (transitionName) {
|
|
92
96
|
const target = el.firstElementChild || el;
|
|
@@ -94,10 +98,11 @@ export function _animateOut(el, animName, transitionName, callback, durationMs)
|
|
|
94
98
|
transitionName + "-leave",
|
|
95
99
|
transitionName + "-leave-active",
|
|
96
100
|
);
|
|
97
|
-
|
|
101
|
+
let called = false;
|
|
102
|
+
let timerId;
|
|
103
|
+
const rafId = requestAnimationFrame(() => {
|
|
98
104
|
target.classList.remove(transitionName + "-leave");
|
|
99
105
|
target.classList.add(transitionName + "-leave-to");
|
|
100
|
-
let called = false;
|
|
101
106
|
const done = () => {
|
|
102
107
|
if (called) return;
|
|
103
108
|
called = true;
|
|
@@ -108,9 +113,19 @@ export function _animateOut(el, animName, transitionName, callback, durationMs)
|
|
|
108
113
|
callback();
|
|
109
114
|
};
|
|
110
115
|
target.addEventListener("transitionend", done, { once: true });
|
|
111
|
-
setTimeout(done, fallback);
|
|
116
|
+
timerId = setTimeout(done, fallback);
|
|
112
117
|
});
|
|
113
|
-
return
|
|
118
|
+
return () => {
|
|
119
|
+
called = true;
|
|
120
|
+
cancelAnimationFrame(rafId);
|
|
121
|
+
clearTimeout(timerId);
|
|
122
|
+
target.classList.remove(
|
|
123
|
+
transitionName + "-leave",
|
|
124
|
+
transitionName + "-leave-active",
|
|
125
|
+
transitionName + "-leave-to",
|
|
126
|
+
);
|
|
127
|
+
};
|
|
114
128
|
}
|
|
115
129
|
callback();
|
|
130
|
+
return () => {};
|
|
116
131
|
}
|
package/src/context.js
CHANGED
|
@@ -9,6 +9,7 @@ import { _devtoolsEmit, _ctxRegistry } from "./devtools.js";
|
|
|
9
9
|
let _batchDepth = 0;
|
|
10
10
|
const _batchQueue = new Set();
|
|
11
11
|
let _ctxId = 0;
|
|
12
|
+
let _ctxGeneration = 0;
|
|
12
13
|
|
|
13
14
|
export function _resetCtxId() { _ctxId = 0; }
|
|
14
15
|
|
|
@@ -102,6 +103,7 @@ export function createContext(data = {}, parent = null) {
|
|
|
102
103
|
const old = target[key];
|
|
103
104
|
target[key] = value;
|
|
104
105
|
if (old !== value) {
|
|
106
|
+
_ctxGeneration++;
|
|
105
107
|
notify();
|
|
106
108
|
_devtoolsEmit("ctx:updated", {
|
|
107
109
|
id: target.__devtoolsId,
|
|
@@ -135,7 +137,11 @@ export function createContext(data = {}, parent = null) {
|
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
// Collect all keys from a context + its parent chain
|
|
140
|
+
// Result is cached per context and invalidated on any reactive mutation.
|
|
138
141
|
export function _collectKeys(ctx) {
|
|
142
|
+
const cache = ctx.__raw.__collectKeysCache;
|
|
143
|
+
if (cache && cache.gen === _ctxGeneration) return cache.result;
|
|
144
|
+
|
|
139
145
|
const allKeys = new Set();
|
|
140
146
|
const allVals = {};
|
|
141
147
|
let c = ctx;
|
|
@@ -149,5 +155,7 @@ export function _collectKeys(ctx) {
|
|
|
149
155
|
}
|
|
150
156
|
c = c.$parent;
|
|
151
157
|
}
|
|
152
|
-
|
|
158
|
+
const result = { keys: [...allKeys], vals: allVals };
|
|
159
|
+
ctx.__raw.__collectKeysCache = { gen: _ctxGeneration, result };
|
|
160
|
+
return result;
|
|
153
161
|
}
|
package/src/devtools.js
CHANGED
|
@@ -11,6 +11,22 @@ import { _i18n } from "./i18n.js";
|
|
|
11
11
|
// Maps __devtoolsId → Proxy reference for inspect/mutate commands.
|
|
12
12
|
export const _ctxRegistry = new Map();
|
|
13
13
|
|
|
14
|
+
// ─── Hostname guard ─────────────────────────────────────────────────────────
|
|
15
|
+
// Optional `hostname` param exists for unit-testing without window.location mocking.
|
|
16
|
+
export function _isLocalHostname(hostname) {
|
|
17
|
+
if (hostname === undefined) {
|
|
18
|
+
hostname = (typeof window !== "undefined" && window.location) ? window.location.hostname : "";
|
|
19
|
+
}
|
|
20
|
+
return (
|
|
21
|
+
hostname === "" ||
|
|
22
|
+
hostname === "localhost" ||
|
|
23
|
+
hostname === "127.0.0.1" ||
|
|
24
|
+
hostname === "::1" ||
|
|
25
|
+
hostname === "0.0.0.0" ||
|
|
26
|
+
hostname.endsWith(".localhost")
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
// ─── Emit a devtools event ──────────────────────────────────────────────────
|
|
15
31
|
export function _devtoolsEmit(type, data) {
|
|
16
32
|
if (!_config.devtools || typeof window === "undefined") return;
|
|
@@ -224,6 +240,11 @@ function _handleDevtoolsCommand(event) {
|
|
|
224
240
|
export function initDevtools(nojs) {
|
|
225
241
|
if (!_config.devtools || typeof window === "undefined") return;
|
|
226
242
|
|
|
243
|
+
if (!_isLocalHostname()) {
|
|
244
|
+
console.warn("[No.JS] devtools: true is ignored outside local environments. Remove devtools: true before deploying to production.");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
227
248
|
// Listen for commands
|
|
228
249
|
window.addEventListener("nojs:devtools:cmd", _handleDevtoolsCommand);
|
|
229
250
|
|
|
@@ -33,6 +33,46 @@ registerDirective("bind-html", {
|
|
|
33
33
|
},
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
+
const _SAFE_URL_ATTRS = new Set(["href", "src", "action", "formaction", "poster", "data"]);
|
|
37
|
+
|
|
38
|
+
// Strip JS vectors from raw SVG markup: <script> blocks and on* event handlers.
|
|
39
|
+
function _sanitizeSvgContent(svg) {
|
|
40
|
+
return svg
|
|
41
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
42
|
+
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s/>]*)/gi, "")
|
|
43
|
+
.replace(/\s+(?:href|xlink:href)\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Sanitize a data:image/svg+xml URI — handles both base64 and URL-encoded forms.
|
|
47
|
+
function _sanitizeSvgDataUri(str) {
|
|
48
|
+
try {
|
|
49
|
+
const b64 = str.match(/^data:image\/svg\+xml;base64,(.+)$/i);
|
|
50
|
+
if (b64) {
|
|
51
|
+
const clean = _sanitizeSvgContent(atob(b64[1]));
|
|
52
|
+
return "data:image/svg+xml;base64," + btoa(clean);
|
|
53
|
+
}
|
|
54
|
+
const comma = str.indexOf(",");
|
|
55
|
+
if (comma === -1) return "#";
|
|
56
|
+
const header = str.slice(0, comma + 1);
|
|
57
|
+
const clean = _sanitizeSvgContent(decodeURIComponent(str.slice(comma + 1)));
|
|
58
|
+
return header + encodeURIComponent(clean);
|
|
59
|
+
} catch (_e) {
|
|
60
|
+
return "#";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _sanitizeAttrValue(attrName, value) {
|
|
65
|
+
if (_SAFE_URL_ATTRS.has(attrName)) {
|
|
66
|
+
const str = String(value).trimStart();
|
|
67
|
+
if (/^(javascript|vbscript):/i.test(str)) return "#";
|
|
68
|
+
if (/^data:/i.test(str)) {
|
|
69
|
+
if (/^data:image\/svg\+xml/i.test(str)) return _sanitizeSvgDataUri(str);
|
|
70
|
+
if (!/^data:image\//i.test(str)) return "#";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
36
76
|
registerDirective("bind-*", {
|
|
37
77
|
priority: 20,
|
|
38
78
|
init(el, name, expr) {
|
|
@@ -72,7 +112,7 @@ registerDirective("bind-*", {
|
|
|
72
112
|
if (attrName in el) el[attrName] = !!val;
|
|
73
113
|
return;
|
|
74
114
|
}
|
|
75
|
-
if (val != null) el.setAttribute(attrName, String(val));
|
|
115
|
+
if (val != null) el.setAttribute(attrName, String(_sanitizeAttrValue(attrName, val)));
|
|
76
116
|
else el.removeAttribute(attrName);
|
|
77
117
|
}
|
|
78
118
|
_watchExpr(expr, ctx, update);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// DIRECTIVES: if, else-if, else, show, hide, switch
|
|
3
3
|
// ═══════════════════════════════════════════════════════════════════════
|
|
4
4
|
|
|
5
|
-
import { _watchExpr } from "../globals.js";
|
|
5
|
+
import { _watchExpr, _onDispose } from "../globals.js";
|
|
6
6
|
import { evaluate } from "../evaluate.js";
|
|
7
7
|
import { findContext, _clearDeclared, _cloneTemplate } from "../dom.js";
|
|
8
8
|
import { registerDirective, processTree, _disposeChildren } from "../registry.js";
|
|
@@ -21,6 +21,8 @@ registerDirective("if", {
|
|
|
21
21
|
const animDuration = parseInt(el.getAttribute("animate-duration")) || 0;
|
|
22
22
|
const originalChildren = [...el.childNodes].map((n) => n.cloneNode(true));
|
|
23
23
|
let currentState = undefined;
|
|
24
|
+
let _cancelAnim = null;
|
|
25
|
+
_onDispose(() => { if (_cancelAnim) { _cancelAnim(); _cancelAnim = null; } });
|
|
24
26
|
|
|
25
27
|
function update() {
|
|
26
28
|
const result = !!evaluate(expr, ctx);
|
|
@@ -29,7 +31,11 @@ registerDirective("if", {
|
|
|
29
31
|
|
|
30
32
|
// Animation leave
|
|
31
33
|
if (animLeave || transition) {
|
|
32
|
-
|
|
34
|
+
if (_cancelAnim) { _cancelAnim(); _cancelAnim = null; }
|
|
35
|
+
_cancelAnim = _animateOut(el, animLeave, transition, () => {
|
|
36
|
+
_cancelAnim = null;
|
|
37
|
+
render(result);
|
|
38
|
+
}, animDuration);
|
|
33
39
|
} else {
|
|
34
40
|
render(result);
|
|
35
41
|
}
|
package/src/directives/events.js
CHANGED
|
@@ -26,6 +26,10 @@ registerDirective("on:*", {
|
|
|
26
26
|
}
|
|
27
27
|
if (event === "updated") {
|
|
28
28
|
const updatedObserver = new MutationObserver(() => {
|
|
29
|
+
if (!el.isConnected) {
|
|
30
|
+
updatedObserver.disconnect();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
29
33
|
_execStatement(expr, ctx, { $el: el });
|
|
30
34
|
});
|
|
31
35
|
updatedObserver.observe(el, { childList: true, subtree: true, characterData: true, attributes: true });
|
package/src/directives/http.js
CHANGED
|
@@ -20,6 +20,11 @@ import { _devtoolsEmit } from "../devtools.js";
|
|
|
20
20
|
|
|
21
21
|
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
|
|
22
22
|
|
|
23
|
+
const _SENSITIVE_HEADERS = new Set([
|
|
24
|
+
'authorization', 'x-api-key', 'x-auth-token', 'cookie',
|
|
25
|
+
'proxy-authorization', 'set-cookie', 'x-csrf-token',
|
|
26
|
+
]);
|
|
27
|
+
|
|
23
28
|
for (const method of HTTP_METHODS) {
|
|
24
29
|
registerDirective(method, {
|
|
25
30
|
priority: 1,
|
|
@@ -121,6 +126,14 @@ for (const method of HTTP_METHODS) {
|
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
const extraHeaders = headersAttr ? JSON.parse(headersAttr) : {};
|
|
129
|
+
if (_config.debug && headersAttr) {
|
|
130
|
+
for (const k of Object.keys(extraHeaders)) {
|
|
131
|
+
const lower = k.toLowerCase();
|
|
132
|
+
if (_SENSITIVE_HEADERS.has(lower) || /^x-(auth|api)-/.test(lower)) {
|
|
133
|
+
_warn(`Sensitive header "${k}" is set inline on a headers attribute. Use NoJS.config({ headers }) or an interceptor to avoid exposing credentials in HTML source.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
124
137
|
const savedRetries = _config.retries;
|
|
125
138
|
const savedRetryDelay = _config.retryDelay;
|
|
126
139
|
_config.retries = retryCount;
|
|
@@ -256,7 +269,7 @@ for (const method of HTTP_METHODS) {
|
|
|
256
269
|
el.addEventListener("submit", submitHandler);
|
|
257
270
|
_onDispose(() => el.removeEventListener("submit", submitHandler));
|
|
258
271
|
} else if (method === "get") {
|
|
259
|
-
doRequest();
|
|
272
|
+
if (el.isConnected) doRequest();
|
|
260
273
|
} else {
|
|
261
274
|
// Non-GET on non-FORM: attach click listener
|
|
262
275
|
const clickHandler = (e) => {
|
|
@@ -306,7 +319,10 @@ for (const method of HTTP_METHODS) {
|
|
|
306
319
|
|
|
307
320
|
// Polling
|
|
308
321
|
if (refreshInterval > 0) {
|
|
309
|
-
const id = setInterval(
|
|
322
|
+
const id = setInterval(() => {
|
|
323
|
+
if (!el.isConnected) { clearInterval(id); return; }
|
|
324
|
+
doRequest();
|
|
325
|
+
}, refreshInterval);
|
|
310
326
|
_onDispose(() => clearInterval(id));
|
|
311
327
|
}
|
|
312
328
|
},
|
package/src/directives/loops.js
CHANGED
|
@@ -25,6 +25,8 @@ registerDirective("each", {
|
|
|
25
25
|
const stagger = parseInt(el.getAttribute("animate-stagger")) || 0;
|
|
26
26
|
const animDuration = parseInt(el.getAttribute("animate-duration")) || 0;
|
|
27
27
|
let prevList = null;
|
|
28
|
+
// key → wrapper div; only populated when the `key` attribute is set.
|
|
29
|
+
const keyMap = new Map();
|
|
28
30
|
|
|
29
31
|
function update() {
|
|
30
32
|
let list = /[\[\]()\s+\-*\/!?:&|]/.test(listPath)
|
|
@@ -32,10 +34,7 @@ registerDirective("each", {
|
|
|
32
34
|
: resolve(listPath, ctx);
|
|
33
35
|
if (!Array.isArray(list)) return;
|
|
34
36
|
|
|
35
|
-
//
|
|
36
|
-
// and just propagate the notification to child contexts so their
|
|
37
|
-
// watchers (bind, show, model, etc.) can react to parent changes
|
|
38
|
-
// without destroying/recreating the DOM (preserves input focus).
|
|
37
|
+
// Same-reference optimisation: propagate to children without DOM rebuild.
|
|
39
38
|
if (list === prevList && list.length > 0 && el.children.length > 0) {
|
|
40
39
|
for (const child of el.children) {
|
|
41
40
|
if (child.__ctx && child.__ctx.$notify) child.__ctx.$notify();
|
|
@@ -49,6 +48,7 @@ registerDirective("each", {
|
|
|
49
48
|
const clone = _cloneTemplate(elseTpl);
|
|
50
49
|
if (clone) {
|
|
51
50
|
_disposeChildren(el);
|
|
51
|
+
keyMap.clear();
|
|
52
52
|
el.innerHTML = "";
|
|
53
53
|
el.appendChild(clone);
|
|
54
54
|
processTree(el);
|
|
@@ -80,6 +80,87 @@ registerDirective("each", {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
function renderItems(tpl, list) {
|
|
83
|
+
if (keyExpr) {
|
|
84
|
+
reconcileItems(tpl, list);
|
|
85
|
+
} else {
|
|
86
|
+
rebuildItems(tpl, list);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Key-based reconciliation: reuses existing wrapper divs for items whose
|
|
91
|
+
// key is still present in the new list, only creating/removing DOM nodes
|
|
92
|
+
// for items that genuinely appeared or disappeared.
|
|
93
|
+
function reconcileItems(tpl, list) {
|
|
94
|
+
const count = list.length;
|
|
95
|
+
|
|
96
|
+
// Evaluate the key for every item in the new list up-front.
|
|
97
|
+
const newOrder = list.map((item, i) => {
|
|
98
|
+
const tempCtx = createContext({ [itemName]: item, $index: i }, ctx);
|
|
99
|
+
return { key: evaluate(keyExpr, tempCtx), item, i };
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const nextKeySet = new Set(newOrder.map((e) => e.key));
|
|
103
|
+
|
|
104
|
+
// Remove wrappers whose keys are no longer in the list.
|
|
105
|
+
for (const [key, wrapper] of keyMap) {
|
|
106
|
+
if (!nextKeySet.has(key)) {
|
|
107
|
+
_disposeChildren(wrapper);
|
|
108
|
+
wrapper.remove();
|
|
109
|
+
keyMap.delete(key);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Create new wrappers and update existing ones.
|
|
114
|
+
newOrder.forEach(({ key, item, i }) => {
|
|
115
|
+
const childData = {
|
|
116
|
+
[itemName]: item,
|
|
117
|
+
$index: i,
|
|
118
|
+
$count: count,
|
|
119
|
+
$first: i === 0,
|
|
120
|
+
$last: i === count - 1,
|
|
121
|
+
$even: i % 2 === 0,
|
|
122
|
+
$odd: i % 2 !== 0,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (!keyMap.has(key)) {
|
|
126
|
+
const clone = tpl.content.cloneNode(true);
|
|
127
|
+
const wrapper = document.createElement("div");
|
|
128
|
+
wrapper.style.display = "contents";
|
|
129
|
+
wrapper.__ctx = createContext(childData, ctx);
|
|
130
|
+
wrapper.appendChild(clone);
|
|
131
|
+
keyMap.set(key, wrapper);
|
|
132
|
+
el.appendChild(wrapper); // placed at end; reordered below
|
|
133
|
+
processTree(wrapper);
|
|
134
|
+
|
|
135
|
+
if (animEnter) {
|
|
136
|
+
const firstChild = wrapper.firstElementChild;
|
|
137
|
+
if (firstChild) {
|
|
138
|
+
firstChild.classList.add(animEnter);
|
|
139
|
+
firstChild.addEventListener(
|
|
140
|
+
"animationend",
|
|
141
|
+
() => firstChild.classList.remove(animEnter),
|
|
142
|
+
{ once: true },
|
|
143
|
+
);
|
|
144
|
+
if (stagger) firstChild.style.animationDelay = i * stagger + "ms";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
// Existing item: update positional metadata and notify watchers.
|
|
149
|
+
Object.assign(keyMap.get(key).__ctx.__raw, childData);
|
|
150
|
+
keyMap.get(key).__ctx.$notify();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Reorder DOM to match the new list using a single forward pass.
|
|
155
|
+
for (let i = 0; i < newOrder.length; i++) {
|
|
156
|
+
const wrapper = keyMap.get(newOrder[i].key);
|
|
157
|
+
if (wrapper !== el.children[i]) el.insertBefore(wrapper, el.children[i] ?? null);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Full rebuild: dispose all children and recreate from scratch.
|
|
162
|
+
// Used when no `key` attribute is set (backward-compatible behaviour).
|
|
163
|
+
function rebuildItems(tpl, list) {
|
|
83
164
|
const count = list.length;
|
|
84
165
|
_disposeChildren(el);
|
|
85
166
|
el.innerHTML = "";
|
|
@@ -109,7 +190,6 @@ registerDirective("each", {
|
|
|
109
190
|
if (firstChild) {
|
|
110
191
|
firstChild.classList.add(animEnter);
|
|
111
192
|
firstChild.addEventListener("animationend", () => firstChild.classList.remove(animEnter), { once: true });
|
|
112
|
-
// Stagger animation — delay must be on the child, not the wrapper
|
|
113
193
|
if (stagger) {
|
|
114
194
|
firstChild.style.animationDelay = i * stagger + "ms";
|
|
115
195
|
}
|
|
@@ -135,6 +215,7 @@ registerDirective("foreach", {
|
|
|
135
215
|
const limit = parseInt(el.getAttribute("limit")) || Infinity;
|
|
136
216
|
const offset = parseInt(el.getAttribute("offset")) || 0;
|
|
137
217
|
const tplId = el.getAttribute("template");
|
|
218
|
+
const keyExpr = el.getAttribute("key");
|
|
138
219
|
const animEnter = el.getAttribute("animate-enter") || el.getAttribute("animate");
|
|
139
220
|
const animLeave = el.getAttribute("animate-leave");
|
|
140
221
|
const stagger = parseInt(el.getAttribute("animate-stagger")) || 0;
|
|
@@ -157,6 +238,7 @@ registerDirective("foreach", {
|
|
|
157
238
|
templateContent.removeAttribute("offset");
|
|
158
239
|
templateContent.removeAttribute("else");
|
|
159
240
|
templateContent.removeAttribute("template");
|
|
241
|
+
templateContent.removeAttribute("key");
|
|
160
242
|
templateContent.removeAttribute("animate-enter");
|
|
161
243
|
templateContent.removeAttribute("animate");
|
|
162
244
|
templateContent.removeAttribute("animate-leave");
|
|
@@ -164,6 +246,9 @@ registerDirective("foreach", {
|
|
|
164
246
|
templateContent.removeAttribute("animate-duration");
|
|
165
247
|
}
|
|
166
248
|
|
|
249
|
+
// key → wrapper div; only populated when the `key` attribute is set.
|
|
250
|
+
const keyMap = new Map();
|
|
251
|
+
|
|
167
252
|
function update() {
|
|
168
253
|
let list = resolve(fromPath, ctx);
|
|
169
254
|
if (!Array.isArray(list)) return;
|
|
@@ -199,6 +284,7 @@ registerDirective("foreach", {
|
|
|
199
284
|
const clone = _cloneTemplate(elseTpl);
|
|
200
285
|
if (clone) {
|
|
201
286
|
_disposeChildren(el);
|
|
287
|
+
keyMap.clear();
|
|
202
288
|
el.innerHTML = "";
|
|
203
289
|
el.appendChild(clone);
|
|
204
290
|
processTree(el);
|
|
@@ -209,6 +295,11 @@ registerDirective("foreach", {
|
|
|
209
295
|
const tpl = tplId ? document.getElementById(tplId) : null;
|
|
210
296
|
const count = list.length;
|
|
211
297
|
|
|
298
|
+
if (keyExpr) {
|
|
299
|
+
reconcileForeachItems(tpl, list, count);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
212
303
|
function renderForeachItems() {
|
|
213
304
|
_disposeChildren(el);
|
|
214
305
|
el.innerHTML = "";
|
|
@@ -244,7 +335,6 @@ registerDirective("foreach", {
|
|
|
244
335
|
if (firstChild) {
|
|
245
336
|
firstChild.classList.add(animEnter);
|
|
246
337
|
firstChild.addEventListener("animationend", () => firstChild.classList.remove(animEnter), { once: true });
|
|
247
|
-
// Stagger animation — delay must be on the child, not the wrapper
|
|
248
338
|
if (stagger) {
|
|
249
339
|
firstChild.style.animationDelay = (i * stagger) + "ms";
|
|
250
340
|
}
|
|
@@ -273,6 +363,81 @@ registerDirective("foreach", {
|
|
|
273
363
|
}
|
|
274
364
|
}
|
|
275
365
|
|
|
366
|
+
// Key-based reconciliation for foreach — mirrors each's reconcileItems.
|
|
367
|
+
// Applied to the final (filtered, sorted, sliced) list so keys always
|
|
368
|
+
// correspond to what is actually rendered.
|
|
369
|
+
function reconcileForeachItems(tpl, list, count) {
|
|
370
|
+
// On first render the element may still hold its original inline template
|
|
371
|
+
// markup (the same content that was captured into templateContent).
|
|
372
|
+
// Clear it so only managed wrappers appear as children.
|
|
373
|
+
if (keyMap.size === 0) el.innerHTML = "";
|
|
374
|
+
|
|
375
|
+
const newOrder = list.map((item, i) => {
|
|
376
|
+
const tempCtx = createContext({ [itemName]: item, [indexName]: i }, ctx);
|
|
377
|
+
return { key: evaluate(keyExpr, tempCtx), item, i };
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const nextKeySet = new Set(newOrder.map((e) => e.key));
|
|
381
|
+
|
|
382
|
+
for (const [key, wrapper] of keyMap) {
|
|
383
|
+
if (!nextKeySet.has(key)) {
|
|
384
|
+
_disposeChildren(wrapper);
|
|
385
|
+
wrapper.remove();
|
|
386
|
+
keyMap.delete(key);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
newOrder.forEach(({ key, item, i }) => {
|
|
391
|
+
const childData = {
|
|
392
|
+
[itemName]: item,
|
|
393
|
+
[indexName]: i,
|
|
394
|
+
$index: i,
|
|
395
|
+
$count: count,
|
|
396
|
+
$first: i === 0,
|
|
397
|
+
$last: i === count - 1,
|
|
398
|
+
$even: i % 2 === 0,
|
|
399
|
+
$odd: i % 2 !== 0,
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
if (!keyMap.has(key)) {
|
|
403
|
+
let clone;
|
|
404
|
+
if (tpl) {
|
|
405
|
+
clone = tpl.content.cloneNode(true);
|
|
406
|
+
} else {
|
|
407
|
+
clone = templateContent.cloneNode(true);
|
|
408
|
+
}
|
|
409
|
+
const wrapper = document.createElement("div");
|
|
410
|
+
wrapper.style.display = "contents";
|
|
411
|
+
wrapper.__ctx = createContext(childData, ctx);
|
|
412
|
+
wrapper.appendChild(clone);
|
|
413
|
+
keyMap.set(key, wrapper);
|
|
414
|
+
el.appendChild(wrapper);
|
|
415
|
+
processTree(wrapper);
|
|
416
|
+
|
|
417
|
+
if (animEnter) {
|
|
418
|
+
const firstChild = wrapper.firstElementChild;
|
|
419
|
+
if (firstChild) {
|
|
420
|
+
firstChild.classList.add(animEnter);
|
|
421
|
+
firstChild.addEventListener(
|
|
422
|
+
"animationend",
|
|
423
|
+
() => firstChild.classList.remove(animEnter),
|
|
424
|
+
{ once: true },
|
|
425
|
+
);
|
|
426
|
+
if (stagger) firstChild.style.animationDelay = i * stagger + "ms";
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
Object.assign(keyMap.get(key).__ctx.__raw, childData);
|
|
431
|
+
keyMap.get(key).__ctx.$notify();
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
for (let i = 0; i < newOrder.length; i++) {
|
|
436
|
+
const wrapper = keyMap.get(newOrder[i].key);
|
|
437
|
+
if (wrapper !== el.children[i]) el.insertBefore(wrapper, el.children[i] ?? null);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
276
441
|
_watchExpr(fromPath, ctx, update);
|
|
277
442
|
update();
|
|
278
443
|
},
|
package/src/directives/state.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// DIRECTIVES: state, store, computed, watch
|
|
3
3
|
// ═══════════════════════════════════════════════════════════════════════
|
|
4
4
|
|
|
5
|
-
import { _stores, _log, _watchExpr } from "../globals.js";
|
|
5
|
+
import { _stores, _log, _warn, _watchExpr } from "../globals.js";
|
|
6
6
|
import { createContext } from "../context.js";
|
|
7
7
|
import { evaluate, _execStatement } from "../evaluate.js";
|
|
8
8
|
import { findContext } from "../dom.js";
|
|
@@ -20,6 +20,10 @@ registerDirective("state", {
|
|
|
20
20
|
// Persistence
|
|
21
21
|
const persist = el.getAttribute("persist");
|
|
22
22
|
const persistKey = el.getAttribute("persist-key");
|
|
23
|
+
if (persist && !persistKey) {
|
|
24
|
+
_warn(`persist="${persist}" requires a persist-key attribute. State will not be persisted.`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
23
27
|
if (persist && persistKey) {
|
|
24
28
|
const store =
|
|
25
29
|
persist === "localStorage"
|
|
@@ -28,21 +32,28 @@ registerDirective("state", {
|
|
|
28
32
|
? sessionStorage
|
|
29
33
|
: null;
|
|
30
34
|
if (store) {
|
|
35
|
+
const persistFieldsAttr = el.getAttribute("persist-fields");
|
|
36
|
+
const persistFields = persistFieldsAttr
|
|
37
|
+
? new Set(persistFieldsAttr.split(",").map((f) => f.trim()))
|
|
38
|
+
: null;
|
|
31
39
|
try {
|
|
32
40
|
const saved = store.getItem("nojs_state_" + persistKey);
|
|
33
41
|
if (saved) {
|
|
34
42
|
const parsed = JSON.parse(saved);
|
|
35
|
-
for (const [k, v] of Object.entries(parsed))
|
|
43
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
44
|
+
if (!persistFields || persistFields.has(k)) ctx.$set(k, v);
|
|
45
|
+
}
|
|
36
46
|
}
|
|
37
47
|
} catch {
|
|
38
48
|
/* ignore */
|
|
39
49
|
}
|
|
40
50
|
ctx.$watch(() => {
|
|
41
51
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
const raw = ctx.__raw;
|
|
53
|
+
const data = persistFields
|
|
54
|
+
? Object.fromEntries(Object.entries(raw).filter(([k]) => persistFields.has(k)))
|
|
55
|
+
: raw;
|
|
56
|
+
store.setItem("nojs_state_" + persistKey, JSON.stringify(data));
|
|
46
57
|
} catch {
|
|
47
58
|
/* ignore */
|
|
48
59
|
}
|
package/src/dom.js
CHANGED
|
@@ -33,14 +33,48 @@ export function _cloneTemplate(id) {
|
|
|
33
33
|
return tpl.content ? tpl.content.cloneNode(true) : null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
//
|
|
36
|
+
// Structural HTML sanitizer — uses DOMParser to parse the markup before cleaning.
|
|
37
|
+
// Regex-based sanitizers are bypassable via SVG/MathML event handlers, nested
|
|
38
|
+
// srcdoc attributes, and HTML entity encoding (e.g. javascript:).
|
|
39
|
+
// DOMParser resolves entities and builds a real DOM tree, making all vectors
|
|
40
|
+
// uniformly detectable by a single attribute-name/value check.
|
|
41
|
+
//
|
|
42
|
+
// Custom hook: set _config.sanitizeHtml to a function to plug in an external
|
|
43
|
+
// sanitizer (e.g. DOMPurify) without bundling it as a hard dependency.
|
|
44
|
+
const _BLOCKED_TAGS = new Set([
|
|
45
|
+
'script', 'style', 'iframe', 'object', 'embed',
|
|
46
|
+
'base', 'form', 'meta', 'link', 'noscript',
|
|
47
|
+
]);
|
|
48
|
+
|
|
37
49
|
export function _sanitizeHtml(html) {
|
|
38
50
|
if (!_config.sanitize) return html;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
if (typeof _config.sanitizeHtml === 'function') return _config.sanitizeHtml(html);
|
|
52
|
+
|
|
53
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
54
|
+
|
|
55
|
+
function _clean(node) {
|
|
56
|
+
for (const child of [...node.childNodes]) {
|
|
57
|
+
if (child.nodeType !== 1) continue; // text and comment nodes are safe
|
|
58
|
+
if (_BLOCKED_TAGS.has(child.tagName.toLowerCase())) {
|
|
59
|
+
child.remove();
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const attr of [...child.attributes]) {
|
|
63
|
+
const n = attr.name.toLowerCase();
|
|
64
|
+
const v = attr.value.toLowerCase().trimStart();
|
|
65
|
+
const isUrlAttr = n === 'href' || n === 'src' || n === 'action' || n === 'xlink:href';
|
|
66
|
+
const isDangerousScheme = v.startsWith('javascript:') || v.startsWith('vbscript:');
|
|
67
|
+
const isDangerousData = isUrlAttr && v.startsWith('data:') && !/^data:image\//.test(v);
|
|
68
|
+
if (n.startsWith('on') || isDangerousScheme || isDangerousData) {
|
|
69
|
+
child.removeAttribute(attr.name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
_clean(child);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_clean(doc.body);
|
|
77
|
+
return doc.body.innerHTML;
|
|
44
78
|
}
|
|
45
79
|
|
|
46
80
|
// Resolve a template src path.
|