@erickxavier/no-js 1.9.1 → 1.10.1

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.
@@ -2,7 +2,7 @@
2
2
  // DIRECTIVES: state, store, computed, watch
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _stores, _log, _watchExpr } from "../globals.js";
5
+ import { _stores, _log, _warn, _watchExpr } from "../globals.js";
6
6
  import { createContext } from "../context.js";
7
7
  import { evaluate, _execStatement } from "../evaluate.js";
8
8
  import { findContext } from "../dom.js";
@@ -12,14 +12,18 @@ 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
21
21
  const persist = el.getAttribute("persist");
22
22
  const persistKey = el.getAttribute("persist-key");
23
+ if (persist && !persistKey) {
24
+ _warn(`persist="${persist}" requires a persist-key attribute. State will not be persisted.`);
25
+ return;
26
+ }
23
27
  if (persist && persistKey) {
24
28
  const store =
25
29
  persist === "localStorage"
@@ -28,21 +32,47 @@ registerDirective("state", {
28
32
  ? sessionStorage
29
33
  : null;
30
34
  if (store) {
35
+ const persistFieldsAttr = el.getAttribute("persist-fields");
36
+ const persistFields = persistFieldsAttr
37
+ ? new Set(persistFieldsAttr.split(",").map((f) => f.trim()))
38
+ : null;
31
39
  try {
32
40
  const saved = store.getItem("nojs_state_" + persistKey);
33
41
  if (saved) {
34
42
  const parsed = JSON.parse(saved);
35
- for (const [k, v] of Object.entries(parsed)) ctx.$set(k, v);
43
+ const schemaCheck = el.hasAttribute("persist-schema");
44
+ for (const [k, v] of Object.entries(parsed)) {
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
+ }
55
+ }
36
56
  }
37
57
  } catch {
38
58
  /* ignore */
39
59
  }
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
+
40
69
  ctx.$watch(() => {
41
70
  try {
42
- store.setItem(
43
- "nojs_state_" + persistKey,
44
- JSON.stringify(ctx.__raw),
45
- );
71
+ const raw = ctx.__raw;
72
+ const data = persistFields
73
+ ? Object.fromEntries(Object.entries(raw).filter(([k]) => persistFields.has(k)))
74
+ : raw;
75
+ store.setItem("nojs_state_" + persistKey, JSON.stringify(data));
46
76
  } catch {
47
77
  /* ignore */
48
78
  }
@@ -50,7 +80,7 @@ registerDirective("state", {
50
80
  }
51
81
  }
52
82
 
53
- _log("state", data);
83
+ _log("state", initialState);
54
84
  },
55
85
  });
56
86
 
@@ -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
  });
package/src/dom.js CHANGED
@@ -33,14 +33,51 @@ export function _cloneTemplate(id) {
33
33
  return tpl.content ? tpl.content.cloneNode(true) : null;
34
34
  }
35
35
 
36
- // Simple HTML sanitizer
36
+ // Structural HTML sanitizer — uses DOMParser to parse the markup before cleaning.
37
+ // Regex-based sanitizers are bypassable via SVG/MathML event handlers, nested
38
+ // srcdoc attributes, and HTML entity encoding (e.g. javascript:).
39
+ // DOMParser resolves entities and builds a real DOM tree, making all vectors
40
+ // uniformly detectable by a single attribute-name/value check.
41
+ //
42
+ // Custom hook: set _config.sanitizeHtml to a function to plug in an external
43
+ // sanitizer (e.g. DOMPurify) without bundling it as a hard dependency.
44
+ const _BLOCKED_TAGS = new Set([
45
+ 'script', 'style', 'iframe', 'object', 'embed',
46
+ 'base', 'form', 'meta', 'link', 'noscript',
47
+ ]);
48
+
37
49
  export function _sanitizeHtml(html) {
38
- if (!_config.sanitize) return html;
39
- const safe = html
40
- .replace(/<script[\s\S]*?<\/script>/gi, "")
41
- .replace(/on\w+\s*=/gi, "data-blocked=")
42
- .replace(/javascript:/gi, "");
43
- return safe;
50
+ if (_config.dangerouslyDisableSanitize || !_config.sanitize) {
51
+ _warn('HTML sanitization is DISABLED. This exposes your app to XSS attacks. Only disable for trusted content.');
52
+ return html;
53
+ }
54
+ if (typeof _config.sanitizeHtml === 'function') return _config.sanitizeHtml(html);
55
+
56
+ const doc = new DOMParser().parseFromString(html, 'text/html');
57
+
58
+ function _clean(node) {
59
+ for (const child of [...node.childNodes]) {
60
+ if (child.nodeType !== 1) continue; // text and comment nodes are safe
61
+ if (_BLOCKED_TAGS.has(child.tagName.toLowerCase())) {
62
+ child.remove();
63
+ continue;
64
+ }
65
+ for (const attr of [...child.attributes]) {
66
+ const n = attr.name.toLowerCase();
67
+ const v = attr.value.toLowerCase().trimStart();
68
+ const isUrlAttr = n === 'href' || n === 'src' || n === 'action' || n === 'xlink:href';
69
+ const isDangerousScheme = v.startsWith('javascript:') || v.startsWith('vbscript:');
70
+ const isDangerousData = isUrlAttr && v.startsWith('data:') && !/^data:image\//.test(v);
71
+ if (n.startsWith('on') || isDangerousScheme || isDangerousData) {
72
+ child.removeAttribute(attr.name);
73
+ }
74
+ }
75
+ _clean(child);
76
+ }
77
+ }
78
+
79
+ _clean(doc.body);
80
+ return doc.body.innerHTML;
44
81
  }
45
82
 
46
83
  // Resolve a template src path.
@@ -64,6 +101,18 @@ function _resolveTemplateSrc(src, tpl) {
64
101
  return resolveUrl(src, tpl);
65
102
  }
66
103
 
104
+ // Warn when a template URL uses plain HTTP from an HTTPS page (mixed content / MITM risk).
105
+ // The optional pageProtocol parameter enables testing without mutating jsdom's
106
+ // non-configurable window.location.protocol property.
107
+ export function _warnIfInsecureTemplateUrl(resolvedUrl, src, pageProtocol) {
108
+ const proto = pageProtocol !== undefined
109
+ ? pageProtocol
110
+ : (typeof window !== 'undefined' && window.location ? window.location.protocol : '');
111
+ if (resolvedUrl.startsWith('http://') && proto === 'https:') {
112
+ _warn('Template "' + src + '" is loaded over insecure HTTP from an HTTPS page. Use HTTPS to prevent tampering.');
113
+ }
114
+ }
115
+
67
116
  export async function _loadRemoteTemplates(root) {
68
117
  const scope = root || document;
69
118
  const templates = scope.querySelectorAll("template[src]");
@@ -74,6 +123,7 @@ export async function _loadRemoteTemplates(root) {
74
123
  tpl.__srcLoaded = true;
75
124
  const src = tpl.getAttribute("src");
76
125
  const resolvedUrl = _resolveTemplateSrc(src, tpl);
126
+ _warnIfInsecureTemplateUrl(resolvedUrl, src);
77
127
  // Track the folder of this template so children can use "./" paths
78
128
  const baseFolder = resolvedUrl.substring(0, resolvedUrl.lastIndexOf("/") + 1);
79
129
  try {
@@ -129,6 +179,7 @@ export async function _loadTemplateElement(tpl) {
129
179
  _log("[LTE] START fetch:", src, "| route:", tpl.hasAttribute("route"), "| inDOM:", document.contains(tpl), "| loading:", tpl.getAttribute("loading"));
130
180
  tpl.__srcLoaded = true;
131
181
  const resolvedUrl = _resolveTemplateSrc(src, tpl);
182
+ _warnIfInsecureTemplateUrl(resolvedUrl, src);
132
183
  const baseFolder = resolvedUrl.substring(0, resolvedUrl.lastIndexOf("/") + 1);
133
184
 
134
185
  // Synchronously insert loading placeholder before the fetch begins
package/src/evaluate.js CHANGED
@@ -2,12 +2,36 @@
2
2
  // EXPRESSION EVALUATOR
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _stores, _routerInstance, _filters, _warn, _notifyStoreWatchers } from "./globals.js";
5
+ import { _config, _stores, _routerInstance, _filters, _warn, _notifyStoreWatchers } from "./globals.js";
6
6
  import { _i18n } from "./i18n.js";
7
7
  import { _collectKeys } from "./context.js";
8
8
 
9
- const _exprCache = new Map();
10
- const _stmtCache = new Map();
9
+ function _makeCache() {
10
+ const map = new Map();
11
+ return {
12
+ get(k) {
13
+ if (!map.has(k)) return undefined;
14
+ // Move to end so this entry is the most-recently-used
15
+ const v = map.get(k);
16
+ map.delete(k);
17
+ map.set(k, v);
18
+ return v;
19
+ },
20
+ has(k) { return map.has(k); },
21
+ set(k, v) {
22
+ const max = _config.exprCacheSize;
23
+ if (map.has(k)) {
24
+ map.delete(k); // refresh position before re-inserting
25
+ } else if (map.size >= max) {
26
+ map.delete(map.keys().next().value); // evict LRU (insertion-order first)
27
+ }
28
+ map.set(k, v);
29
+ },
30
+ get size() { return map.size; },
31
+ };
32
+ }
33
+ export const _exprCache = _makeCache();
34
+ export const _stmtCache = _makeCache();
11
35
 
12
36
  // ── Tokenizer ──────────────────────────────────────────────────────────
13
37
 
@@ -707,7 +731,149 @@ const _SAFE_GLOBALS = {
707
731
  Error, Symbol, console,
708
732
  };
709
733
 
710
- const _DENY_GLOBALS = { eval: 1, Function: 1, process: 1, require: 1, importScripts: 1 };
734
+ // Explicit allow-list for browser globals accessible in template expressions.
735
+ // Using an allow-list (opt-in) rather than a deny-list (opt-out) ensures that
736
+ // network and storage APIs — fetch, XMLHttpRequest, localStorage, sessionStorage,
737
+ // WebSocket, indexedDB — are unreachable from template code by default, closing
738
+ // the surface where interpolated external data could trigger unintended requests.
739
+ // window, document, and location are further wrapped in Proxy objects below
740
+ // to block sensitive sub-properties (fetch, cookie, navigation, etc.).
741
+
742
+ // ── Security proxies for window, document, and location ─────────────────
743
+ // Even though window/document are on the allow-list, we wrap them in Proxy
744
+ // objects that block access to sensitive sub-properties (network, storage,
745
+ // cookie, eval, etc.) while still allowing safe DOM / measurement APIs.
746
+
747
+ const _BLOCKED_WINDOW_PROPS = new Set([
748
+ 'fetch', 'XMLHttpRequest', 'localStorage', 'sessionStorage',
749
+ 'WebSocket', 'indexedDB', 'eval', 'Function', 'importScripts',
750
+ 'open', 'postMessage',
751
+ ]);
752
+ // Props on window that must return safe proxies instead of raw objects
753
+ const _WINDOW_PROXY_OVERRIDES = {}; // populated after proxy creation below
754
+
755
+ const _BLOCKED_DOCUMENT_PROPS = new Set([
756
+ 'cookie', 'domain', 'write', 'writeln', 'execCommand',
757
+ ]);
758
+
759
+ const _safeWindow = typeof globalThis !== 'undefined' && typeof globalThis.window !== 'undefined'
760
+ ? new Proxy(globalThis.window, {
761
+ get(target, prop, receiver) {
762
+ if (typeof prop === 'string' && _BLOCKED_WINDOW_PROPS.has(prop)) return undefined;
763
+ if (typeof prop === 'string' && prop in _WINDOW_PROXY_OVERRIDES) return _WINDOW_PROXY_OVERRIDES[prop];
764
+ return Reflect.get(target, prop, receiver);
765
+ },
766
+ set(target, prop, value) {
767
+ // Block writes to dangerous window properties from expressions;
768
+ // allow writing user-defined properties (e.g. window.__myHelper)
769
+ if (typeof prop === 'string' && _BLOCKED_WINDOW_PROPS.has(prop)) return true;
770
+ if (prop === 'name' || prop === 'status') return true; // anti-exfiltration
771
+ target[prop] = value;
772
+ return true;
773
+ },
774
+ })
775
+ : undefined;
776
+
777
+ const _safeDocument = typeof globalThis !== 'undefined' && typeof globalThis.document !== 'undefined'
778
+ ? new Proxy(globalThis.document, {
779
+ get(target, prop, receiver) {
780
+ if (typeof prop === 'string' && _BLOCKED_DOCUMENT_PROPS.has(prop)) return undefined;
781
+ if (prop === 'defaultView') return _safeWindow;
782
+ return Reflect.get(target, prop, receiver);
783
+ },
784
+ set(target, prop, value) {
785
+ if (typeof prop === 'string' && _BLOCKED_DOCUMENT_PROPS.has(prop)) return true;
786
+ target[prop] = value;
787
+ return true;
788
+ },
789
+ })
790
+ : undefined;
791
+
792
+ // Read-only location wrapper — exposes common getters via a plain object with
793
+ // property descriptors that read from the real location. Navigation methods are
794
+ // replaced with no-ops. Using a plain object avoids Proxy invariant violations
795
+ // on non-configurable properties (like location.assign).
796
+ const _LOCATION_READ_PROPS = [
797
+ 'href', 'pathname', 'search', 'hash', 'origin',
798
+ 'hostname', 'port', 'protocol', 'host',
799
+ ];
800
+ const _locationNoop = () => {};
801
+
802
+ const _safeLocation = typeof globalThis !== 'undefined' && typeof globalThis.location !== 'undefined'
803
+ ? (() => {
804
+ const loc = {};
805
+ for (const prop of _LOCATION_READ_PROPS) {
806
+ Object.defineProperty(loc, prop, {
807
+ get() { return globalThis.location[prop]; },
808
+ set() { /* silently ignore writes */ },
809
+ enumerable: true, configurable: false,
810
+ });
811
+ }
812
+ loc.assign = _locationNoop;
813
+ loc.replace = _locationNoop;
814
+ loc.reload = _locationNoop;
815
+ loc.toString = () => globalThis.location.href;
816
+ return Object.freeze(loc);
817
+ })()
818
+ : undefined;
819
+
820
+ // Read-only history wrapper — exposes state and length as read-only getters,
821
+ // replaces navigation methods with no-ops. Prevents expressions from
822
+ // manipulating browser history (pushState, back, forward, etc.).
823
+ const _HISTORY_READ_PROPS = ['length', 'state', 'scrollRestoration'];
824
+
825
+ const _safeHistory = typeof globalThis !== 'undefined' && typeof globalThis.history !== 'undefined'
826
+ ? (() => {
827
+ const h = {};
828
+ for (const prop of _HISTORY_READ_PROPS) {
829
+ Object.defineProperty(h, prop, {
830
+ get() { return globalThis.history[prop]; },
831
+ enumerable: true, configurable: false,
832
+ });
833
+ }
834
+ h.pushState = _locationNoop;
835
+ h.replaceState = _locationNoop;
836
+ h.back = _locationNoop;
837
+ h.forward = _locationNoop;
838
+ h.go = _locationNoop;
839
+ return Object.freeze(h);
840
+ })()
841
+ : undefined;
842
+
843
+ // Navigator proxy — blocks sendBeacon (data exfiltration) and credentials
844
+ const _BLOCKED_NAVIGATOR_PROPS = new Set(['sendBeacon', 'credentials']);
845
+ const _safeNavigator = typeof globalThis !== 'undefined' && typeof globalThis.navigator !== 'undefined'
846
+ ? new Proxy(globalThis.navigator, {
847
+ get(target, prop, receiver) {
848
+ if (typeof prop === 'string' && _BLOCKED_NAVIGATOR_PROPS.has(prop)) return undefined;
849
+ return Reflect.get(target, prop, receiver);
850
+ },
851
+ set() { return true; }, // navigator props are browser-enforced read-only
852
+ })
853
+ : undefined;
854
+
855
+ // Wire window.location → _safeLocation, window.document → _safeDocument,
856
+ // window.history → _safeHistory, window.navigator → _safeNavigator
857
+ // so accessing via the window proxy returns safe versions
858
+ if (_safeLocation) _WINDOW_PROXY_OVERRIDES.location = _safeLocation;
859
+ if (_safeDocument) _WINDOW_PROXY_OVERRIDES.document = _safeDocument;
860
+ if (_safeHistory) _WINDOW_PROXY_OVERRIDES.history = _safeHistory;
861
+ if (_safeNavigator) _WINDOW_PROXY_OVERRIDES.navigator = _safeNavigator;
862
+
863
+ const _BROWSER_GLOBALS = new Set([
864
+ 'window', 'document', 'console', 'location', 'history',
865
+ 'navigator', 'screen', 'performance', 'crypto',
866
+ // setTimeout/setInterval allow deferred execution from template expressions;
867
+ // necessary for legitimate use cases (e.g. debounce patterns in event handlers).
868
+ 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval',
869
+ 'requestAnimationFrame', 'cancelAnimationFrame',
870
+ // alert/confirm/prompt are included for completeness and backward compatibility
871
+ // (e.g. confirm dialogs before delete). They are discouraged in production UIs —
872
+ // prefer custom modal components for a better user experience.
873
+ 'alert', 'confirm', 'prompt',
874
+ 'CustomEvent', 'Event', 'URL', 'URLSearchParams',
875
+ 'FormData', 'FileReader', 'Blob', 'Promise',
876
+ ]);
711
877
 
712
878
  function _evalNode(node, scope) {
713
879
  try {
@@ -721,8 +887,14 @@ function _evalNode(node, scope) {
721
887
  case 'Identifier':
722
888
  if (node.name in scope) return scope[node.name];
723
889
  if (node.name in _SAFE_GLOBALS) return _SAFE_GLOBALS[node.name];
724
- // Allow access to browser globals (window, document, etc.) for backward compat
725
- if (typeof globalThis !== 'undefined' && node.name in globalThis && !_DENY_GLOBALS[node.name]) return globalThis[node.name];
890
+ if (_BROWSER_GLOBALS.has(node.name) && typeof globalThis !== 'undefined') {
891
+ if (node.name === 'window') return _safeWindow;
892
+ if (node.name === 'document') return _safeDocument;
893
+ if (node.name === 'location') return _safeLocation;
894
+ if (node.name === 'history') return _safeHistory;
895
+ if (node.name === 'navigator') return _safeNavigator;
896
+ return globalThis[node.name];
897
+ }
726
898
  return undefined;
727
899
 
728
900
  case 'Forbidden':
@@ -873,7 +1045,12 @@ function _evalNode(node, scope) {
873
1045
  for (let i = 0; i < node.properties.length; i++) {
874
1046
  const prop = node.properties[i];
875
1047
  if (prop.spread) {
876
- Object.assign(obj, _evalNode(prop.value, scope));
1048
+ const src = _evalNode(prop.value, scope);
1049
+ if (src && typeof src === 'object') {
1050
+ for (const k of Object.keys(src)) {
1051
+ if (!_FORBIDDEN_PROPS[k]) obj[k] = src[k];
1052
+ }
1053
+ }
877
1054
  } else {
878
1055
  const key = prop.computed ? _evalNode(prop.key, scope) : prop.key;
879
1056
  if (_FORBIDDEN_PROPS[key]) continue;
@@ -1006,8 +1183,7 @@ function _execStmtNode(node, scope) {
1006
1183
  // so error-boundary directives can catch the error
1007
1184
  if (node.type === "CallExpr" && node.callee.type === "Identifier") {
1008
1185
  const name = node.callee.name;
1009
- if (!(name in scope) && !(name in _SAFE_GLOBALS) &&
1010
- (typeof globalThis === "undefined" || !(name in globalThis))) {
1186
+ if (!(name in scope) && !(name in _SAFE_GLOBALS) && !_BROWSER_GLOBALS.has(name)) {
1011
1187
  throw new ReferenceError(name + " is not defined");
1012
1188
  }
1013
1189
  }
@@ -1124,25 +1300,17 @@ export function evaluate(expr, ctx) {
1124
1300
  const mainExpr = pipes[0];
1125
1301
  const { keys, vals } = _collectKeys(ctx);
1126
1302
 
1127
- // Add special variables
1128
- const specialKeys = [
1129
- "$store",
1130
- "$route",
1131
- "$router",
1132
- "$i18n",
1133
- "$refs",
1134
- "$form",
1135
- ];
1136
- for (const sk of specialKeys) {
1137
- if (!keys.includes(sk)) {
1138
- keys.push(sk);
1139
- vals[sk] = ctx[sk];
1140
- }
1141
- }
1142
-
1143
- // Build scope object from keys/vals
1303
+ // Build scope from cache without mutating it
1144
1304
  const scope = {};
1145
1305
  for (let i = 0; i < keys.length; i++) scope[keys[i]] = vals[keys[i]];
1306
+ // Add special variables to scope only (never to the shared cache),
1307
+ // preserving any same-named local context vars already in scope
1308
+ if (!("$store" in scope)) scope.$store = _stores;
1309
+ if (!("$route" in scope)) scope.$route = _routerInstance?.current;
1310
+ if (!("$router" in scope)) scope.$router = _routerInstance;
1311
+ if (!("$i18n" in scope)) scope.$i18n = _i18n;
1312
+ if (!("$refs" in scope)) scope.$refs = ctx.$refs;
1313
+ if (!("$form" in scope)) scope.$form = ctx.$form || null;
1146
1314
 
1147
1315
  // Parse expression into AST (cached)
1148
1316
  let ast = _exprCache.get(mainExpr);
@@ -1170,25 +1338,16 @@ export function evaluate(expr, ctx) {
1170
1338
  export function _execStatement(expr, ctx, extraVars = {}) {
1171
1339
  try {
1172
1340
  const { keys, vals } = _collectKeys(ctx);
1173
- // Add special vars
1174
- const specials = {
1175
- $store: _stores,
1176
- $route: _routerInstance?.current,
1177
- $router: _routerInstance,
1178
- $i18n: _i18n,
1179
- $refs: ctx.$refs,
1180
- };
1181
- Object.assign(specials, extraVars);
1182
- for (const [k, v] of Object.entries(specials)) {
1183
- if (!keys.includes(k)) {
1184
- keys.push(k);
1185
- vals[k] = v;
1186
- }
1187
- }
1188
1341
 
1189
- // Build scope
1342
+ // Build scope from cache without mutating it, then add special vars and extraVars
1190
1343
  const scope = {};
1191
1344
  for (let i = 0; i < keys.length; i++) scope[keys[i]] = vals[keys[i]];
1345
+ if (!("$store" in scope)) scope.$store = _stores;
1346
+ if (!("$route" in scope)) scope.$route = _routerInstance?.current;
1347
+ if (!("$router" in scope)) scope.$router = _routerInstance;
1348
+ if (!("$i18n" in scope)) scope.$i18n = _i18n;
1349
+ if (!("$refs" in scope)) scope.$refs = ctx.$refs;
1350
+ Object.assign(scope, extraVars);
1192
1351
 
1193
1352
  // Snapshot context chain values for write-back comparison
1194
1353
  const chainKeys = new Set();
@@ -1227,10 +1386,11 @@ export function _execStatement(expr, ctx, extraVars = {}) {
1227
1386
  }
1228
1387
  }
1229
1388
 
1230
- // Write back new variables created during execution
1389
+ // Write back new variables created during execution.
1390
+ // Skip extraVars keys (e.g. __val, $el, $event) — they are execution-local
1391
+ // and must not be persisted to the reactive context.
1231
1392
  for (const k in scope) {
1232
- if (k.startsWith("$") || chainKeys.has(k)) continue;
1233
- if (k in vals) continue;
1393
+ if (k.startsWith("$") || chainKeys.has(k) || k in extraVars) continue;
1234
1394
  ctx.$set(k, scope[k]);
1235
1395
  }
1236
1396
 
@@ -1254,9 +1414,13 @@ export function resolve(path, ctx) {
1254
1414
  }
1255
1415
 
1256
1416
  // Interpolate strings like "/users/{user.id}?q={search}"
1417
+ // Note: interpolated values are encoded with encodeURIComponent, which encodes
1418
+ // "/" as "%2F". Path segments that intentionally contain "/" must be passed
1419
+ // as pre-encoded strings or concatenated outside of {} placeholders.
1257
1420
  export function _interpolate(str, ctx) {
1258
1421
  return str.replace(/\{([^}]+)\}/g, (_, expr) => {
1259
1422
  const val = evaluate(expr.trim(), ctx);
1260
- return val != null ? val : "";
1423
+ if (val == null) return "";
1424
+ return encodeURIComponent(String(val));
1261
1425
  });
1262
1426
  }
package/src/fetch.js CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { _config, _interceptors, _cache } from "./globals.js";
6
6
 
7
+ const _MAX_CACHE = 200;
8
+
7
9
  export function resolveUrl(url, el) {
8
10
  if (
9
11
  url.startsWith("http://") ||
@@ -31,6 +33,8 @@ export async function _doFetch(
31
33
  extraHeaders = {},
32
34
  el = null,
33
35
  externalSignal = null,
36
+ retries = undefined,
37
+ retryDelay = undefined,
34
38
  ) {
35
39
  const fullUrl = resolveUrl(url, el);
36
40
  let opts = {
@@ -68,7 +72,7 @@ export async function _doFetch(
68
72
  }
69
73
 
70
74
  // Retry logic
71
- const maxRetries = _config.retries || 0;
75
+ const maxRetries = retries !== undefined ? retries : (_config.retries || 0);
72
76
  let lastError;
73
77
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
74
78
  try {
@@ -115,7 +119,7 @@ export async function _doFetch(
115
119
  if (e.name === "AbortError") throw e; // Don't retry aborted requests
116
120
  lastError = e;
117
121
  if (attempt < maxRetries) {
118
- await new Promise((r) => setTimeout(r, _config.retryDelay || 1000));
122
+ await new Promise((r) => setTimeout(r, retryDelay !== undefined ? retryDelay : (_config.retryDelay || 1000)));
119
123
  }
120
124
  }
121
125
  }
@@ -126,8 +130,12 @@ export function _cacheGet(key, strategy) {
126
130
  if (strategy === "none") return null;
127
131
  if (strategy === "memory") {
128
132
  const entry = _cache.get(key);
129
- if (entry && Date.now() - entry.time < (_config.cache.ttl || 300000))
133
+ if (entry && Date.now() - entry.time < (_config.cache.ttl || 300000)) {
134
+ // Move to end (most-recently-used) for LRU eviction
135
+ _cache.delete(key);
136
+ _cache.set(key, entry);
130
137
  return entry.data;
138
+ }
131
139
  return null;
132
140
  }
133
141
  const store =
@@ -154,6 +162,11 @@ export function _cacheSet(key, data, strategy) {
154
162
  if (strategy === "none") return;
155
163
  const entry = { data, time: Date.now() };
156
164
  if (strategy === "memory") {
165
+ if (_cache.has(key)) {
166
+ _cache.delete(key); // refresh position before re-inserting
167
+ } else if (_cache.size >= _MAX_CACHE) {
168
+ _cache.delete(_cache.keys().next().value); // evict LRU (insertion-order first)
169
+ }
157
170
  _cache.set(key, entry);
158
171
  return;
159
172
  }
package/src/filters.js CHANGED
@@ -20,7 +20,12 @@ _filters.slugify = (v) =>
20
20
  .toLowerCase()
21
21
  .replace(/[^a-z0-9]+/g, "-")
22
22
  .replace(/^-|-$/g, "");
23
- _filters.nl2br = (v) => String(v ?? "").replace(/\n/g, "<br>");
23
+ _filters.nl2br = (v) =>
24
+ String(v ?? "")
25
+ .replace(/&/g, "&amp;")
26
+ .replace(/</g, "&lt;")
27
+ .replace(/>/g, "&gt;")
28
+ .replace(/\n/g, "<br>");
24
29
  _filters.encodeUri = (v) => encodeURIComponent(String(v ?? ""));
25
30
 
26
31
  // Numbers
package/src/globals.js CHANGED
@@ -17,6 +17,10 @@ export const _config = {
17
17
  debug: false,
18
18
  devtools: false,
19
19
  sanitize: true,
20
+ dangerouslyDisableSanitize: false,
21
+ sanitizeHtml: null,
22
+ exprCacheSize: 500,
23
+ maxEventListeners: 100,
20
24
  };
21
25
 
22
26
  export const _interceptors = { request: [], response: [] };
@@ -68,6 +72,22 @@ export function _watchExpr(expr, ctx, fn) {
68
72
  });
69
73
  if (typeof expr === "string" && expr.includes("$store")) {
70
74
  _storeWatchers.add(fn);
75
+ fn._el = _currentEl;
76
+ // Self-cleanup when the element is removed without going through dispose
77
+ const el = _currentEl;
78
+ if (el && el.parentElement) {
79
+ const ro = new MutationObserver(() => {
80
+ if (!el.isConnected) {
81
+ _storeWatchers.delete(fn);
82
+ unwatch();
83
+ ro.disconnect();
84
+ }
85
+ });
86
+ // subtree: false — we only care about direct children of parentElement being removed
87
+ ro.observe(el.parentElement, { childList: true, subtree: false });
88
+ // Also disconnect via the normal disposal path to avoid a dangling MO
89
+ _onDispose(() => ro.disconnect());
90
+ }
71
91
  }
72
92
  }
73
93
 
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  _routerInstance,
16
16
  setRouterInstance,
17
17
  _log,
18
+ _warn,
18
19
  _notifyStoreWatchers,
19
20
  } from "./globals.js";
20
21
  import { _i18n, _loadI18nForLocale } from "./i18n.js";
@@ -87,7 +88,14 @@ const NoJS = {
87
88
  _warn("csp config option removed — No.JS is now CSP-safe by default");
88
89
  delete opts.csp;
89
90
  }
91
+ if (opts.exprCacheSize !== undefined) {
92
+ const n = parseInt(opts.exprCacheSize);
93
+ opts.exprCacheSize = (Number.isFinite(n) && n > 0) ? n : 500;
94
+ }
90
95
  Object.assign(_config, opts);
96
+ if (opts.sanitize === false) {
97
+ _warn('sanitize:false is deprecated — use dangerouslyDisableSanitize:true to make the risk explicit.');
98
+ }
91
99
  if (opts.headers)
92
100
  _config.headers = { ...prevHeaders, ...opts.headers };
93
101
  if (opts.csrf) _config.csrf = opts.csrf;
@@ -210,6 +218,12 @@ const NoJS = {
210
218
  // Event bus
211
219
  on(event, fn) {
212
220
  if (!_eventBus[event]) _eventBus[event] = [];
221
+ if (_eventBus[event].length >= _config.maxEventListeners) {
222
+ _warn(
223
+ 'MaxListenersExceeded: event "' + event + '" has ' + _eventBus[event].length +
224
+ ' listeners (max ' + _config.maxEventListeners + '). Possible memory leak.'
225
+ );
226
+ }
213
227
  _eventBus[event].push(fn);
214
228
  return () => {
215
229
  _eventBus[event] = _eventBus[event].filter((f) => f !== fn);
@@ -244,7 +258,7 @@ const NoJS = {
244
258
  resolve,
245
259
 
246
260
  // Version
247
- version: "1.9.1",
261
+ version: "1.10.1",
248
262
  };
249
263
 
250
264
  export default NoJS;