@erickxavier/no-js 1.8.2 → 1.9.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.
package/src/evaluate.js CHANGED
@@ -2,89 +2,1018 @@
2
2
  // EXPRESSION EVALUATOR
3
3
  // ═══════════════════════════════════════════════════════════════════════
4
4
 
5
- import { _stores, _routerInstance, _filters, _warn, _config, _notifyStoreWatchers } from "./globals.js";
5
+ import { _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
9
  const _exprCache = new Map();
10
+ const _stmtCache = new Map();
10
11
 
11
- // CSP-safe expression evaluator (no new Function / eval)
12
- // Handles dot-notation paths, basic comparisons, boolean operators, negation, and literals.
13
- function _cspSafeEval(expr, keys, vals) {
14
- const scope = {};
15
- for (let i = 0; i < keys.length; i++) scope[keys[i]] = vals[i];
12
+ // ── Tokenizer ──────────────────────────────────────────────────────────
16
13
 
17
- function resolvePath(path, obj) {
18
- return path.split(".").reduce((o, k) => o?.[k], obj);
14
+ const _KEYWORDS = new Set(["true", "false", "null", "undefined", "typeof", "in", "instanceof"]);
15
+ const _FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);
16
+
17
+ // Multi-char operators/punctuation, sorted longest-first for greedy matching
18
+ const _MULTI = ["===", "!==", "...", "??", "?.", "==", "!=", ">=", "<=", "&&", "||", "+=", "-=", "*=", "/=", "%=", "++", "--", "=>"];
19
+ const _SINGLE_OPS = new Set(["+", "-", "*", "/", "%", ">", "<", "!", "=", "|"]);
20
+ const _SINGLE_PUNC = new Set(["(", ")", "[", "]", "{", "}", ".", ",", ":", ";", "?"]);
21
+
22
+ function _tokenize(expr) {
23
+ if (typeof expr !== "string") return [];
24
+ const tokens = [];
25
+ const len = expr.length;
26
+ let pos = 0;
27
+
28
+ while (pos < len) {
29
+ const ch = expr[pos];
30
+
31
+ // Skip whitespace
32
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { pos++; continue; }
33
+
34
+ // String literals (single or double quoted)
35
+ if (ch === "'" || ch === '"') {
36
+ const start = pos;
37
+ const quote = ch;
38
+ pos++;
39
+ let value = "";
40
+ while (pos < len && expr[pos] !== quote) {
41
+ if (expr[pos] === "\\") {
42
+ pos++;
43
+ if (pos < len) {
44
+ const esc = expr[pos];
45
+ if (esc === "n") value += "\n";
46
+ else if (esc === "t") value += "\t";
47
+ else if (esc === "r") value += "\r";
48
+ else value += esc;
49
+ pos++;
50
+ }
51
+ } else {
52
+ value += expr[pos++];
53
+ }
54
+ }
55
+ if (pos < len) pos++; // skip closing quote
56
+ tokens.push({ type: "String", value, pos: start });
57
+ continue;
58
+ }
59
+
60
+ // Template literals
61
+ if (ch === "`") {
62
+ const start = pos;
63
+ pos++;
64
+ const parts = [];
65
+ const exprs = [];
66
+ let seg = "";
67
+ while (pos < len && expr[pos] !== "`") {
68
+ if (expr[pos] === "\\" && pos + 1 < len) {
69
+ const esc = expr[pos + 1];
70
+ if (esc === "n") seg += "\n";
71
+ else if (esc === "t") seg += "\t";
72
+ else if (esc === "r") seg += "\r";
73
+ else seg += esc;
74
+ pos += 2;
75
+ } else if (expr[pos] === "$" && pos + 1 < len && expr[pos + 1] === "{") {
76
+ parts.push(seg);
77
+ seg = "";
78
+ pos += 2; // skip ${
79
+ // Collect expression text respecting nested braces
80
+ let depth = 1;
81
+ let inner = "";
82
+ while (pos < len && depth > 0) {
83
+ if (expr[pos] === "{") depth++;
84
+ else if (expr[pos] === "}") { depth--; if (depth === 0) break; }
85
+ else if (expr[pos] === "'" || expr[pos] === '"' || expr[pos] === "`") {
86
+ // skip string inside interpolation
87
+ const q = expr[pos];
88
+ inner += q; pos++;
89
+ while (pos < len && expr[pos] !== q) {
90
+ if (expr[pos] === "\\") { inner += expr[pos++]; if (pos < len) inner += expr[pos++]; }
91
+ else inner += expr[pos++];
92
+ }
93
+ if (pos < len) { inner += expr[pos]; pos++; }
94
+ continue;
95
+ }
96
+ inner += expr[pos++];
97
+ }
98
+ if (pos < len) pos++; // skip closing }
99
+ exprs.push(_tokenize(inner));
100
+ } else {
101
+ seg += expr[pos++];
102
+ }
103
+ }
104
+ if (pos < len) pos++; // skip closing `
105
+ parts.push(seg);
106
+ tokens.push({ type: "Template", parts, exprs, pos: start });
107
+ continue;
108
+ }
109
+
110
+ // Numbers: starts with digit, or '.' followed by digit
111
+ if ((ch >= "0" && ch <= "9") || (ch === "." && pos + 1 < len && expr[pos + 1] >= "0" && expr[pos + 1] <= "9")) {
112
+ const start = pos;
113
+ let num = "";
114
+ while (pos < len && ((expr[pos] >= "0" && expr[pos] <= "9") || expr[pos] === ".")) {
115
+ num += expr[pos++];
116
+ }
117
+ tokens.push({ type: "Number", value: num, pos: start });
118
+ continue;
119
+ }
120
+
121
+ // Identifiers / Keywords
122
+ if ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_" || ch === "$") {
123
+ const start = pos;
124
+ let id = "";
125
+ while (pos < len) {
126
+ const c = expr[pos];
127
+ if ((c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c === "_" || c === "$") {
128
+ id += c; pos++;
129
+ } else break;
130
+ }
131
+ if (_FORBIDDEN.has(id)) tokens.push({ type: "Forbidden", value: id, pos: start });
132
+ else if (_KEYWORDS.has(id)) tokens.push({ type: "Keyword", value: id, pos: start });
133
+ else tokens.push({ type: "Ident", value: id, pos: start });
134
+ continue;
135
+ }
136
+
137
+ // Multi-char operators / punctuation (longest first)
138
+ let matched = false;
139
+ for (let m = 0; m < _MULTI.length; m++) {
140
+ const op = _MULTI[m];
141
+ if (expr.startsWith(op, pos)) {
142
+ const isPunc = op === "..." || op === "?.";
143
+ tokens.push({ type: isPunc ? "Punc" : "Op", value: op, pos });
144
+ pos += op.length;
145
+ matched = true;
146
+ break;
147
+ }
148
+ }
149
+ if (matched) continue;
150
+
151
+ // Single-char operators
152
+ if (_SINGLE_OPS.has(ch)) {
153
+ tokens.push({ type: "Op", value: ch, pos });
154
+ pos++;
155
+ continue;
156
+ }
157
+
158
+ // Single-char punctuation
159
+ if (_SINGLE_PUNC.has(ch)) {
160
+ tokens.push({ type: "Punc", value: ch, pos });
161
+ pos++;
162
+ continue;
163
+ }
164
+
165
+ // Unrecognized character — skip
166
+ pos++;
167
+ }
168
+
169
+ return tokens;
170
+ }
171
+
172
+ // ── Recursive-descent expression parser ────────────────────────────────
173
+
174
+ function _parseExpr(tokens) {
175
+ if (!tokens || tokens.length === 0) return { type: "Literal", value: undefined };
176
+
177
+ let pos = 0;
178
+
179
+ function peek() { return tokens[pos]; }
180
+ function next() { return tokens[pos++]; }
181
+
182
+ function match(type, value) {
183
+ const t = tokens[pos];
184
+ if (!t) return false;
185
+ if (value !== undefined) return t.type === type && t.value === value;
186
+ return t.type === type;
187
+ }
188
+
189
+ function expect(type, value) {
190
+ const t = tokens[pos];
191
+ if (t && t.type === type && (value === undefined || t.value === value)) {
192
+ pos++;
193
+ return t;
194
+ }
195
+ return null;
196
+ }
197
+
198
+ // ─── Grammar rules (lowest → highest precedence) ───
199
+
200
+ function parseExpression() {
201
+ return parseTernary();
202
+ }
203
+
204
+ function parseTernary() {
205
+ let node = parseNullishOr();
206
+ if (match("Punc", "?")) {
207
+ next(); // consume ?
208
+ const consequent = parseTernary();
209
+ expect("Punc", ":");
210
+ const alternate = parseTernary();
211
+ node = { type: "ConditionalExpr", test: node, consequent, alternate };
212
+ }
213
+ return node;
214
+ }
215
+
216
+ function parseNullishOr() {
217
+ let node = parseLogicalOr();
218
+ if (match("Op", "??")) {
219
+ next();
220
+ const right = parseNullishOr();
221
+ node = { type: "BinaryExpr", op: "??", left: node, right };
222
+ }
223
+ return node;
224
+ }
225
+
226
+ function parseLogicalOr() {
227
+ let node = parseLogicalAnd();
228
+ while (match("Op", "||")) {
229
+ next();
230
+ const right = parseLogicalAnd();
231
+ node = { type: "BinaryExpr", op: "||", left: node, right };
232
+ }
233
+ return node;
234
+ }
235
+
236
+ function parseLogicalAnd() {
237
+ let node = parseBitwiseOr();
238
+ while (match("Op", "&&")) {
239
+ next();
240
+ const right = parseBitwiseOr();
241
+ node = { type: "BinaryExpr", op: "&&", left: node, right };
242
+ }
243
+ return node;
244
+ }
245
+
246
+ function parseBitwiseOr() {
247
+ let node = parseComparison();
248
+ while (peek() && peek().type === "Op" && peek().value === "|" && (!tokens[pos + 1] || tokens[pos + 1].value !== "|")) {
249
+ next();
250
+ const right = parseComparison();
251
+ node = { type: "BinaryExpr", op: "|", left: node, right };
252
+ }
253
+ return node;
254
+ }
255
+
256
+ function parseComparison() {
257
+ let node = parseAddition();
258
+ const t = peek();
259
+ if (!t) return node;
260
+ const compOps = ["===", "!==", "==", "!=", ">=", "<=", ">", "<"];
261
+ if ((t.type === "Op" && compOps.indexOf(t.value) !== -1) ||
262
+ (t.type === "Keyword" && (t.value === "in" || t.value === "instanceof"))) {
263
+ const op = next().value;
264
+ const right = parseAddition();
265
+ node = { type: "BinaryExpr", op, left: node, right };
266
+ }
267
+ return node;
268
+ }
269
+
270
+ function parseAddition() {
271
+ let node = parseMultiplication();
272
+ while (peek() && peek().type === "Op" && (peek().value === "+" || peek().value === "-")) {
273
+ const op = next().value;
274
+ const right = parseMultiplication();
275
+ node = { type: "BinaryExpr", op, left: node, right };
276
+ }
277
+ return node;
278
+ }
279
+
280
+ function parseMultiplication() {
281
+ let node = parseUnary();
282
+ while (peek() && peek().type === "Op" && (peek().value === "*" || peek().value === "/" || peek().value === "%")) {
283
+ const op = next().value;
284
+ const right = parseUnary();
285
+ node = { type: "BinaryExpr", op, left: node, right };
286
+ }
287
+ return node;
288
+ }
289
+
290
+ function parseUnary() {
291
+ const t = peek();
292
+ if (!t) return { type: "Literal", value: undefined };
293
+ // typeof
294
+ if (t.type === "Keyword" && t.value === "typeof") {
295
+ next();
296
+ return { type: "UnaryExpr", op: "typeof", argument: parseUnary() };
297
+ }
298
+ // ! or unary - or unary +
299
+ if (t.type === "Op" && (t.value === "!" || t.value === "-" || t.value === "+")) {
300
+ next();
301
+ return { type: "UnaryExpr", op: t.value, argument: parseUnary() };
302
+ }
303
+ // Prefix ++ / --
304
+ if (t.type === "Op" && (t.value === "++" || t.value === "--")) {
305
+ next();
306
+ return { type: "UnaryExpr", op: t.value, argument: parseUnary(), prefix: true };
307
+ }
308
+ return parsePostfix();
309
+ }
310
+
311
+ function parsePostfix() {
312
+ let node = parseCallMember();
313
+ const t = peek();
314
+ if (t && t.type === "Op" && (t.value === "++" || t.value === "--")) {
315
+ next();
316
+ node = { type: "PostfixExpr", op: t.value, argument: node };
317
+ }
318
+ return node;
319
+ }
320
+
321
+ function parseCallMember() {
322
+ let node = parsePrimary();
323
+
324
+ while (true) {
325
+ const t = peek();
326
+ if (!t) break;
327
+
328
+ // Dot access: obj.prop
329
+ if (t.type === "Punc" && t.value === ".") {
330
+ next();
331
+ const prop = peek();
332
+ if (prop && (prop.type === "Ident" || prop.type === "Keyword")) {
333
+ next();
334
+ node = { type: "MemberExpr", object: node, property: { type: "Identifier", name: prop.value }, computed: false };
335
+ } else if (prop && prop.type === "Forbidden") {
336
+ next();
337
+ node = { type: "Forbidden" };
338
+ } else {
339
+ break;
340
+ }
341
+ continue;
342
+ }
343
+
344
+ // Optional chaining: obj?.prop or obj?.(args)
345
+ if (t.type === "Punc" && t.value === "?.") {
346
+ next();
347
+ const nt = peek();
348
+ // Optional call: obj?.(args)
349
+ if (nt && nt.type === "Punc" && nt.value === "(") {
350
+ next(); // consume (
351
+ const args = parseArgsList();
352
+ expect("Punc", ")");
353
+ node = { type: "OptionalCallExpr", callee: node, args };
354
+ }
355
+ // Optional member: obj?.prop
356
+ else if (nt && (nt.type === "Ident" || nt.type === "Keyword")) {
357
+ next();
358
+ node = { type: "OptionalMemberExpr", object: node, property: { type: "Identifier", name: nt.value }, computed: false };
359
+ }
360
+ // Optional bracket: obj?.[expr]
361
+ else if (nt && nt.type === "Punc" && nt.value === "[") {
362
+ next(); // consume [
363
+ const prop = parseExpression();
364
+ expect("Punc", "]");
365
+ node = { type: "OptionalMemberExpr", object: node, property: prop, computed: true };
366
+ } else {
367
+ break;
368
+ }
369
+ continue;
370
+ }
371
+
372
+ // Bracket access: obj[expr]
373
+ if (t.type === "Punc" && t.value === "[") {
374
+ next();
375
+ const prop = parseExpression();
376
+ expect("Punc", "]");
377
+ node = { type: "MemberExpr", object: node, property: prop, computed: true };
378
+ continue;
379
+ }
380
+
381
+ // Function call: fn(args)
382
+ if (t.type === "Punc" && t.value === "(") {
383
+ next();
384
+ const args = parseArgsList();
385
+ expect("Punc", ")");
386
+ node = { type: "CallExpr", callee: node, args };
387
+ continue;
388
+ }
389
+
390
+ break;
391
+ }
392
+
393
+ return node;
394
+ }
395
+
396
+ function parseArgsList() {
397
+ const args = [];
398
+ if (match("Punc", ")")) return args;
399
+ args.push(parseSpreadOrExpr());
400
+ while (match("Punc", ",")) {
401
+ next();
402
+ if (match("Punc", ")")) break; // trailing comma
403
+ args.push(parseSpreadOrExpr());
404
+ }
405
+ return args;
406
+ }
407
+
408
+ function parseSpreadOrExpr() {
409
+ if (match("Punc", "...")) {
410
+ next();
411
+ return { type: "SpreadElement", argument: parseExpression() };
412
+ }
413
+ return parseExpression();
414
+ }
415
+
416
+ // ─── Arrow function detection helpers ───
417
+
418
+ function isArrowParams() {
419
+ // Lookahead from current pos (after consuming "(") to see if this is (id, id, ...) =>
420
+ const saved = pos;
421
+ // Empty params: () =>
422
+ if (match("Punc", ")")) {
423
+ const after = tokens[pos + 1];
424
+ if (after && after.type === "Op" && after.value === "=>") {
425
+ pos = saved;
426
+ return true;
427
+ }
428
+ pos = saved;
429
+ return false;
430
+ }
431
+ // Check for ident list followed by ) =>
432
+ while (pos < tokens.length) {
433
+ const t = peek();
434
+ if (!t) break;
435
+ if (t.type === "Ident") {
436
+ next();
437
+ if (match("Punc", ",")) {
438
+ next();
439
+ continue;
440
+ }
441
+ if (match("Punc", ")")) {
442
+ const after = tokens[pos + 1];
443
+ if (after && after.type === "Op" && after.value === "=>") {
444
+ pos = saved;
445
+ return true;
446
+ }
447
+ pos = saved;
448
+ return false;
449
+ }
450
+ pos = saved;
451
+ return false;
452
+ }
453
+ // Spread param: (...rest) =>
454
+ if (t.type === "Punc" && t.value === "...") {
455
+ next();
456
+ if (match("Ident")) { next(); }
457
+ if (match("Punc", ")")) {
458
+ const after = tokens[pos + 1];
459
+ if (after && after.type === "Op" && after.value === "=>") {
460
+ pos = saved;
461
+ return true;
462
+ }
463
+ }
464
+ pos = saved;
465
+ return false;
466
+ }
467
+ pos = saved;
468
+ return false;
469
+ }
470
+ pos = saved;
471
+ return false;
19
472
  }
20
473
 
21
- function parseValue(token) {
22
- const t = token.trim();
23
- if (t === "true") return true;
24
- if (t === "false") return false;
25
- if (t === "null") return null;
26
- if (t === "undefined") return undefined;
27
- if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
28
- if (/^(['"`]).*\1$/.test(t)) return t.slice(1, -1);
29
- // Treat as property path resolved from scope
30
- return resolvePath(t, scope);
474
+ function parseArrowParams() {
475
+ // Parse comma-separated identifiers until ")"
476
+ const params = [];
477
+ if (match("Punc", ")")) return params;
478
+ if (match("Punc", "...")) {
479
+ next();
480
+ if (match("Ident")) params.push("..." + next().value);
481
+ } else if (match("Ident")) {
482
+ params.push(next().value);
483
+ }
484
+ while (match("Punc", ",")) {
485
+ next();
486
+ if (match("Punc", ")")) break;
487
+ if (match("Punc", "...")) {
488
+ next();
489
+ if (match("Ident")) params.push("..." + next().value);
490
+ } else if (match("Ident")) {
491
+ params.push(next().value);
492
+ }
493
+ }
494
+ return params;
31
495
  }
32
496
 
33
- const trimmed = expr.trim();
497
+ // ─── Primary ───
498
+
499
+ function parsePrimary() {
500
+ const t = peek();
501
+ if (!t) return { type: "Literal", value: undefined };
502
+
503
+ // Forbidden token
504
+ if (t.type === "Forbidden") {
505
+ next();
506
+ return { type: "Forbidden" };
507
+ }
508
+
509
+ // Number literal
510
+ if (t.type === "Number") {
511
+ next();
512
+ return { type: "Literal", value: Number(t.value) };
513
+ }
514
+
515
+ // String literal
516
+ if (t.type === "String") {
517
+ next();
518
+ return { type: "Literal", value: t.value };
519
+ }
520
+
521
+ // Template literal
522
+ if (t.type === "Template") {
523
+ next();
524
+ return {
525
+ type: "TemplateLiteral",
526
+ parts: t.parts,
527
+ expressions: t.exprs.map(function(exprTokens) { return _parseExpr(exprTokens); })
528
+ };
529
+ }
530
+
531
+ // Keywords: true, false, null, undefined
532
+ if (t.type === "Keyword") {
533
+ if (t.value === "true") { next(); return { type: "Literal", value: true }; }
534
+ if (t.value === "false") { next(); return { type: "Literal", value: false }; }
535
+ if (t.value === "null") { next(); return { type: "Literal", value: null }; }
536
+ if (t.value === "undefined") { next(); return { type: "Literal", value: undefined }; }
537
+ }
538
+
539
+ // Array literal: [...]
540
+ if (t.type === "Punc" && t.value === "[") {
541
+ next();
542
+ const elements = [];
543
+ while (!match("Punc", "]") && pos < tokens.length) {
544
+ elements.push(parseSpreadOrExpr());
545
+ if (match("Punc", ",")) next();
546
+ }
547
+ expect("Punc", "]");
548
+ return { type: "ArrayExpr", elements };
549
+ }
550
+
551
+ // Object literal: { ... }
552
+ if (t.type === "Punc" && t.value === "{") {
553
+ return parseObjectLiteral();
554
+ }
555
+
556
+ // Parenthesized expression or arrow function with parens
557
+ if (t.type === "Punc" && t.value === "(") {
558
+ next(); // consume (
559
+
560
+ // Check for arrow function: (params) =>
561
+ if (isArrowParams()) {
562
+ const params = parseArrowParams();
563
+ expect("Punc", ")");
564
+ expect("Op", "=>");
565
+ const body = parseExpression();
566
+ return { type: "ArrowFunction", params, body };
567
+ }
568
+
569
+ // Regular grouping
570
+ const expr = parseExpression();
571
+ expect("Punc", ")");
572
+ return expr;
573
+ }
34
574
 
35
- // Handle ternary: condition ? trueExpr : falseExpr
36
- const ternaryMatch = trimmed.match(/^(.+?)\s*\?\s*(.+?)\s*:\s*(.+)$/);
37
- if (ternaryMatch) {
38
- const cond = _cspSafeEval(ternaryMatch[1].trim(), keys, vals);
39
- return cond
40
- ? _cspSafeEval(ternaryMatch[2].trim(), keys, vals)
41
- : _cspSafeEval(ternaryMatch[3].trim(), keys, vals);
575
+ // Identifier (possibly arrow: x => ...)
576
+ if (t.type === "Ident") {
577
+ next();
578
+ // Single-param arrow function: x => expr
579
+ if (match("Op", "=>")) {
580
+ next(); // consume =>
581
+ const body = parseExpression();
582
+ return { type: "ArrowFunction", params: [t.value], body };
583
+ }
584
+ return { type: "Identifier", name: t.value };
585
+ }
586
+
587
+ // Assignment operators
588
+ if (t.type === "Op" && (t.value === "=" || t.value === "+=" || t.value === "-=" || t.value === "*=" || t.value === "/=" || t.value === "%=")) {
589
+ // Should not appear as primary; skip
590
+ next();
591
+ return { type: "Literal", value: undefined };
592
+ }
593
+
594
+ // Spread in unexpected position (e.g. top level)
595
+ if (t.type === "Punc" && t.value === "...") {
596
+ next();
597
+ return { type: "SpreadElement", argument: parseExpression() };
598
+ }
599
+
600
+ // Fallback: skip unrecognized token
601
+ next();
602
+ return { type: "Literal", value: undefined };
42
603
  }
43
604
 
44
- // Handle logical OR (||)
45
- if (trimmed.includes("||")) {
46
- const parts = trimmed.split("||");
47
- for (const part of parts) {
48
- const val = _cspSafeEval(part.trim(), keys, vals);
49
- if (val) return val;
605
+ function parseObjectLiteral() {
606
+ next(); // consume {
607
+ const properties = [];
608
+ while (!match("Punc", "}") && pos < tokens.length) {
609
+ // Spread property: ...expr
610
+ if (match("Punc", "...")) {
611
+ next();
612
+ properties.push({ key: null, value: parseExpression(), computed: false, spread: true });
613
+ if (match("Punc", ",")) next();
614
+ continue;
615
+ }
616
+
617
+ // Computed property: [expr]: value
618
+ if (match("Punc", "[")) {
619
+ next();
620
+ const keyExpr = parseExpression();
621
+ expect("Punc", "]");
622
+ expect("Punc", ":");
623
+ const val = parseExpression();
624
+ properties.push({ key: keyExpr, value: val, computed: true, spread: false });
625
+ if (match("Punc", ",")) next();
626
+ continue;
627
+ }
628
+
629
+ // String key: 'key': value
630
+ if (match("String")) {
631
+ const keyToken = next();
632
+ if (match("Punc", ":")) {
633
+ next();
634
+ const val = parseExpression();
635
+ properties.push({ key: keyToken.value, value: val, computed: false, spread: false });
636
+ }
637
+ if (match("Punc", ",")) next();
638
+ continue;
639
+ }
640
+
641
+ // Identifier key (shorthand or key: value)
642
+ if (match("Ident") || match("Keyword")) {
643
+ const keyToken = next();
644
+ if (match("Punc", ":")) {
645
+ next();
646
+ const val = parseExpression();
647
+ properties.push({ key: keyToken.value, value: val, computed: false, spread: false });
648
+ } else {
649
+ // Shorthand: { key } → { key: key }
650
+ properties.push({
651
+ key: keyToken.value,
652
+ value: { type: "Identifier", name: keyToken.value },
653
+ computed: false,
654
+ spread: false
655
+ });
656
+ }
657
+ if (match("Punc", ",")) next();
658
+ continue;
659
+ }
660
+
661
+ // Number key
662
+ if (match("Number")) {
663
+ const keyToken = next();
664
+ if (match("Punc", ":")) {
665
+ next();
666
+ const val = parseExpression();
667
+ properties.push({ key: keyToken.value, value: val, computed: false, spread: false });
668
+ }
669
+ if (match("Punc", ",")) next();
670
+ continue;
671
+ }
672
+
673
+ // Unrecognized — skip
674
+ next();
50
675
  }
51
- return _cspSafeEval(parts[parts.length - 1].trim(), keys, vals);
676
+ expect("Punc", "}");
677
+ return { type: "ObjectExpr", properties };
52
678
  }
53
679
 
54
- // Handle logical AND (&&)
55
- if (trimmed.includes("&&")) {
56
- const parts = trimmed.split("&&");
57
- let last;
58
- for (const part of parts) {
59
- last = _cspSafeEval(part.trim(), keys, vals);
60
- if (!last) return last;
680
+ // ─── Handle top-level assignment ───
681
+
682
+ function parseTopLevel() {
683
+ const expr = parseExpression();
684
+ // Check for assignment at top level: ident = expr, ident += expr, etc.
685
+ const t = peek();
686
+ if (t && t.type === "Op" && (t.value === "=" || t.value === "+=" || t.value === "-=" || t.value === "*=" || t.value === "/=" || t.value === "%=")) {
687
+ const op = next().value;
688
+ const right = parseExpression();
689
+ return { type: "AssignExpr", op, left: expr, right };
61
690
  }
62
- return last;
691
+ return expr;
63
692
  }
64
693
 
65
- // Handle comparisons: ===, !==, ==, !=, >=, <=, >, <
66
- const cmpMatch = trimmed.match(/^(.+?)\s*(===|!==|==|!=|>=|<=|>|<)\s*(.+)$/);
67
- if (cmpMatch) {
68
- const left = parseValue(cmpMatch[1]);
69
- const right = parseValue(cmpMatch[3]);
70
- switch (cmpMatch[2]) {
71
- case "===": return left === right;
72
- case "!==": return left !== right;
73
- case "==": return left == right;
74
- case "!=": return left != right;
75
- case ">=": return left >= right;
76
- case "<=": return left <= right;
77
- case ">": return left > right;
78
- case "<": return left < right;
694
+ const ast = parseTopLevel();
695
+ return ast;
696
+ }
697
+
698
+ // ---------------------------------------------------------------------------
699
+ // AST tree-walking evaluator
700
+ // ---------------------------------------------------------------------------
701
+ const _FORBIDDEN_PROPS = { __proto__: 1, constructor: 1, prototype: 1 };
702
+
703
+ /* Safe subset of JS globals available in expressions (no eval/Function/process) */
704
+ const _SAFE_GLOBALS = {
705
+ Array, Object, String, Number, Boolean, Math, Date, RegExp, Map, Set,
706
+ JSON, parseInt, parseFloat, isNaN, isFinite, Infinity, NaN, undefined,
707
+ Error, Symbol, console,
708
+ };
709
+
710
+ const _DENY_GLOBALS = { eval: 1, Function: 1, process: 1, require: 1, importScripts: 1 };
711
+
712
+ function _evalNode(node, scope) {
713
+ try {
714
+ if (!node) return undefined;
715
+
716
+ switch (node.type) {
717
+
718
+ case 'Literal':
719
+ return node.value;
720
+
721
+ case 'Identifier':
722
+ if (node.name in scope) return scope[node.name];
723
+ 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];
726
+ return undefined;
727
+
728
+ case 'Forbidden':
729
+ return undefined;
730
+
731
+ case 'BinaryExpr': {
732
+ // Short-circuit operators evaluate lazily
733
+ if (node.op === '&&') {
734
+ const l = _evalNode(node.left, scope);
735
+ return l ? _evalNode(node.right, scope) : l;
736
+ }
737
+ if (node.op === '||') {
738
+ const l = _evalNode(node.left, scope);
739
+ return l ? l : _evalNode(node.right, scope);
740
+ }
741
+ if (node.op === '??') {
742
+ const l = _evalNode(node.left, scope);
743
+ return (l === null || l === undefined) ? _evalNode(node.right, scope) : l;
744
+ }
745
+ const left = _evalNode(node.left, scope);
746
+ const right = _evalNode(node.right, scope);
747
+ switch (node.op) {
748
+ case '+': return left + right;
749
+ case '-': return left - right;
750
+ case '*': return left * right;
751
+ case '/': return left / right;
752
+ case '%': return left % right;
753
+ case '**': return left ** right;
754
+ case '===': return left === right;
755
+ case '!==': return left !== right;
756
+ case '==': return left == right;
757
+ case '!=': return left != right;
758
+ case '>': return left > right;
759
+ case '<': return left < right;
760
+ case '>=': return left >= right;
761
+ case '<=': return left <= right;
762
+ case 'in': return (right && typeof right === 'object') ? (left in right) : undefined;
763
+ case 'instanceof': return left instanceof right;
764
+ case '&': return left & right;
765
+ case '|': return left | right;
766
+ case '^': return left ^ right;
767
+ case '<<': return left << right;
768
+ case '>>': return left >> right;
769
+ case '>>>': return left >>> right;
770
+ default: return undefined;
771
+ }
772
+ }
773
+
774
+ case 'UnaryExpr': {
775
+ if (node.op === 'typeof') {
776
+ // Special: if identifier not in scope, return "undefined" string
777
+ if (node.argument && node.argument.type === 'Identifier' && !(node.argument.name in scope)) {
778
+ return 'undefined';
779
+ }
780
+ return typeof _evalNode(node.argument, scope);
781
+ }
782
+ // Prefix ++ / --
783
+ if (node.op === '++' || node.op === '--') {
784
+ const oldVal = _evalNode(node.argument, scope);
785
+ const newVal = node.op === '++' ? oldVal + 1 : oldVal - 1;
786
+ return node.prefix ? newVal : oldVal;
787
+ }
788
+ const arg = _evalNode(node.argument, scope);
789
+ switch (node.op) {
790
+ case '!': return !arg;
791
+ case '-': return -arg;
792
+ case '+': return +arg;
793
+ case '~': return ~arg;
794
+ case 'void': return undefined;
795
+ default: return undefined;
796
+ }
797
+ }
798
+
799
+ case 'ConditionalExpr': {
800
+ return _evalNode(node.test, scope)
801
+ ? _evalNode(node.consequent, scope)
802
+ : _evalNode(node.alternate, scope);
803
+ }
804
+
805
+ case 'MemberExpr':
806
+ case 'OptionalMemberExpr': {
807
+ const obj = _evalNode(node.object, scope);
808
+ if (obj == null) return undefined;
809
+ const prop = node.computed
810
+ ? _evalNode(node.property, scope)
811
+ : node.property.name || node.property.value;
812
+ if (_FORBIDDEN_PROPS[prop]) return undefined;
813
+ return obj[prop];
814
+ }
815
+
816
+ case 'CallExpr':
817
+ case 'OptionalCallExpr': {
818
+ // Evaluate args (handle spread)
819
+ const evalArgs = (args) => {
820
+ const result = [];
821
+ for (let i = 0; i < args.length; i++) {
822
+ if (args[i].type === 'SpreadElement') {
823
+ const spread = _evalNode(args[i].argument, scope);
824
+ if (spread && typeof spread[Symbol.iterator] === 'function') {
825
+ result.push(...spread);
826
+ }
827
+ } else {
828
+ result.push(_evalNode(args[i], scope));
829
+ }
830
+ }
831
+ return result;
832
+ };
833
+
834
+ if (node.callee.type === 'MemberExpr' || node.callee.type === 'OptionalMemberExpr') {
835
+ const thisObj = _evalNode(node.callee.object, scope);
836
+ if (thisObj == null) {
837
+ if (node.type === 'OptionalCallExpr' || node.callee.type === 'OptionalMemberExpr') return undefined;
838
+ return undefined;
839
+ }
840
+ const prop = node.callee.computed
841
+ ? _evalNode(node.callee.property, scope)
842
+ : node.callee.property.name;
843
+ if (_FORBIDDEN_PROPS[prop]) return undefined;
844
+ const fn = thisObj[prop];
845
+ if (typeof fn !== 'function') return undefined;
846
+ return fn.apply(thisObj, evalArgs(node.args));
847
+ }
848
+
849
+ const fn = _evalNode(node.callee, scope);
850
+ if (fn == null && node.type === 'OptionalCallExpr') return undefined;
851
+ if (typeof fn !== 'function') return undefined;
852
+ return fn.apply(undefined, evalArgs(node.args));
853
+ }
854
+
855
+ case 'ArrayExpr': {
856
+ const arr = [];
857
+ for (let i = 0; i < node.elements.length; i++) {
858
+ const el = node.elements[i];
859
+ if (el.type === 'SpreadElement') {
860
+ const spread = _evalNode(el.argument, scope);
861
+ if (spread && typeof spread[Symbol.iterator] === 'function') {
862
+ arr.push(...spread);
863
+ }
864
+ } else {
865
+ arr.push(_evalNode(el, scope));
866
+ }
867
+ }
868
+ return arr;
869
+ }
870
+
871
+ case 'ObjectExpr': {
872
+ const obj = {};
873
+ for (let i = 0; i < node.properties.length; i++) {
874
+ const prop = node.properties[i];
875
+ if (prop.spread) {
876
+ Object.assign(obj, _evalNode(prop.value, scope));
877
+ } else {
878
+ const key = prop.computed ? _evalNode(prop.key, scope) : prop.key;
879
+ if (_FORBIDDEN_PROPS[key]) continue;
880
+ obj[key] = _evalNode(prop.value, scope);
881
+ }
882
+ }
883
+ return obj;
884
+ }
885
+
886
+ case 'SpreadElement':
887
+ return _evalNode(node.argument, scope);
888
+
889
+ case 'ArrowFunction':
890
+ return function (...callArgs) {
891
+ const childScope = Object.create(scope);
892
+ for (let i = 0; i < node.params.length; i++) {
893
+ const p = node.params[i];
894
+ if (typeof p === 'string' && p.startsWith('...')) {
895
+ childScope[p.slice(3)] = callArgs.slice(i);
896
+ break;
897
+ }
898
+ childScope[p] = callArgs[i];
899
+ }
900
+ return _evalNode(node.body, childScope);
901
+ };
902
+
903
+ case 'TemplateLiteral': {
904
+ let result = node.parts[0];
905
+ for (let i = 0; i < node.expressions.length; i++) {
906
+ result += String(_evalNode(node.expressions[i], scope));
907
+ result += node.parts[i + 1];
908
+ }
909
+ return result;
910
+ }
911
+
912
+ case 'PostfixExpr': {
913
+ // In expression context, return the current value (no mutation)
914
+ return _evalNode(node.argument, scope);
915
+ }
916
+
917
+ case 'AssignExpr': {
918
+ // In expression context, evaluate and return the RHS
919
+ return _evalNode(node.right, scope);
920
+ }
921
+
922
+ default:
923
+ return undefined;
79
924
  }
925
+ } catch (_e) {
926
+ return undefined;
80
927
  }
928
+ }
81
929
 
82
- // Handle negation
83
- if (trimmed.startsWith("!")) {
84
- return !_cspSafeEval(trimmed.slice(1).trim(), keys, vals);
930
+ // ---------------------------------------------------------------------------
931
+ // Statement parser & executor (for on:*, watch, etc.)
932
+ // ---------------------------------------------------------------------------
933
+
934
+ // Parse semicolon-separated statements into an array of AST nodes
935
+ function _parseStatements(expr) {
936
+ if (_stmtCache.has(expr)) return _stmtCache.get(expr);
937
+ const tokens = _tokenize(expr);
938
+ const stmts = [];
939
+ let start = 0;
940
+ for (let i = 0; i <= tokens.length; i++) {
941
+ if (i === tokens.length || (tokens[i].type === "Punc" && tokens[i].value === ";")) {
942
+ const chunk = tokens.slice(start, i);
943
+ if (chunk.length > 0) stmts.push(_parseExpr(chunk));
944
+ start = i + 1;
945
+ }
85
946
  }
947
+ _stmtCache.set(expr, stmts);
948
+ return stmts;
949
+ }
86
950
 
87
- return parseValue(trimmed);
951
+ // Assign a value to an AST target node (Identifier or MemberExpr)
952
+ function _assignToTarget(target, value, scope) {
953
+ if (target.type === "Identifier") {
954
+ scope[target.name] = value;
955
+ } else if (target.type === "MemberExpr" || target.type === "OptionalMemberExpr") {
956
+ const obj = _evalNode(target.object, scope);
957
+ if (obj == null) return;
958
+ const prop = target.computed
959
+ ? _evalNode(target.property, scope)
960
+ : target.property.name || target.property.value;
961
+ if (_FORBIDDEN_PROPS[prop]) return;
962
+ obj[prop] = value;
963
+ }
964
+ }
965
+
966
+ // Execute a single statement node with mutation support
967
+ function _execStmtNode(node, scope) {
968
+ if (!node) return undefined;
969
+ switch (node.type) {
970
+ case "AssignExpr": {
971
+ const rhs = _evalNode(node.right, scope);
972
+ let value;
973
+ if (node.op === "=") {
974
+ value = rhs;
975
+ } else {
976
+ const lhs = _evalNode(node.left, scope);
977
+ switch (node.op) {
978
+ case "+=": value = lhs + rhs; break;
979
+ case "-=": value = lhs - rhs; break;
980
+ case "*=": value = lhs * rhs; break;
981
+ case "/=": value = lhs / rhs; break;
982
+ case "%=": value = lhs % rhs; break;
983
+ default: value = rhs;
984
+ }
985
+ }
986
+ _assignToTarget(node.left, value, scope);
987
+ return value;
988
+ }
989
+ case "PostfixExpr": {
990
+ const oldVal = _evalNode(node.argument, scope);
991
+ const newVal = node.op === "++" ? oldVal + 1 : oldVal - 1;
992
+ _assignToTarget(node.argument, newVal, scope);
993
+ return oldVal;
994
+ }
995
+ case "UnaryExpr": {
996
+ if (node.op === "++" || node.op === "--") {
997
+ const oldVal = _evalNode(node.argument, scope);
998
+ const newVal = node.op === "++" ? oldVal + 1 : oldVal - 1;
999
+ _assignToTarget(node.argument, newVal, scope);
1000
+ return newVal;
1001
+ }
1002
+ return _evalNode(node, scope);
1003
+ }
1004
+ default: {
1005
+ // In statement context, throw for undefined function calls
1006
+ // so error-boundary directives can catch the error
1007
+ if (node.type === "CallExpr" && node.callee.type === "Identifier") {
1008
+ const name = node.callee.name;
1009
+ if (!(name in scope) && !(name in _SAFE_GLOBALS) &&
1010
+ (typeof globalThis === "undefined" || !(name in globalThis))) {
1011
+ throw new ReferenceError(name + " is not defined");
1012
+ }
1013
+ }
1014
+ return _evalNode(node, scope);
1015
+ }
1016
+ }
88
1017
  }
89
1018
 
90
1019
  // Parse pipe syntax: "expr | filter1 | filter2:arg"
@@ -211,22 +1140,20 @@ export function evaluate(expr, ctx) {
211
1140
  }
212
1141
  }
213
1142
 
214
- const keyArr = keys;
215
- const valArr = keyArr.map((k) => vals[k]);
1143
+ // Build scope object from keys/vals
1144
+ const scope = {};
1145
+ for (let i = 0; i < keys.length; i++) scope[keys[i]] = vals[keys[i]];
216
1146
 
217
- let result;
218
- if (_config.csp === "strict") {
219
- result = _cspSafeEval(mainExpr, keyArr, valArr);
220
- } else {
221
- let cacheKey = mainExpr + "|" + keyArr.join(",");
222
- let fn = _exprCache.get(cacheKey);
223
- if (!fn) {
224
- fn = new Function(...keyArr, `return (${mainExpr})`);
225
- _exprCache.set(cacheKey, fn);
226
- }
227
- result = fn(...valArr);
1147
+ // Parse expression into AST (cached)
1148
+ let ast = _exprCache.get(mainExpr);
1149
+ if (!ast) {
1150
+ ast = _parseExpr(_tokenize(mainExpr));
1151
+ _exprCache.set(mainExpr, ast);
228
1152
  }
229
1153
 
1154
+ // Evaluate AST against scope
1155
+ let result = _evalNode(ast, scope);
1156
+
230
1157
  // Apply filters
231
1158
  for (let i = 1; i < pipes.length; i++) {
232
1159
  result = _applyFilter(result, pipes[i]);
@@ -234,6 +1161,7 @@ export function evaluate(expr, ctx) {
234
1161
 
235
1162
  return result;
236
1163
  } catch (e) {
1164
+ _warn("Expression error:", expr, e.message);
237
1165
  return undefined;
238
1166
  }
239
1167
  }
@@ -258,35 +1186,53 @@ export function _execStatement(expr, ctx, extraVars = {}) {
258
1186
  }
259
1187
  }
260
1188
 
261
- const keyArr = [...keys];
262
- const valArr = keyArr.map((k) => vals[k]);
1189
+ // Build scope
1190
+ const scope = {};
1191
+ for (let i = 0; i < keys.length; i++) scope[keys[i]] = vals[keys[i]];
263
1192
 
264
- // Build setters to write back state through the full context chain.
265
- // For each key in any ancestor context, find the owning context at runtime
266
- // and call $set on it — so mutations inside `each` loops correctly
267
- // propagate back to parent state (e.g. cart updated from a loop's on:click).
268
- // Only write back values that actually changed locally, to avoid
269
- // overwriting proxy mutations made by called functions.
1193
+ // Snapshot context chain values for write-back comparison
270
1194
  const chainKeys = new Set();
271
1195
  let _wCtx = ctx;
272
1196
  while (_wCtx && _wCtx.__isProxy) {
273
1197
  for (const k of Object.keys(_wCtx.__raw)) chainKeys.add(k);
274
1198
  _wCtx = _wCtx.$parent;
275
1199
  }
276
- const origObj = {};
1200
+ const originals = {};
277
1201
  for (const k of chainKeys) {
278
- if (!k.startsWith("$") && k in vals) origObj[k] = vals[k];
279
- }
280
- const setters = [...chainKeys]
281
- .filter((k) => !k.startsWith("$"))
282
- .map(
283
- (k) =>
284
- `{let _c=__ctx;while(_c&&_c.__isProxy){if('${k}'in _c.__raw){if(typeof ${k}!=='undefined'){if(${k}!==__orig['${k}'])_c.$set('${k}',${k});else if(typeof ${k}==='object'&&${k}!==null)_c.$notify();}break;}_c=_c.$parent;}}`,
285
- )
286
- .join("\n");
287
-
288
- const fn = new Function("__ctx", "__orig", ...keyArr, `${expr};\n${setters}`);
289
- fn(ctx, origObj, ...valArr);
1202
+ if (!k.startsWith("$") && k in scope) originals[k] = scope[k];
1203
+ }
1204
+
1205
+ // Parse and execute statements
1206
+ const stmts = _parseStatements(expr);
1207
+ for (let i = 0; i < stmts.length; i++) _execStmtNode(stmts[i], scope);
1208
+
1209
+ // Write back changed values to owning context
1210
+ for (const k of chainKeys) {
1211
+ if (k.startsWith("$")) continue;
1212
+ if (!(k in scope)) continue;
1213
+ const newVal = scope[k];
1214
+ const oldVal = originals[k];
1215
+ if (newVal !== oldVal) {
1216
+ let c = ctx;
1217
+ while (c && c.__isProxy) {
1218
+ if (k in c.__raw) { c.$set(k, newVal); break; }
1219
+ c = c.$parent;
1220
+ }
1221
+ } else if (typeof newVal === "object" && newVal !== null) {
1222
+ let c = ctx;
1223
+ while (c && c.__isProxy) {
1224
+ if (k in c.__raw) { c.$notify(); break; }
1225
+ c = c.$parent;
1226
+ }
1227
+ }
1228
+ }
1229
+
1230
+ // Write back new variables created during execution
1231
+ for (const k in scope) {
1232
+ if (k.startsWith("$") || chainKeys.has(k)) continue;
1233
+ if (k in vals) continue;
1234
+ ctx.$set(k, scope[k]);
1235
+ }
290
1236
 
291
1237
  // Notify global store watchers when expression touches $store
292
1238
  if (typeof expr === "string" && expr.includes("$store")) {