@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erickxavier/no-js",
3
- "version": "1.9.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
- requestAnimationFrame(() => {
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
- return { keys: [...allKeys], vals: allVals };
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
- _animateOut(el, animLeave, transition, () => render(result), animDuration);
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
  }
@@ -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 });
@@ -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(doRequest, refreshInterval);
322
+ const id = setInterval(() => {
323
+ if (!el.isConnected) { clearInterval(id); return; }
324
+ doRequest();
325
+ }, refreshInterval);
310
326
  _onDispose(() => clearInterval(id));
311
327
  }
312
328
  },
@@ -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
- // If same list reference and items are rendered, skip re-render
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
  },
@@ -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)) ctx.$set(k, v);
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
- store.setItem(
43
- "nojs_state_" + persistKey,
44
- JSON.stringify(ctx.__raw),
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
- // Simple HTML sanitizer
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. &#x6A;avascript:).
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
- const safe = html
40
- .replace(/<script[\s\S]*?<\/script>/gi, "")
41
- .replace(/on\w+\s*=/gi, "data-blocked=")
42
- .replace(/javascript:/gi, "");
43
- return safe;
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.