@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erickxavier/no-js",
3
- "version": "1.10.0",
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) ────
@@ -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: _stores,
255
- config: _config,
256
- 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 }; },
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) 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();
@@ -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: <script> blocks and on* event handlers.
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
- 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, "");
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.
@@ -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,
@@ -126,7 +122,7 @@ for (const method of HTTP_METHODS) {
126
122
  }
127
123
 
128
124
  const extraHeaders = headersAttr ? JSON.parse(headersAttr) : {};
129
- if (_config.debug && headersAttr) {
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 savedRetries = _config.retries;
138
- const savedRetryDelay = _config.retryDelay;
139
- _config.retries = retryCount;
140
- _config.retryDelay = retryDelay;
141
- let data;
142
- try {
143
- data = await _doFetch(
144
- resolvedUrl,
145
- method,
146
- reqBody,
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);
@@ -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.innerHTML = text;
28
+ _disposeChildren(el);
29
+ el.innerHTML = _sanitizeHtml(text);
29
30
  } else {
30
31
  el.textContent = text;
31
32
  }
@@ -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";
@@ -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 data = evaluate(value, createContext()) || {};
15
+ const initialState = evaluate(value, createContext()) || {};
16
16
  const parent = el.parentElement ? findContext(el.parentElement) : null;
17
- const ctx = createContext(data, parent);
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)) ctx.$set(k, v);
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
- ctx.$watch(() => {
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", data);
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
  });