@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/README.md +68 -6
- package/dist/cjs/no.js +7 -7
- package/dist/cjs/no.js.map +4 -4
- package/dist/esm/no.js +7 -7
- package/dist/esm/no.js.map +4 -4
- package/dist/iife/no.js +7 -7
- package/dist/iife/no.js.map +4 -4
- package/package.json +1 -1
- package/src/animations.js +11 -7
- package/src/context.js +6 -1
- package/src/devtools.js +34 -6
- package/src/directives/binding.js +13 -3
- package/src/directives/dnd.js +11 -2
- package/src/directives/events.js +1 -0
- package/src/directives/head.js +142 -0
- package/src/directives/http.js +1 -5
- package/src/directives/i18n.js +2 -1
- package/src/directives/loops.js +4 -2
- package/src/directives/refs.js +9 -0
- package/src/directives/state.js +3 -2
- package/src/directives/validation.js +34 -13
- package/src/dom.js +50 -1
- package/src/evaluate.js +11 -1
- package/src/fetch.js +137 -6
- package/src/globals.js +26 -1
- package/src/index.js +276 -32
- package/src/registry.js +12 -1
- package/src/router.js +39 -8
package/package.json
CHANGED
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
|
-
|
|
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.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
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)
|
|
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();
|
package/src/directives/dnd.js
CHANGED
|
@@ -805,7 +805,7 @@ registerDirective("drag-list", {
|
|
|
805
805
|
wrapper.addEventListener("dragend", itemDragend);
|
|
806
806
|
|
|
807
807
|
// Keyboard DnD on items
|
|
808
|
-
|
|
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
|
});
|
package/src/directives/events.js
CHANGED
|
@@ -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
|
+
});
|
package/src/directives/http.js
CHANGED
|
@@ -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,
|
package/src/directives/i18n.js
CHANGED
|
@@ -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;
|
package/src/directives/loops.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|
package/src/directives/refs.js
CHANGED
|
@@ -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,
|
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, _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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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. javascript:).
|
|
@@ -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);
|