@erickxavier/no-js 1.10.1 → 1.11.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.10.1",
3
+ "version": "1.11.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",
package/src/animations.js CHANGED
@@ -33,16 +33,18 @@ function _injectBuiltInStyles() {
33
33
 
34
34
  export function _animateIn(el, animName, transitionName, durationMs) {
35
35
  _injectBuiltInStyles();
36
- const fallback = durationMs || 1000;
36
+ // || 0: fires on the next event-loop tick when no CSS transition is present,
37
+ // instead of blocking for an arbitrary duration.
38
+ const fallback = durationMs || 0;
37
39
  if (animName) {
38
40
  const target = el.firstElementChild || el;
39
41
  target.classList.add(animName);
40
42
  if (durationMs) target.style.animationDuration = durationMs + "ms";
41
- target.addEventListener(
42
- "animationend",
43
- () => target.classList.remove(animName),
44
- { once: true },
45
- );
43
+ const done = () => target.classList.remove(animName);
44
+ target.addEventListener("animationend", done, { once: true });
45
+ // Fallback: remove the class on the next tick if animationend never fires
46
+ // (e.g. CSS absent, element detached). Mirrors the transitionName branch.
47
+ setTimeout(done, fallback);
46
48
  }
47
49
  if (transitionName) {
48
50
  const target = el.firstElementChild || el;
@@ -68,7 +70,9 @@ export function _animateIn(el, animName, transitionName, durationMs) {
68
70
 
69
71
  export function _animateOut(el, animName, transitionName, callback, durationMs) {
70
72
  _injectBuiltInStyles();
71
- const fallback = durationMs || 2000;
73
+ // || 0: fires on the next event-loop tick when no CSS animation/transition is
74
+ // present, instead of blocking for an arbitrary duration.
75
+ const fallback = durationMs || 0;
72
76
  if (!el.firstElementChild && !el.childNodes.length) {
73
77
  callback();
74
78
  return () => {};
package/src/context.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // REACTIVE CONTEXT
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _config, _stores, _refs, _routerInstance, _currentEl } from "./globals.js";
5
+ import { _config, _stores, _refs, _routerInstance, _currentEl, _globals } from "./globals.js";
6
6
  import { _i18n } from "./i18n.js";
7
7
  import { _devtoolsEmit, _ctxRegistry } from "./devtools.js";
8
8
 
@@ -95,6 +95,10 @@ export function createContext(data = {}, parent = null) {
95
95
  if (key === "$router") return _routerInstance;
96
96
  if (key === "$i18n") return _i18n;
97
97
  if (key === "$form") return target.$form || null;
98
+ // Plugin globals fallback (after all core $ checks)
99
+ if (key.startsWith("$") && key.slice(1) in _globals) {
100
+ return _globals[key.slice(1)];
101
+ }
98
102
  if (key in target) return target[key];
99
103
  if (parent && parent.__isProxy) return parent[key];
100
104
  return undefined;
@@ -116,6 +120,7 @@ export function createContext(data = {}, parent = null) {
116
120
  },
117
121
  has(target, key) {
118
122
  if (key in target) return true;
123
+ if (typeof key === "string" && key.startsWith("$") && key.slice(1) in _globals) return true;
119
124
  if (parent && parent.__isProxy) return key in parent;
120
125
  return false;
121
126
  },
package/src/devtools.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // All hooks are guarded by _config.devtools — no cost when disabled.
5
5
  // ═══════════════════════════════════════════════════════════════════════
6
6
 
7
- import { _config, _stores, _refs, _routerInstance } from "./globals.js";
7
+ import { _config, _stores, _refs, _routerInstance, _plugins, _globals } from "./globals.js";
8
8
  import { _i18n } from "./i18n.js";
9
9
 
10
10
  // ─── Context registry (populated by createContext when devtools enabled) ────
@@ -237,6 +237,15 @@ function _handleDevtoolsCommand(event) {
237
237
  );
238
238
  }
239
239
 
240
+ // ─── Cleanup reference ───────────────────────────────────────────────────────
241
+
242
+ let _devtoolsCleanup = null;
243
+
244
+ // Called from NoJS.dispose() to remove the command listener and clean up.
245
+ export function destroyDevtools() {
246
+ if (_devtoolsCleanup) _devtoolsCleanup();
247
+ }
248
+
240
249
  // ─── Initialization ─────────────────────────────────────────────────────────
241
250
 
242
251
  export function initDevtools(nojs) {
@@ -247,17 +256,36 @@ export function initDevtools(nojs) {
247
256
  return;
248
257
  }
249
258
 
250
- // Listen for commands
259
+ // Listen for commands (store reference for cleanup)
251
260
  window.addEventListener("nojs:devtools:cmd", _handleDevtoolsCommand);
261
+ _devtoolsCleanup = () => {
262
+ window.removeEventListener("nojs:devtools:cmd", _handleDevtoolsCommand);
263
+ delete window.__NOJS_DEVTOOLS__;
264
+ _devtoolsCleanup = null;
265
+ };
252
266
 
253
267
  // Expose public API on window
254
268
  window.__NOJS_DEVTOOLS__ = {
255
- // Data access
256
- stores: _stores,
257
- config: _config,
258
- refs: _refs,
269
+ // Data access (read-only snapshots — no live references leak)
270
+ get stores() {
271
+ return Object.fromEntries(
272
+ Object.entries(_stores).map(([k, v]) => [k, _safeSnapshot(v)])
273
+ );
274
+ },
275
+ get config() {
276
+ const c = { ..._config };
277
+ if (c.headers) c.headers = { ...c.headers };
278
+ if (c.router) c.router = { ...c.router };
279
+ if (c.cache) c.cache = { ...c.cache };
280
+ if (c.csrf) c.csrf = { ...c.csrf };
281
+ if (c.i18n) c.i18n = { ...c.i18n };
282
+ return c;
283
+ },
284
+ get refs() { return { ..._refs }; },
259
285
  router: _routerInstance,
260
286
  version: nojs.version,
287
+ get plugins() { return new Map(_plugins); },
288
+ get globals() { return { ..._globals }; },
261
289
 
262
290
  // Inspect API
263
291
  inspect: (selector) => _inspectElement(selector),
@@ -2,10 +2,10 @@
2
2
  // DIRECTIVES: bind, bind-html, bind-*, model
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _watchExpr, _onDispose } from "../globals.js";
5
+ import { _watchExpr, _onDispose, _config, _warn } from "../globals.js";
6
6
  import { evaluate, _execStatement } from "../evaluate.js";
7
7
  import { findContext, _sanitizeHtml } from "../dom.js";
8
- import { registerDirective } from "../registry.js";
8
+ import { registerDirective, _disposeChildren } from "../registry.js";
9
9
 
10
10
  registerDirective("bind", {
11
11
  priority: 20,
@@ -24,9 +24,19 @@ registerDirective("bind-html", {
24
24
  priority: 20,
25
25
  init(el, name, expr) {
26
26
  const ctx = findContext(el);
27
+ if ((_config.debug || _config.devtools) && !/^['"`]/.test(expr.trim())) {
28
+ _warn(
29
+ `[Security] bind-html used with dynamic expression: "${expr}". ` +
30
+ `Ensure the value is trusted or sanitized — use bind for plain text.`,
31
+ el
32
+ );
33
+ }
27
34
  function update() {
28
35
  const val = evaluate(expr, ctx);
29
- if (val != null) el.innerHTML = _sanitizeHtml(String(val));
36
+ if (val != null) {
37
+ _disposeChildren(el);
38
+ el.innerHTML = _sanitizeHtml(String(val));
39
+ }
30
40
  }
31
41
  _watchExpr(expr, ctx, update);
32
42
  update();
@@ -805,7 +805,7 @@ registerDirective("drag-list", {
805
805
  wrapper.addEventListener("dragend", itemDragend);
806
806
 
807
807
  // Keyboard DnD on items
808
- wrapper.addEventListener("keydown", (e) => {
808
+ const itemKeydown = (e) => {
809
809
  if (e.key === " " && !_dndState.dragging) {
810
810
  e.preventDefault();
811
811
  _dndState.dragging = {
@@ -842,7 +842,16 @@ registerDirective("drag-list", {
842
842
  prevEl.focus();
843
843
  }
844
844
  }
845
- });
845
+ };
846
+ wrapper.addEventListener("keydown", itemKeydown);
847
+
848
+ // Register cleanup on wrapper so disposers run when _disposeChildren(el) is called
849
+ wrapper.__disposers = wrapper.__disposers || [];
850
+ wrapper.__disposers.push(
851
+ () => wrapper.removeEventListener("dragstart", itemDragstart),
852
+ () => wrapper.removeEventListener("dragend", itemDragend),
853
+ () => wrapper.removeEventListener("keydown", itemKeydown),
854
+ );
846
855
 
847
856
  processTree(wrapper);
848
857
  });
@@ -127,6 +127,7 @@ registerDirective("on:*", {
127
127
  clearTimeout(timer);
128
128
  timer = setTimeout(() => original(e), debounceMs);
129
129
  };
130
+ _onDispose(() => clearTimeout(timer));
130
131
  }
131
132
 
132
133
  // Wrap with throttle
@@ -0,0 +1,142 @@
1
+ // ═══════════════════════════════════════════════════════════════════════
2
+ // DIRECTIVES: page-title, page-description, page-canonical, page-jsonld
3
+ // ═══════════════════════════════════════════════════════════════════════
4
+ //
5
+ // These directives update <head> elements reactively using the same
6
+ // _watchExpr + evaluate() infrastructure as all other directives.
7
+ // They are meant for placement in the page body (not inside <head>).
8
+ // Use <div hidden> as the host element — it is invisible, semantically
9
+ // neutral, and avoids custom attributes on void elements like <meta>:
10
+ //
11
+ // <div hidden page-title="product.name + ' | My Store'"></div>
12
+ // <div hidden page-title="'About Us | My Store'"></div>
13
+ // <div hidden page-description="product.description"></div>
14
+ // <div hidden page-canonical="'/products/' + product.slug"></div>
15
+ // <div hidden page-jsonld>
16
+ // { "@type": "Product", "name": "{product.name}" }
17
+ // </div>
18
+ //
19
+ // Note: <script> elements are skipped by processTree; use <div hidden>
20
+ // as the host element for page-jsonld.
21
+ //
22
+ // Each directive watches its expression and re-applies the side effect
23
+ // whenever the reactive context changes.
24
+ // ═══════════════════════════════════════════════════════════════════════
25
+
26
+ import { _watchExpr } from "../globals.js";
27
+ import { evaluate, resolve } from "../evaluate.js";
28
+ import { findContext } from "../dom.js";
29
+ import { registerDirective } from "../registry.js";
30
+
31
+ // Interpolate {key} placeholders in a string without URL-encoding.
32
+ // Used by page-jsonld where the template is a JSON string.
33
+ function _interpolateRaw(str, ctx) {
34
+ // Match only {identifiers} — skip { starting with " or ' to avoid consuming JSON structure.
35
+ return str.replace(/\{([^}"'{][^}]*)\}/g, (_, expr) => {
36
+ try {
37
+ const val = evaluate(expr.trim(), ctx);
38
+ return val != null ? String(val) : "";
39
+ } catch (_) {
40
+ return "";
41
+ }
42
+ });
43
+ }
44
+
45
+ // ── page-title ────────────────────────────────────────────────────────────────
46
+ // Updates document.title reactively.
47
+ // Value is a No.JS expression: page-title="product.name + ' | Store'"
48
+ registerDirective("page-title", {
49
+ priority: 1,
50
+ init(el, name, expr) {
51
+ const ctx = findContext(el);
52
+ function update() {
53
+ const val = evaluate(expr, ctx);
54
+ if (val != null) document.title = String(val);
55
+ }
56
+ _watchExpr(expr, ctx, update);
57
+ update();
58
+ },
59
+ });
60
+
61
+ // ── page-description ──────────────────────────────────────────────────────────
62
+ // Creates or updates <meta name="description" content="..."> in <head>.
63
+ // Value is a No.JS expression: page-description="product.description"
64
+ registerDirective("page-description", {
65
+ priority: 1,
66
+ init(el, name, expr) {
67
+ const ctx = findContext(el);
68
+ function update() {
69
+ const val = evaluate(expr, ctx);
70
+ if (val == null) return;
71
+ let meta = document.querySelector('meta[name="description"]');
72
+ if (!meta) {
73
+ meta = document.createElement("meta");
74
+ meta.name = "description";
75
+ document.head.appendChild(meta);
76
+ }
77
+ meta.content = String(val);
78
+ }
79
+ _watchExpr(expr, ctx, update);
80
+ update();
81
+ },
82
+ });
83
+
84
+ // ── page-canonical ────────────────────────────────────────────────────────────
85
+ // Creates or updates <link rel="canonical" href="..."> in <head>.
86
+ // Value is a No.JS expression: page-canonical="'/products/' + product.slug"
87
+ registerDirective("page-canonical", {
88
+ priority: 1,
89
+ init(el, name, expr) {
90
+ const ctx = findContext(el);
91
+ function update() {
92
+ const val = evaluate(expr, ctx);
93
+ if (val == null) return;
94
+ let link = document.querySelector('link[rel="canonical"]');
95
+ if (!link) {
96
+ link = document.createElement("link");
97
+ link.rel = "canonical";
98
+ document.head.appendChild(link);
99
+ }
100
+ link.href = String(val);
101
+ }
102
+ _watchExpr(expr, ctx, update);
103
+ update();
104
+ },
105
+ });
106
+
107
+ // ── page-jsonld ───────────────────────────────────────────────────────────────
108
+ // Creates or updates a <script type="application/ld+json" data-nojs> in <head>.
109
+ // Value is either:
110
+ // - A No.JS expression that evaluates to an object → JSON.stringify is applied
111
+ // - A JSON string with {interpolation} placeholders → _interpolate is applied
112
+ //
113
+ // The data-nojs marker distinguishes the managed tag from any hand-written
114
+ // JSON-LD the developer may have added, so they can coexist.
115
+ registerDirective("page-jsonld", {
116
+ priority: 1,
117
+ init(el, name, expr) {
118
+ const ctx = findContext(el);
119
+ // The JSON template lives in the element's text content (or innerHTML for
120
+ // elements like <div hidden>). The attribute value (expr) is intentionally
121
+ // empty — the developer writes the JSON template as the element body.
122
+ const template = (el.textContent || el.innerHTML).trim();
123
+ if (!template) return;
124
+ function update() {
125
+ // Resolve {interpolation} placeholders in the JSON template.
126
+ const json = _interpolateRaw(template, ctx);
127
+ if (!json) return;
128
+ let script = document.querySelector(
129
+ 'script[type="application/ld+json"][data-nojs]',
130
+ );
131
+ if (!script) {
132
+ script = document.createElement("script");
133
+ script.type = "application/ld+json";
134
+ script.setAttribute("data-nojs", "");
135
+ document.head.appendChild(script);
136
+ }
137
+ script.textContent = json;
138
+ }
139
+ _watchExpr(template, ctx, update);
140
+ update();
141
+ },
142
+ });
@@ -10,6 +10,7 @@ import {
10
10
  _emitEvent,
11
11
  _routerInstance,
12
12
  _onDispose,
13
+ _SENSITIVE_HEADERS,
13
14
  } from "../globals.js";
14
15
  import { createContext } from "../context.js";
15
16
  import { evaluate, _execStatement, _interpolate } from "../evaluate.js";
@@ -20,11 +21,6 @@ import { _devtoolsEmit } from "../devtools.js";
20
21
 
21
22
  const HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
22
23
 
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
-
28
24
  for (const method of HTTP_METHODS) {
29
25
  registerDirective(method, {
30
26
  priority: 1,
@@ -7,7 +7,7 @@ import { _i18n, _watchI18n, _loadI18nNamespace, _notifyI18n } from "../i18n.js";
7
7
  import { _watchExpr } from "../globals.js";
8
8
  import { evaluate } from "../evaluate.js";
9
9
  import { findContext, _sanitizeHtml } from "../dom.js";
10
- import { registerDirective, processTree } from "../registry.js";
10
+ import { registerDirective, processTree, _disposeChildren } from "../registry.js";
11
11
 
12
12
  registerDirective("t", {
13
13
  priority: 20,
@@ -25,6 +25,7 @@ registerDirective("t", {
25
25
  }
26
26
  const text = _i18n.t(key, params);
27
27
  if (useHtml) {
28
+ _disposeChildren(el);
28
29
  el.innerHTML = _sanitizeHtml(text);
29
30
  } else {
30
31
  el.textContent = text;
@@ -72,7 +72,8 @@ registerDirective("each", {
72
72
  if (remaining <= 0) renderItems(tpl, list);
73
73
  };
74
74
  target.addEventListener("animationend", done, { once: true });
75
- setTimeout(done, animDuration || 2000);
75
+ // || 0: unblocks the next render on the next tick when no CSS animation fires.
76
+ setTimeout(done, animDuration || 0);
76
77
  });
77
78
  } else {
78
79
  renderItems(tpl, list);
@@ -356,7 +357,8 @@ registerDirective("foreach", {
356
357
  if (remaining <= 0) renderForeachItems();
357
358
  };
358
359
  target.addEventListener("animationend", done, { once: true });
359
- setTimeout(done, animDuration || 2000);
360
+ // || 0: unblocks the next render on the next tick when no CSS animation fires.
361
+ setTimeout(done, animDuration || 0);
360
362
  });
361
363
  } else {
362
364
  renderForeachItems();
@@ -10,6 +10,7 @@ import {
10
10
  _routerInstance,
11
11
  _warn,
12
12
  _onDispose,
13
+ _SENSITIVE_HEADERS,
13
14
  } from "../globals.js";
14
15
  import { _devtoolsEmit } from "../devtools.js";
15
16
  import { createContext } from "../context.js";
@@ -128,6 +129,14 @@ registerDirective("call", {
128
129
  }
129
130
 
130
131
  const extraHeaders = headersAttr ? JSON.parse(headersAttr) : {};
132
+ if (headersAttr) {
133
+ for (const k of Object.keys(extraHeaders)) {
134
+ const lower = k.toLowerCase();
135
+ if (_SENSITIVE_HEADERS.has(lower) || /^x-(auth|api)-/.test(lower)) {
136
+ _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.`);
137
+ }
138
+ }
139
+ }
131
140
  const data = await _doFetch(
132
141
  resolvedUrl,
133
142
  method,
@@ -2,7 +2,7 @@
2
2
  // DIRECTIVES: state, store, computed, watch
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _stores, _log, _warn, _watchExpr } from "../globals.js";
5
+ import { _stores, _log, _warn, _watchExpr, _onDispose } 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";
@@ -66,7 +66,7 @@ registerDirective("state", {
66
66
  _warn('State key(s) ' + riskyKeys.map(k => '"' + k + '"').join(', ') + ' may contain sensitive data. Consider using persist-fields to exclude them.');
67
67
  }
68
68
 
69
- ctx.$watch(() => {
69
+ const unwatch = ctx.$watch(() => {
70
70
  try {
71
71
  const raw = ctx.__raw;
72
72
  const data = persistFields
@@ -77,6 +77,7 @@ registerDirective("state", {
77
77
  /* ignore */
78
78
  }
79
79
  });
80
+ _onDispose(() => { if (unwatch) unwatch(); });
80
81
  }
81
82
  }
82
83
 
@@ -417,26 +417,33 @@ registerDirective("validate", {
417
417
 
418
418
  if (triggers.includes("input")) {
419
419
  field.addEventListener("input", handler);
420
+ _onDispose(() => field.removeEventListener("input", handler));
420
421
  } else {
421
422
  // Always track dirty and re-validate on input for data accuracy
422
423
  // (validate-on only affects visual feedback like error-class/templates)
423
- field.addEventListener("input", () => {
424
+ const silentInputHandler = () => {
424
425
  dirtyFields.add(field.name);
425
426
  formCtx.dirty = true;
426
427
  checkValidity();
427
- });
428
+ };
429
+ field.addEventListener("input", silentInputHandler);
430
+ _onDispose(() => field.removeEventListener("input", silentInputHandler));
428
431
  }
429
432
  if (triggers.includes("blur") || triggers.includes("focusout")) {
430
- field.addEventListener("focusout", (e) => {
433
+ const blurFocusoutHandler = (e) => {
431
434
  touchHandler();
432
435
  if (triggers.includes("blur")) handler();
433
- });
436
+ };
437
+ field.addEventListener("focusout", blurFocusoutHandler);
438
+ _onDispose(() => field.removeEventListener("focusout", blurFocusoutHandler));
434
439
  } else {
435
440
  // Always track touched on focusout
436
441
  field.addEventListener("focusout", touchHandler);
442
+ _onDispose(() => field.removeEventListener("focusout", touchHandler));
437
443
  }
438
444
  if (triggers.includes("submit")) {
439
445
  field.addEventListener("focusout", touchHandler);
446
+ _onDispose(() => field.removeEventListener("focusout", touchHandler));
440
447
  }
441
448
  }
442
449
 
@@ -455,14 +462,18 @@ registerDirective("validate", {
455
462
  checkValidity();
456
463
  };
457
464
  el.addEventListener("input", inputHandler);
465
+ _onDispose(() => el.removeEventListener("input", inputHandler));
458
466
  el.addEventListener("change", inputHandler);
459
- el.addEventListener("focusout", (e) => {
467
+ _onDispose(() => el.removeEventListener("change", inputHandler));
468
+ const focusoutHandler = (e) => {
460
469
  if (e.target && e.target.name) {
461
470
  touchedFields.add(e.target.name);
462
471
  }
463
472
  formCtx.touched = true;
464
473
  checkValidity();
465
- });
474
+ };
475
+ el.addEventListener("focusout", focusoutHandler);
476
+ _onDispose(() => el.removeEventListener("focusout", focusoutHandler));
466
477
  } else {
467
478
  // Per-field event binding with validate-on
468
479
  for (const field of getFields()) {
@@ -470,7 +481,7 @@ registerDirective("validate", {
470
481
  }
471
482
  }
472
483
 
473
- el.addEventListener("submit", (e) => {
484
+ const submitHandler = (e) => {
474
485
  // If validate-on="submit", run validation now
475
486
  formCtx.submitting = true;
476
487
  // Mark all fields as touched on submit
@@ -484,7 +495,9 @@ registerDirective("validate", {
484
495
  formCtx.submitting = false;
485
496
  ctx.$set("$form", { ...formCtx });
486
497
  });
487
- });
498
+ };
499
+ el.addEventListener("submit", submitHandler);
500
+ _onDispose(() => el.removeEventListener("submit", submitHandler));
488
501
 
489
502
  // Initial check
490
503
  requestAnimationFrame(checkValidity);
@@ -501,7 +514,7 @@ registerDirective("validate", {
501
514
  el.tagName === "SELECT")
502
515
  ) {
503
516
  const errorTpl = el.getAttribute("error");
504
- el.addEventListener("input", () => {
517
+ const fieldInputHandler = () => {
505
518
  const err = _validateField(el.value, rules, {});
506
519
  if (err && errorTpl) {
507
520
  let errorEl = el.nextElementSibling?.__validationError
@@ -516,6 +529,7 @@ registerDirective("validate", {
516
529
  const clone = _cloneTemplate(errorTpl);
517
530
  if (clone) {
518
531
  const childCtx = createContext({ err: { message: err } }, ctx);
532
+ _disposeChildren(errorEl);
519
533
  errorEl.innerHTML = "";
520
534
  errorEl.__ctx = childCtx;
521
535
  errorEl.appendChild(clone);
@@ -525,9 +539,14 @@ registerDirective("validate", {
525
539
  const errorEl = el.nextElementSibling?.__validationError
526
540
  ? el.nextElementSibling
527
541
  : null;
528
- if (errorEl) errorEl.innerHTML = "";
542
+ if (errorEl) {
543
+ _disposeChildren(errorEl);
544
+ errorEl.innerHTML = "";
545
+ }
529
546
  }
530
- });
547
+ };
548
+ el.addEventListener("input", fieldInputHandler);
549
+ _onDispose(() => el.removeEventListener("input", fieldInputHandler));
531
550
  }
532
551
  },
533
552
  });
@@ -556,9 +575,11 @@ registerDirective("error-boundary", {
556
575
  }
557
576
 
558
577
  // Listen for NoJS expression errors dispatched from event handlers
559
- el.addEventListener("nojs:error", (e) => {
578
+ const nojsErrorHandler = (e) => {
560
579
  showFallback(e.detail?.message || "An error occurred");
561
- });
580
+ };
581
+ el.addEventListener("nojs:error", nojsErrorHandler);
582
+ _onDispose(() => el.removeEventListener("nojs:error", nojsErrorHandler));
562
583
 
563
584
  // Listen for window-level errors (resource load failures, etc.)
564
585
  const errorHandler = (e) => {
package/src/dom.js CHANGED
@@ -33,6 +33,51 @@ export function _cloneTemplate(id) {
33
33
  return tpl.content ? tpl.content.cloneNode(true) : null;
34
34
  }
35
35
 
36
+ // ─── SVG data URI deep-sanitization ──────────────────────────────────────────
37
+ // Strip JS vectors from raw SVG markup using DOMParser for robust sanitization.
38
+ // Regex-based approaches are bypassable via entity encoding and nested contexts.
39
+ function _sanitizeSvgContent(svg) {
40
+ const doc = new DOMParser().parseFromString(svg, "image/svg+xml");
41
+ const root = doc.documentElement;
42
+ if (root.querySelector("parsererror") ||
43
+ root.nodeName !== "svg" ||
44
+ root.getElementsByTagNameNS("http://www.mozilla.org/newlayout/xml/parsererror.xml", "parsererror").length) {
45
+ return "<svg></svg>";
46
+ }
47
+ function cleanAttrs(node) {
48
+ for (const attr of [...node.attributes]) {
49
+ const name = attr.name.toLowerCase();
50
+ if (name.startsWith("on")) { node.removeAttribute(attr.name); continue; }
51
+ if ((name === "href" || name === "xlink:href") &&
52
+ attr.value.trim().toLowerCase().startsWith("javascript:")) {
53
+ node.removeAttribute(attr.name);
54
+ }
55
+ }
56
+ }
57
+ for (const s of [...root.querySelectorAll("script")]) s.remove();
58
+ cleanAttrs(root);
59
+ for (const node of root.querySelectorAll("*")) cleanAttrs(node);
60
+ return new XMLSerializer().serializeToString(root);
61
+ }
62
+
63
+ // Sanitize a data:image/svg+xml URI — handles both base64 and URL-encoded forms.
64
+ function _sanitizeSvgDataUri(str) {
65
+ try {
66
+ const b64 = str.match(/^data:image\/svg\+xml;base64,(.+)$/i);
67
+ if (b64) {
68
+ const clean = _sanitizeSvgContent(atob(b64[1]));
69
+ return "data:image/svg+xml;base64," + btoa(clean);
70
+ }
71
+ const comma = str.indexOf(",");
72
+ if (comma === -1) return "#";
73
+ const header = str.slice(0, comma + 1);
74
+ const clean = _sanitizeSvgContent(decodeURIComponent(str.slice(comma + 1)));
75
+ return header + encodeURIComponent(clean);
76
+ } catch (_e) {
77
+ return "#";
78
+ }
79
+ }
80
+
36
81
  // Structural HTML sanitizer — uses DOMParser to parse the markup before cleaning.
37
82
  // Regex-based sanitizers are bypassable via SVG/MathML event handlers, nested
38
83
  // srcdoc attributes, and HTML entity encoding (e.g. &#x6A;avascript:).
@@ -65,11 +110,15 @@ export function _sanitizeHtml(html) {
65
110
  for (const attr of [...child.attributes]) {
66
111
  const n = attr.name.toLowerCase();
67
112
  const v = attr.value.toLowerCase().trimStart();
68
- const isUrlAttr = n === 'href' || n === 'src' || n === 'action' || n === 'xlink:href';
113
+ const isUrlAttr = n === 'href' || n === 'src' || n === 'action' || n === 'xlink:href'
114
+ || n === 'formaction' || n === 'poster' || n === 'data';
69
115
  const isDangerousScheme = v.startsWith('javascript:') || v.startsWith('vbscript:');
70
116
  const isDangerousData = isUrlAttr && v.startsWith('data:') && !/^data:image\//.test(v);
71
117
  if (n.startsWith('on') || isDangerousScheme || isDangerousData) {
72
118
  child.removeAttribute(attr.name);
119
+ } else if (isUrlAttr && v.startsWith('data:image/svg+xml')) {
120
+ // Deep-sanitize SVG data URIs to strip embedded <script> and on* handlers
121
+ child.setAttribute(attr.name, _sanitizeSvgDataUri(attr.value));
73
122
  }
74
123
  }
75
124
  _clean(child);