@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/dist/cjs/no.js +7 -7
- package/dist/cjs/no.js.map +3 -3
- package/dist/esm/no.js +7 -7
- package/dist/esm/no.js.map +3 -3
- package/dist/iife/no.js +7 -7
- package/dist/iife/no.js.map +3 -3
- package/package.json +2 -1
- package/src/animations.js +22 -7
- package/src/context.js +9 -1
- package/src/devtools.js +21 -0
- package/src/directives/binding.js +41 -1
- package/src/directives/conditionals.js +8 -2
- package/src/directives/events.js +4 -0
- package/src/directives/http.js +18 -2
- package/src/directives/loops.js +171 -6
- package/src/directives/state.js +17 -6
- package/src/dom.js +40 -6
- package/src/evaluate.js +74 -45
- package/src/globals.js +18 -0
- package/src/index.js +6 -1
- package/src/router.js +22 -5
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
|
-
|
|
10
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
|
95
|
-
|
|
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
|
|
113
|
-
|
|
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
|
}
|