@erickxavier/no-js 1.9.1 → 1.10.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/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,26 @@ 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.fetch / window.localStorage remain accessible via the window object.
740
+ const _BROWSER_GLOBALS = new Set([
741
+ 'window', 'document', 'console', 'location', 'history',
742
+ 'navigator', 'screen', 'performance', 'crypto',
743
+ // setTimeout/setInterval allow deferred execution from template expressions;
744
+ // necessary for legitimate use cases (e.g. debounce patterns in event handlers).
745
+ 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval',
746
+ 'requestAnimationFrame', 'cancelAnimationFrame',
747
+ // alert/confirm/prompt are included for completeness and backward compatibility
748
+ // (e.g. confirm dialogs before delete). They are discouraged in production UIs —
749
+ // prefer custom modal components for a better user experience.
750
+ 'alert', 'confirm', 'prompt',
751
+ 'CustomEvent', 'Event', 'URL', 'URLSearchParams',
752
+ 'FormData', 'FileReader', 'Blob', 'Promise',
753
+ ]);
711
754
 
712
755
  function _evalNode(node, scope) {
713
756
  try {
@@ -721,8 +764,7 @@ function _evalNode(node, scope) {
721
764
  case 'Identifier':
722
765
  if (node.name in scope) return scope[node.name];
723
766
  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];
767
+ if (_BROWSER_GLOBALS.has(node.name) && typeof globalThis !== 'undefined') return globalThis[node.name];
726
768
  return undefined;
727
769
 
728
770
  case 'Forbidden':
@@ -1006,8 +1048,7 @@ function _execStmtNode(node, scope) {
1006
1048
  // so error-boundary directives can catch the error
1007
1049
  if (node.type === "CallExpr" && node.callee.type === "Identifier") {
1008
1050
  const name = node.callee.name;
1009
- if (!(name in scope) && !(name in _SAFE_GLOBALS) &&
1010
- (typeof globalThis === "undefined" || !(name in globalThis))) {
1051
+ if (!(name in scope) && !(name in _SAFE_GLOBALS) && !_BROWSER_GLOBALS.has(name)) {
1011
1052
  throw new ReferenceError(name + " is not defined");
1012
1053
  }
1013
1054
  }
@@ -1124,25 +1165,17 @@ export function evaluate(expr, ctx) {
1124
1165
  const mainExpr = pipes[0];
1125
1166
  const { keys, vals } = _collectKeys(ctx);
1126
1167
 
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
1168
+ // Build scope from cache without mutating it
1144
1169
  const scope = {};
1145
1170
  for (let i = 0; i < keys.length; i++) scope[keys[i]] = vals[keys[i]];
1171
+ // Add special variables to scope only (never to the shared cache),
1172
+ // preserving any same-named local context vars already in scope
1173
+ if (!("$store" in scope)) scope.$store = _stores;
1174
+ if (!("$route" in scope)) scope.$route = _routerInstance?.current;
1175
+ if (!("$router" in scope)) scope.$router = _routerInstance;
1176
+ if (!("$i18n" in scope)) scope.$i18n = _i18n;
1177
+ if (!("$refs" in scope)) scope.$refs = ctx.$refs;
1178
+ if (!("$form" in scope)) scope.$form = ctx.$form || null;
1146
1179
 
1147
1180
  // Parse expression into AST (cached)
1148
1181
  let ast = _exprCache.get(mainExpr);
@@ -1170,25 +1203,16 @@ export function evaluate(expr, ctx) {
1170
1203
  export function _execStatement(expr, ctx, extraVars = {}) {
1171
1204
  try {
1172
1205
  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
1206
 
1189
- // Build scope
1207
+ // Build scope from cache without mutating it, then add special vars and extraVars
1190
1208
  const scope = {};
1191
1209
  for (let i = 0; i < keys.length; i++) scope[keys[i]] = vals[keys[i]];
1210
+ if (!("$store" in scope)) scope.$store = _stores;
1211
+ if (!("$route" in scope)) scope.$route = _routerInstance?.current;
1212
+ if (!("$router" in scope)) scope.$router = _routerInstance;
1213
+ if (!("$i18n" in scope)) scope.$i18n = _i18n;
1214
+ if (!("$refs" in scope)) scope.$refs = ctx.$refs;
1215
+ Object.assign(scope, extraVars);
1192
1216
 
1193
1217
  // Snapshot context chain values for write-back comparison
1194
1218
  const chainKeys = new Set();
@@ -1227,10 +1251,11 @@ export function _execStatement(expr, ctx, extraVars = {}) {
1227
1251
  }
1228
1252
  }
1229
1253
 
1230
- // Write back new variables created during execution
1254
+ // Write back new variables created during execution.
1255
+ // Skip extraVars keys (e.g. __val, $el, $event) — they are execution-local
1256
+ // and must not be persisted to the reactive context.
1231
1257
  for (const k in scope) {
1232
- if (k.startsWith("$") || chainKeys.has(k)) continue;
1233
- if (k in vals) continue;
1258
+ if (k.startsWith("$") || chainKeys.has(k) || k in extraVars) continue;
1234
1259
  ctx.$set(k, scope[k]);
1235
1260
  }
1236
1261
 
@@ -1254,9 +1279,13 @@ export function resolve(path, ctx) {
1254
1279
  }
1255
1280
 
1256
1281
  // Interpolate strings like "/users/{user.id}?q={search}"
1282
+ // Note: interpolated values are encoded with encodeURIComponent, which encodes
1283
+ // "/" as "%2F". Path segments that intentionally contain "/" must be passed
1284
+ // as pre-encoded strings or concatenated outside of {} placeholders.
1257
1285
  export function _interpolate(str, ctx) {
1258
1286
  return str.replace(/\{([^}]+)\}/g, (_, expr) => {
1259
1287
  const val = evaluate(expr.trim(), ctx);
1260
- return val != null ? val : "";
1288
+ if (val == null) return "";
1289
+ return encodeURIComponent(String(val));
1261
1290
  });
1262
1291
  }
package/src/globals.js CHANGED
@@ -17,6 +17,8 @@ export const _config = {
17
17
  debug: false,
18
18
  devtools: false,
19
19
  sanitize: true,
20
+ sanitizeHtml: null,
21
+ exprCacheSize: 500,
20
22
  };
21
23
 
22
24
  export const _interceptors = { request: [], response: [] };
@@ -68,6 +70,22 @@ export function _watchExpr(expr, ctx, fn) {
68
70
  });
69
71
  if (typeof expr === "string" && expr.includes("$store")) {
70
72
  _storeWatchers.add(fn);
73
+ fn._el = _currentEl;
74
+ // Self-cleanup when the element is removed without going through dispose
75
+ const el = _currentEl;
76
+ if (el && el.parentElement) {
77
+ const ro = new MutationObserver(() => {
78
+ if (!el.isConnected) {
79
+ _storeWatchers.delete(fn);
80
+ unwatch();
81
+ ro.disconnect();
82
+ }
83
+ });
84
+ // subtree: false — we only care about direct children of parentElement being removed
85
+ ro.observe(el.parentElement, { childList: true, subtree: false });
86
+ // Also disconnect via the normal disposal path to avoid a dangling MO
87
+ _onDispose(() => ro.disconnect());
88
+ }
71
89
  }
72
90
  }
73
91
 
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,6 +88,10 @@ 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);
91
96
  if (opts.headers)
92
97
  _config.headers = { ...prevHeaders, ...opts.headers };
@@ -244,7 +249,7 @@ const NoJS = {
244
249
  resolve,
245
250
 
246
251
  // Version
247
- version: "1.9.1",
252
+ version: "1.10.0",
248
253
  };
249
254
 
250
255
  export default NoJS;
package/src/router.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // CLIENT-SIDE ROUTER
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _config, _stores, _log } from "./globals.js";
5
+ import { _config, _stores, _log, _warn } from "./globals.js";
6
6
  import { createContext } from "./context.js";
7
7
  import { evaluate } from "./evaluate.js";
8
8
  import { findContext, _clearDeclared, _loadTemplateElement, _processTemplateIncludes } from "./dom.js";
@@ -12,6 +12,13 @@ import { _devtoolsEmit } from "./devtools.js";
12
12
 
13
13
  const _BUILTIN_404_HTML = '<div style="text-align:center;padding:3rem 1rem;font-family:system-ui,sans-serif"><h1 style="font-size:4rem;margin:0;opacity:.3">404</h1><p style="font-size:1.25rem;color:#666">Page not found</p></div>';
14
14
 
15
+ function _clearOutlets() {
16
+ for (const outletEl of document.querySelectorAll("[route-view]")) {
17
+ _disposeTree(outletEl);
18
+ outletEl.innerHTML = "";
19
+ }
20
+ }
21
+
15
22
  function _stripBase(pathname) {
16
23
  const base = (_config.router.base || "/").replace(/\/$/, "");
17
24
  if (!base) return pathname || "/";
@@ -91,8 +98,13 @@ export function _createRouter() {
91
98
  ctx.__raw.$store = _stores;
92
99
  ctx.__raw.$route = current;
93
100
  const allowed = evaluate(guardExpr, ctx);
94
- if (!allowed && redirectPath) {
95
- await navigate(redirectPath, true);
101
+ if (!allowed) {
102
+ if (redirectPath) {
103
+ await navigate(redirectPath, true);
104
+ } else {
105
+ _warn(`Route guard failed for "${path}" but no redirect is defined. The route will not render.`);
106
+ _clearOutlets();
107
+ }
96
108
  return;
97
109
  }
98
110
  }
@@ -109,8 +121,13 @@ export function _createRouter() {
109
121
  ctx.__raw.$store = _stores;
110
122
  ctx.__raw.$route = current;
111
123
  const allowed = evaluate(guardExpr, ctx);
112
- if (!allowed && redirectPath) {
113
- await navigate(redirectPath, true);
124
+ if (!allowed) {
125
+ if (redirectPath) {
126
+ await navigate(redirectPath, true);
127
+ } else {
128
+ _warn(`Route guard failed for "${path}" but no redirect is defined. The route will not render.`);
129
+ _clearOutlets();
130
+ }
114
131
  return;
115
132
  }
116
133
  }