@erickxavier/no-js 1.10.0 → 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 +36 -6
- package/src/directives/binding.js +43 -8
- 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 +12 -24
- package/src/directives/i18n.js +4 -3
- package/src/directives/loops.js +4 -2
- package/src/directives/refs.js +9 -0
- package/src/directives/state.js +26 -6
- package/src/directives/styling.js +1 -1
- package/src/directives/validation.js +34 -13
- package/src/dom.js +68 -2
- package/src/evaluate.js +149 -4
- package/src/fetch.js +153 -9
- package/src/filters.js +6 -1
- package/src/globals.js +28 -1
- package/src/index.js +284 -31
- 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) ────
|
|
@@ -208,6 +208,8 @@ function _handleDevtoolsCommand(event) {
|
|
|
208
208
|
break;
|
|
209
209
|
case "get:config":
|
|
210
210
|
result = { ..._config };
|
|
211
|
+
if (result.csrf) result.csrf = { ...result.csrf, token: '[REDACTED]' };
|
|
212
|
+
if (result.headers) result.headers = '[REDACTED]';
|
|
211
213
|
break;
|
|
212
214
|
case "get:routes":
|
|
213
215
|
result = _routerInstance ? _routerInstance.routes || [] : [];
|
|
@@ -235,6 +237,15 @@ function _handleDevtoolsCommand(event) {
|
|
|
235
237
|
);
|
|
236
238
|
}
|
|
237
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
|
+
|
|
238
249
|
// ─── Initialization ─────────────────────────────────────────────────────────
|
|
239
250
|
|
|
240
251
|
export function initDevtools(nojs) {
|
|
@@ -245,17 +256,36 @@ export function initDevtools(nojs) {
|
|
|
245
256
|
return;
|
|
246
257
|
}
|
|
247
258
|
|
|
248
|
-
// Listen for commands
|
|
259
|
+
// Listen for commands (store reference for cleanup)
|
|
249
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
|
+
};
|
|
250
266
|
|
|
251
267
|
// Expose public API on window
|
|
252
268
|
window.__NOJS_DEVTOOLS__ = {
|
|
253
|
-
// Data access
|
|
254
|
-
stores
|
|
255
|
-
|
|
256
|
-
|
|
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 }; },
|
|
257
285
|
router: _routerInstance,
|
|
258
286
|
version: nojs.version,
|
|
287
|
+
get plugins() { return new Map(_plugins); },
|
|
288
|
+
get globals() { return { ..._globals }; },
|
|
259
289
|
|
|
260
290
|
// Inspect API
|
|
261
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();
|
|
@@ -35,12 +45,37 @@ registerDirective("bind-html", {
|
|
|
35
45
|
|
|
36
46
|
const _SAFE_URL_ATTRS = new Set(["href", "src", "action", "formaction", "poster", "data"]);
|
|
37
47
|
|
|
38
|
-
// Strip JS vectors from raw SVG markup
|
|
48
|
+
// Strip JS vectors from raw SVG markup using DOMParser for robust sanitization.
|
|
49
|
+
// Regex-based approaches are bypassable via entity encoding and nested contexts.
|
|
39
50
|
function _sanitizeSvgContent(svg) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
const doc = new DOMParser().parseFromString(svg, "image/svg+xml");
|
|
52
|
+
const root = doc.documentElement;
|
|
53
|
+
// If parsing failed, DOMParser may wrap error in <parsererror> or produce a
|
|
54
|
+
// non-SVG root. In either case return an empty SVG for safety.
|
|
55
|
+
if (root.querySelector("parsererror") ||
|
|
56
|
+
root.nodeName !== "svg" ||
|
|
57
|
+
root.getElementsByTagNameNS("http://www.mozilla.org/newlayout/xml/parsererror.xml", "parsererror").length) {
|
|
58
|
+
return "<svg></svg>";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _cleanAttrs(node) {
|
|
62
|
+
for (const attr of [...node.attributes]) {
|
|
63
|
+
const name = attr.name.toLowerCase();
|
|
64
|
+
// Remove on* event handlers
|
|
65
|
+
if (name.startsWith("on")) { node.removeAttribute(attr.name); continue; }
|
|
66
|
+
// Remove javascript: in href/xlink:href
|
|
67
|
+
if ((name === "href" || name === "xlink:href") &&
|
|
68
|
+
attr.value.trim().toLowerCase().startsWith("javascript:")) {
|
|
69
|
+
node.removeAttribute(attr.name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Remove script elements
|
|
74
|
+
for (const s of [...root.querySelectorAll("script")]) s.remove();
|
|
75
|
+
// Clean attributes on root and all descendants
|
|
76
|
+
_cleanAttrs(root);
|
|
77
|
+
for (const node of root.querySelectorAll("*")) _cleanAttrs(node);
|
|
78
|
+
return new XMLSerializer().serializeToString(root);
|
|
44
79
|
}
|
|
45
80
|
|
|
46
81
|
// Sanitize a data:image/svg+xml URI — handles both base64 and URL-encoded forms.
|
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,
|
|
@@ -126,7 +122,7 @@ for (const method of HTTP_METHODS) {
|
|
|
126
122
|
}
|
|
127
123
|
|
|
128
124
|
const extraHeaders = headersAttr ? JSON.parse(headersAttr) : {};
|
|
129
|
-
if (
|
|
125
|
+
if (headersAttr) {
|
|
130
126
|
for (const k of Object.keys(extraHeaders)) {
|
|
131
127
|
const lower = k.toLowerCase();
|
|
132
128
|
if (_SENSITIVE_HEADERS.has(lower) || /^x-(auth|api)-/.test(lower)) {
|
|
@@ -134,24 +130,16 @@ for (const method of HTTP_METHODS) {
|
|
|
134
130
|
}
|
|
135
131
|
}
|
|
136
132
|
}
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
extraHeaders,
|
|
148
|
-
el,
|
|
149
|
-
_activeAbort.signal,
|
|
150
|
-
);
|
|
151
|
-
} finally {
|
|
152
|
-
_config.retries = savedRetries;
|
|
153
|
-
_config.retryDelay = savedRetryDelay;
|
|
154
|
-
}
|
|
133
|
+
const data = await _doFetch(
|
|
134
|
+
resolvedUrl,
|
|
135
|
+
method,
|
|
136
|
+
reqBody,
|
|
137
|
+
extraHeaders,
|
|
138
|
+
el,
|
|
139
|
+
_activeAbort.signal,
|
|
140
|
+
retryCount,
|
|
141
|
+
retryDelay,
|
|
142
|
+
);
|
|
155
143
|
|
|
156
144
|
// Cache response
|
|
157
145
|
if (method === "get") _cacheSet(cacheKey, data, cacheStrategy);
|
package/src/directives/i18n.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
import { _i18n, _watchI18n, _loadI18nNamespace, _notifyI18n } from "../i18n.js";
|
|
7
7
|
import { _watchExpr } from "../globals.js";
|
|
8
8
|
import { evaluate } from "../evaluate.js";
|
|
9
|
-
import { findContext } from "../dom.js";
|
|
10
|
-
import { registerDirective, processTree } from "../registry.js";
|
|
9
|
+
import { findContext, _sanitizeHtml } from "../dom.js";
|
|
10
|
+
import { registerDirective, processTree, _disposeChildren } from "../registry.js";
|
|
11
11
|
|
|
12
12
|
registerDirective("t", {
|
|
13
13
|
priority: 20,
|
|
@@ -25,7 +25,8 @@ registerDirective("t", {
|
|
|
25
25
|
}
|
|
26
26
|
const text = _i18n.t(key, params);
|
|
27
27
|
if (useHtml) {
|
|
28
|
-
el
|
|
28
|
+
_disposeChildren(el);
|
|
29
|
+
el.innerHTML = _sanitizeHtml(text);
|
|
29
30
|
} else {
|
|
30
31
|
el.textContent = text;
|
|
31
32
|
}
|
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";
|
|
@@ -12,9 +12,9 @@ import { _devtoolsEmit } from "../devtools.js";
|
|
|
12
12
|
registerDirective("state", {
|
|
13
13
|
priority: 0,
|
|
14
14
|
init(el, name, value) {
|
|
15
|
-
const
|
|
15
|
+
const initialState = evaluate(value, createContext()) || {};
|
|
16
16
|
const parent = el.parentElement ? findContext(el.parentElement) : null;
|
|
17
|
-
const ctx = createContext(
|
|
17
|
+
const ctx = createContext(initialState, parent);
|
|
18
18
|
el.__ctx = ctx;
|
|
19
19
|
|
|
20
20
|
// Persistence
|
|
@@ -40,14 +40,33 @@ registerDirective("state", {
|
|
|
40
40
|
const saved = store.getItem("nojs_state_" + persistKey);
|
|
41
41
|
if (saved) {
|
|
42
42
|
const parsed = JSON.parse(saved);
|
|
43
|
+
const schemaCheck = el.hasAttribute("persist-schema");
|
|
43
44
|
for (const [k, v] of Object.entries(parsed)) {
|
|
44
|
-
if (!persistFields || persistFields.has(k))
|
|
45
|
+
if (!persistFields || persistFields.has(k)) {
|
|
46
|
+
if (schemaCheck) {
|
|
47
|
+
if (!(k in initialState)) { _warn('persist-schema: ignoring unknown key "' + k + '"'); continue; }
|
|
48
|
+
if (initialState[k] !== null && v !== null && typeof v !== typeof initialState[k]) {
|
|
49
|
+
_warn('persist-schema: type mismatch for "' + k + '" (expected ' + typeof initialState[k] + ', got ' + typeof v + ')');
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
ctx.$set(k, v);
|
|
54
|
+
}
|
|
45
55
|
}
|
|
46
56
|
}
|
|
47
57
|
} catch {
|
|
48
58
|
/* ignore */
|
|
49
59
|
}
|
|
50
|
-
|
|
60
|
+
|
|
61
|
+
// Warn about potentially sensitive field names in persisted state
|
|
62
|
+
const sensitiveNames = ['token', 'password', 'secret', 'key', 'auth', 'credential', 'session'];
|
|
63
|
+
const stateKeys = Object.keys(initialState);
|
|
64
|
+
const riskyKeys = stateKeys.filter(k => sensitiveNames.some(s => k.toLowerCase().includes(s)));
|
|
65
|
+
if (riskyKeys.length > 0) {
|
|
66
|
+
_warn('State key(s) ' + riskyKeys.map(k => '"' + k + '"').join(', ') + ' may contain sensitive data. Consider using persist-fields to exclude them.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const unwatch = ctx.$watch(() => {
|
|
51
70
|
try {
|
|
52
71
|
const raw = ctx.__raw;
|
|
53
72
|
const data = persistFields
|
|
@@ -58,10 +77,11 @@ registerDirective("state", {
|
|
|
58
77
|
/* ignore */
|
|
59
78
|
}
|
|
60
79
|
});
|
|
80
|
+
_onDispose(() => { if (unwatch) unwatch(); });
|
|
61
81
|
}
|
|
62
82
|
}
|
|
63
83
|
|
|
64
|
-
_log("state",
|
|
84
|
+
_log("state", initialState);
|
|
65
85
|
},
|
|
66
86
|
});
|
|
67
87
|
|
|
@@ -55,7 +55,7 @@ registerDirective("class-*", {
|
|
|
55
55
|
el.classList.toggle(suffix, !!evaluate(expr, ctx));
|
|
56
56
|
}
|
|
57
57
|
_watchExpr(expr, ctx, update);
|
|
58
|
-
if (expr.includes("$i18n") || expr.includes("NoJS.locale")) _watchI18n(update);
|
|
58
|
+
if (expr.includes("$i18n") || expr.includes("NoJS.locale") || expr.includes("window.NoJS.locale")) _watchI18n(update);
|
|
59
59
|
update();
|
|
60
60
|
},
|
|
61
61
|
});
|