jprx 1.2.1 → 1.3.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/helpers/dom.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * DOM-related JPRX helpers for navigating and querying the DOM structure.
3
+ */
4
+
5
+ /**
6
+ * Registers DOM-related helpers including the xpath() helper.
7
+ * @param {Function} registerHelper - The helper registration function
8
+ */
9
+ export const registerDOMHelpers = (registerHelper) => {
10
+ /**
11
+ * Evaluates an XPath expression against the current DOM element.
12
+ * Returns a computed signal that re-evaluates when observed nodes change.
13
+ * Only supports backward-looking axes (parent, ancestor, preceding-sibling, etc.)
14
+ *
15
+ * @param {string} expression - The XPath expression to evaluate
16
+ * @param {object} context - The evaluation context (contains __node__)
17
+ * @returns {any} The result of the XPath evaluation
18
+ */
19
+ registerHelper('xpath', function (expression) {
20
+ const domNode = this; // 'this' is bound to the DOM element
21
+
22
+ if (!domNode || !(domNode instanceof Element)) {
23
+ console.warn('[Lightview-CDOM] xpath() called without valid DOM context');
24
+ return '';
25
+ }
26
+
27
+ // Validate the expression (no forward-looking axes)
28
+ const forbiddenAxes = /\b(child|descendant|following|following-sibling)::/;
29
+ if (forbiddenAxes.test(expression)) {
30
+ console.error(`[Lightview-CDOM] xpath(): Forward-looking axes not allowed: ${expression}`);
31
+ return '';
32
+ }
33
+
34
+ const hasShorthandChild = /\/[a-zA-Z]/.test(expression) && !expression.startsWith('/html');
35
+ if (hasShorthandChild) {
36
+ console.error(`[Lightview-CDOM] xpath(): Shorthand child axis (/) not allowed: ${expression}`);
37
+ return '';
38
+ }
39
+
40
+ // Get Lightview's computed function
41
+ const LV = globalThis.Lightview;
42
+ if (!LV || !LV.computed) {
43
+ console.warn('[Lightview-CDOM] xpath(): Lightview not available');
44
+ return '';
45
+ }
46
+
47
+ // Return a computed signal that evaluates the XPath
48
+ return LV.computed(() => {
49
+ try {
50
+ const result = document.evaluate(
51
+ expression,
52
+ domNode,
53
+ null,
54
+ XPathResult.STRING_TYPE,
55
+ null
56
+ );
57
+
58
+ // TODO: Set up MutationObserver for reactivity
59
+ // For now, this just evaluates once
60
+ // Future: Observe parent/ancestor/sibling changes
61
+
62
+ return result.stringValue;
63
+ } catch (e) {
64
+ console.error(`[Lightview-CDOM] xpath() evaluation failed:`, e.message);
65
+ return '';
66
+ }
67
+ });
68
+ }, { pathAware: false });
69
+ };
package/helpers/logic.js CHANGED
@@ -6,8 +6,10 @@ export const ifHelper = (condition, thenVal, elseVal) => condition ? thenVal : e
6
6
  export const andHelper = (...args) => args.every(Boolean);
7
7
  export const orHelper = (...args) => args.some(Boolean);
8
8
  export const notHelper = (val) => !val;
9
- export const eqHelper = (a, b) => a === b;
10
- export const neqHelper = (a, b) => a !== b;
9
+ export const eqHelper = (a, b) => a == b;
10
+ export const strictEqHelper = (a, b) => a === b;
11
+ export const neqHelper = (a, b) => a != b;
12
+ export const strictNeqHelper = (a, b) => a !== b;
11
13
 
12
14
  export const registerLogicHelpers = (register) => {
13
15
  register('if', ifHelper);
@@ -18,7 +20,11 @@ export const registerLogicHelpers = (register) => {
18
20
  register('not', notHelper);
19
21
  register('!', notHelper);
20
22
  register('eq', eqHelper);
23
+ register('strictEq', strictEqHelper);
21
24
  register('==', eqHelper);
22
- register('===', eqHelper);
25
+ register('===', strictEqHelper);
23
26
  register('neq', neqHelper);
27
+ register('strictNeq', strictNeqHelper);
28
+ register('!=', neqHelper);
29
+ register('!==', strictNeqHelper);
24
30
  };
package/index.js CHANGED
@@ -39,6 +39,7 @@ export { registerStatsHelpers } from './helpers/stats.js';
39
39
  export { registerStateHelpers, set } from './helpers/state.js';
40
40
  export { registerNetworkHelpers } from './helpers/network.js';
41
41
  export { registerCalcHelpers, calc } from './helpers/calc.js';
42
+ export { registerDOMHelpers } from './helpers/dom.js';
42
43
 
43
44
  // Convenience function to register all standard helpers
44
45
  export const registerAllHelpers = (registerFn) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jprx",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "JSON Path Reactive eXpressions - A reactive expression language for JSON data",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/parser.js CHANGED
@@ -40,8 +40,9 @@ export const registerHelper = (name, fn, options = {}) => {
40
40
  * @param {string} symbol - The operator symbol (e.g., '++', '+', '-')
41
41
  * @param {'prefix'|'postfix'|'infix'} position - Operator position
42
42
  * @param {number} [precedence] - Optional precedence (higher = binds tighter)
43
+ * @param {object} [options] - Optional configuration like { requiresWhitespace: true }
43
44
  */
44
- export const registerOperator = (helperName, symbol, position, precedence) => {
45
+ export const registerOperator = (helperName, symbol, position, precedence, options = {}) => {
45
46
  if (!['prefix', 'postfix', 'infix'].includes(position)) {
46
47
  throw new Error(`Invalid operator position: ${position}. Must be 'prefix', 'postfix', or 'infix'.`);
47
48
  }
@@ -50,7 +51,7 @@ export const registerOperator = (helperName, symbol, position, precedence) => {
50
51
  globalThis.console?.warn(`LightviewCDOM: Operator "${symbol}" registered for helper "${helperName}" which is not yet registered.`);
51
52
  }
52
53
  const prec = precedence ?? DEFAULT_PRECEDENCE[position];
53
- operators[position].set(symbol, { helper: helperName, precedence: prec });
54
+ operators[position].set(symbol, { helper: helperName, precedence: prec, options });
54
55
  };
55
56
 
56
57
  const getLV = () => globalThis.Lightview || null;
@@ -136,13 +137,14 @@ export const resolvePath = (path, context) => {
136
137
  // Current context: .
137
138
  if (path === '.') return unwrapSignal(context);
138
139
 
139
- // Global absolute path: =/something
140
- if (path.startsWith('=/')) {
141
- const [rootName, ...rest] = path.slice(2).split('/');
140
+ // Global absolute path: =/something or /something
141
+ if (path.startsWith('=/') || path.startsWith('/')) {
142
+ const segments = path.startsWith('=/') ? path.slice(2).split('/') : path.slice(1).split('/');
143
+ const rootName = segments.shift();
142
144
  const LV = getLV();
143
145
  const root = LV ? LV.get(rootName, { scope: context?.__node__ || context }) : registry?.get(rootName);
144
146
  if (!root) return undefined;
145
- return traverse(root, rest);
147
+ return traverse(root, segments);
146
148
  }
147
149
 
148
150
  // Relative path from current context
@@ -157,6 +159,11 @@ export const resolvePath = (path, context) => {
157
159
 
158
160
  // Path with separators - treat as relative
159
161
  if (path.includes('/') || path.includes('.')) {
162
+ // Optimization: check if full path exists as a key first (handles kebab-case/slashes in keys)
163
+ const unwrapped = unwrapSignal(context);
164
+ if (unwrapped && typeof unwrapped === 'object' && path in unwrapped) {
165
+ return unwrapSignal(unwrapped[path]);
166
+ }
160
167
  return traverse(context, path.split(/[\/.]/));
161
168
  }
162
169
 
@@ -183,9 +190,9 @@ export const resolvePathAsContext = (path, context) => {
183
190
  // Current context: .
184
191
  if (path === '.') return context;
185
192
 
186
- // Global absolute path: =/something
187
- if (path.startsWith('=/')) {
188
- const segments = path.slice(2).split(/[/.]/);
193
+ // Global absolute path: =/something or /something
194
+ if (path.startsWith('=/') || path.startsWith('/')) {
195
+ const segments = path.startsWith('=/') ? path.slice(2).split(/[/.]/) : path.slice(1).split(/[/.]/);
189
196
  const rootName = segments.shift();
190
197
  const LV = getLV();
191
198
  const root = LV ? LV.get(rootName, { scope: context?.__node__ || context }) : registry?.get(rootName);
@@ -205,6 +212,11 @@ export const resolvePathAsContext = (path, context) => {
205
212
 
206
213
  // Path with separators
207
214
  if (path.includes('/') || path.includes('.')) {
215
+ // Optimization: check if full path exists as a key first
216
+ const unwrapped = unwrapSignal(context);
217
+ if (unwrapped && typeof unwrapped === 'object' && path in unwrapped) {
218
+ return new BindingTarget(unwrapped, path);
219
+ }
208
220
  return traverseAsContext(context, path.split(/[\/.]/));
209
221
  }
210
222
 
@@ -437,6 +449,11 @@ const TokenType = {
437
449
  PLACEHOLDER: 'PLACEHOLDER', // _, _/path
438
450
  THIS: 'THIS', // $this
439
451
  EVENT: 'EVENT', // $event, $event.target
452
+ LBRACE: 'LBRACE', // {
453
+ RBRACE: 'RBRACE', // }
454
+ LBRACKET: 'LBRACKET', // [
455
+ RBRACKET: 'RBRACKET', // ]
456
+ COLON: 'COLON', // :
440
457
  EOF: 'EOF'
441
458
  };
442
459
 
@@ -483,18 +500,27 @@ const tokenize = (expr) => {
483
500
  // Special: = followed immediately by an operator symbol
484
501
  // In expressions like "=++/count", the = is just the JPRX delimiter
485
502
  // and ++ is a prefix operator applied to /count
486
- if (expr[i] === '=' && i + 1 < len) {
503
+ if (expr[i] === '=' && i === 0 && i + 1 < len) {
487
504
  // Check if next chars are a PREFIX operator (sort by length to match longest first)
488
505
  const prefixOps = [...operators.prefix.keys()].sort((a, b) => b.length - a.length);
489
- let isPrefixOp = false;
506
+ let matchedPrefix = null;
490
507
  for (const op of prefixOps) {
491
508
  if (expr.slice(i + 1, i + 1 + op.length) === op) {
492
- isPrefixOp = true;
509
+ matchedPrefix = op;
493
510
  break;
494
511
  }
495
512
  }
496
- if (isPrefixOp) {
497
- // Skip the =, it's just a delimiter for a prefix operator (e.g., =++/count)
513
+ if (matchedPrefix) {
514
+ // Skip the =, it's just a delimiter (e.g., =++/count)
515
+ i++;
516
+ continue;
517
+ }
518
+
519
+ // If it's not a prefix op, but it's the leading = of a statement
520
+ // e.g. =/a + b or =sum(1, 2)
521
+ // Skip the = so the following path/word can be processed
522
+ const next = expr[i + 1];
523
+ if (next === '/' || next === '.' || /[a-zA-Z_$]/.test(next)) {
498
524
  i++;
499
525
  continue;
500
526
  }
@@ -519,6 +545,33 @@ const tokenize = (expr) => {
519
545
  continue;
520
546
  }
521
547
 
548
+ // Object/Array/Colon
549
+ if (expr[i] === '{') {
550
+ tokens.push({ type: TokenType.LBRACE, value: '{' });
551
+ i++;
552
+ continue;
553
+ }
554
+ if (expr[i] === '}') {
555
+ tokens.push({ type: TokenType.RBRACE, value: '}' });
556
+ i++;
557
+ continue;
558
+ }
559
+ if (expr[i] === '[') {
560
+ tokens.push({ type: TokenType.LBRACKET, value: '[' });
561
+ i++;
562
+ continue;
563
+ }
564
+ if (expr[i] === ']') {
565
+ tokens.push({ type: TokenType.RBRACKET, value: ']' });
566
+ i++;
567
+ continue;
568
+ }
569
+ if (expr[i] === ':') {
570
+ tokens.push({ type: TokenType.COLON, value: ':' });
571
+ i++;
572
+ continue;
573
+ }
574
+
522
575
  // Check for operators (longest match first)
523
576
  let matchedOp = null;
524
577
  for (const op of opSymbols) {
@@ -528,18 +581,37 @@ const tokenize = (expr) => {
528
581
  const before = i > 0 ? expr[i - 1] : ' ';
529
582
  const after = i + op.length < len ? expr[i + op.length] : ' ';
530
583
 
531
- const isInfix = operators.infix.has(op);
532
- const isPrefix = operators.prefix.has(op);
533
- const isPostfix = operators.postfix.has(op);
584
+ const infixConf = operators.infix.get(op);
585
+ const prefixConf = operators.prefix.get(op);
586
+ const postfixConf = operators.postfix.get(op);
534
587
 
535
- // For infix-only operators (like /, +, -, >, <, >=, <=, !=), we now REQUIRE surrounding whitespace
536
- // This prevents collision with path separators (especially for /)
537
- if (isInfix && !isPrefix && !isPostfix) {
538
- if (/\s/.test(before) && /\s/.test(after)) {
588
+ // Check for whitespace requirements
589
+ // If an operator is configured to REQUIRE whitespace (e.g. / or -), check for it.
590
+ if (infixConf?.options?.requiresWhitespace) {
591
+ if (!prefixConf && !postfixConf) {
592
+ const isWhitespaceMatch = /\s/.test(before) && /\s/.test(after);
593
+ if (!isWhitespaceMatch) continue;
594
+ }
595
+ }
596
+
597
+ // If it's a known INFIX operator (and passed the whitespace check above),
598
+ // it is valid if it follows a value (Path, Literal, RParen, etc.)
599
+ // This allows 'a+b' where + is infix
600
+ if (infixConf) {
601
+ const lastTok = tokens[tokens.length - 1];
602
+ const isValueContext = lastTok && (
603
+ lastTok.type === TokenType.PATH ||
604
+ lastTok.type === TokenType.LITERAL ||
605
+ lastTok.type === TokenType.RPAREN ||
606
+ lastTok.type === TokenType.PLACEHOLDER ||
607
+ lastTok.type === TokenType.THIS ||
608
+ lastTok.type === TokenType.EVENT
609
+ );
610
+
611
+ if (isValueContext) {
539
612
  matchedOp = op;
540
613
  break;
541
614
  }
542
- continue;
543
615
  }
544
616
 
545
617
  // Accept prefix/postfix operator if:
@@ -657,18 +729,20 @@ const tokenize = (expr) => {
657
729
  let isOp = false;
658
730
  for (const op of opSymbols) {
659
731
  if (expr.slice(i, i + op.length) === op) {
660
- const isInfix = operators.infix.has(op);
661
- const isPrefix = operators.prefix.has(op);
662
- const isPostfix = operators.postfix.has(op);
732
+ const infixConf = operators.infix.get(op);
733
+ const prefixConf = operators.prefix.get(op);
734
+ const postfixConf = operators.postfix.get(op);
663
735
 
664
736
  // Strict infix (like /) MUST have spaces to break the path
665
- if (isInfix && !isPrefix && !isPostfix) {
666
- const after = i + op.length < len ? expr[i + op.length] : ' ';
667
- if (/\s/.test(expr[i - 1]) && /\s/.test(after)) {
668
- isOp = true;
669
- break;
737
+ if (infixConf?.options?.requiresWhitespace) {
738
+ if (!prefixConf && !postfixConf) {
739
+ const after = i + op.length < len ? expr[i + op.length] : ' ';
740
+ if (/\s/.test(expr[i - 1]) && /\s/.test(after)) {
741
+ isOp = true;
742
+ break;
743
+ }
744
+ continue;
670
745
  }
671
- continue;
672
746
  }
673
747
 
674
748
  // Prefix/Postfix: if they appear after a path, they are operators
@@ -738,12 +812,10 @@ const tokenize = (expr) => {
738
812
  const hasOperatorSyntax = (expr) => {
739
813
  if (!expr || typeof expr !== 'string') return false;
740
814
 
741
- // Skip function calls - they use legacy parser
742
- if (expr.includes('(')) return false;
743
-
744
- // Check for prefix operator pattern: =++ or =-- followed by /
745
- // This catches: =++/counter, =--/value
746
- if (/^=(\+\+|--|!!)\/?/.test(expr)) {
815
+ // Detect explicit patterns to avoid false positives.
816
+ // We allow function calls '(' to be part of operator expressions.
817
+ // This catches: ++/counter, --/value, !!flag
818
+ if (/^=?(\+\+|--|!!)\/?/.test(expr)) {
747
819
  return true;
748
820
  }
749
821
 
@@ -755,7 +827,17 @@ const hasOperatorSyntax = (expr) => {
755
827
 
756
828
  // Check for infix with explicit whitespace: =/a + =/b
757
829
  // The spaces make it unambiguous that the symbol is an operator, not part of a path
758
- if (/\s+([+\-*/]|>|<|>=|<=|!=)\s+/.test(expr)) {
830
+ if (/\s+([+\-*/%]|>|<|>=|<=|!=|===|==|=)\s+/.test(expr)) {
831
+ return true;
832
+ }
833
+
834
+ // Check for tight infix operators (no whitespace required)
835
+ // Matches if the operator is NOT at the start and NOT at the end
836
+ // Operators: +, =, <, >, <=, >=, ==, ===, !=, !==, %
837
+ // We strictly exclude -, *, / which require whitespace
838
+ if (/[^=\s]([+%=]|==|===|!=|!==|<=|>=|<|>)[^=\s]/.test(expr)) {
839
+ // Exclude cases where it might be part of a path or tag (like <div>)
840
+ // This is heuristic, but Pratt parser will validate structure later
759
841
  return true;
760
842
  }
761
843
 
@@ -913,9 +995,32 @@ class PrattParser {
913
995
  this.consume();
914
996
  return { type: 'Explosion', path: tok.value };
915
997
  }
998
+ // Function call
999
+ if (nextTok.type === TokenType.LPAREN) {
1000
+ this.consume(); // (
1001
+ const args = [];
1002
+ while (this.peek().type !== TokenType.RPAREN && this.peek().type !== TokenType.EOF) {
1003
+ args.push(this.parseExpression(0));
1004
+ if (this.peek().type === TokenType.COMMA) {
1005
+ this.consume();
1006
+ }
1007
+ }
1008
+ this.expect(TokenType.RPAREN);
1009
+ return { type: 'Call', helper: tok.value, args };
1010
+ }
916
1011
  return { type: 'Path', value: tok.value };
917
1012
  }
918
1013
 
1014
+ // Object Literal
1015
+ if (tok.type === TokenType.LBRACE) {
1016
+ return this.parseObjectLiteral();
1017
+ }
1018
+
1019
+ // Array Literal
1020
+ if (tok.type === TokenType.LBRACKET) {
1021
+ return this.parseArrayLiteral();
1022
+ }
1023
+
919
1024
  // EOF or unknown
920
1025
  if (tok.type === TokenType.EOF) {
921
1026
  return { type: 'Literal', value: undefined };
@@ -923,6 +1028,48 @@ class PrattParser {
923
1028
 
924
1029
  throw new Error(`JPRX: Unexpected token ${tok.type}: ${tok.value}`);
925
1030
  }
1031
+
1032
+ parseObjectLiteral() {
1033
+ this.consume(); // {
1034
+ const properties = {};
1035
+ while (this.peek().type !== TokenType.RBRACE && this.peek().type !== TokenType.EOF) {
1036
+ const keyTok = this.consume();
1037
+ let key;
1038
+ if (keyTok.type === TokenType.LITERAL) key = String(keyTok.value);
1039
+ else if (keyTok.type === TokenType.PATH) key = keyTok.value;
1040
+ else if (keyTok.type === TokenType.PATH) key = keyTok.value; // covers identifiers
1041
+ else throw new Error(`JPRX: Expected property name but got ${keyTok.type}`);
1042
+
1043
+ this.expect(TokenType.COLON);
1044
+ const value = this.parseExpression(0);
1045
+ properties[key] = value;
1046
+
1047
+ if (this.peek().type === TokenType.COMMA) {
1048
+ this.consume();
1049
+ } else if (this.peek().type !== TokenType.RBRACE) {
1050
+ break; // Attempt recovery or let it fail at expect
1051
+ }
1052
+ }
1053
+ this.expect(TokenType.RBRACE);
1054
+ return { type: 'ObjectLiteral', properties };
1055
+ }
1056
+
1057
+ parseArrayLiteral() {
1058
+ this.consume(); // [
1059
+ const elements = [];
1060
+ while (this.peek().type !== TokenType.RBRACKET && this.peek().type !== TokenType.EOF) {
1061
+ const value = this.parseExpression(0);
1062
+ elements.push(value);
1063
+
1064
+ if (this.peek().type === TokenType.COMMA) {
1065
+ this.consume();
1066
+ } else if (this.peek().type !== TokenType.RBRACKET) {
1067
+ break;
1068
+ }
1069
+ }
1070
+ this.expect(TokenType.RBRACKET);
1071
+ return { type: 'ArrayLiteral', elements };
1072
+ }
926
1073
  }
927
1074
 
928
1075
  /**
@@ -973,68 +1120,140 @@ const evaluateAST = (ast, context, forMutation = false) => {
973
1120
  });
974
1121
  }
975
1122
 
976
- case 'Explosion': {
977
- const result = resolveArgument(ast.path + '...', context, false);
978
- return result.value;
1123
+ case 'ObjectLiteral': {
1124
+ const res = {};
1125
+ let hasLazy = false;
1126
+ for (const key in ast.properties) {
1127
+ const val = evaluateAST(ast.properties[key], context, forMutation);
1128
+ if (val && val.isLazy) hasLazy = true;
1129
+ res[key] = val;
1130
+ }
1131
+ if (hasLazy) {
1132
+ return new LazyValue((ctx) => {
1133
+ const resolved = {};
1134
+ for (const key in res) {
1135
+ resolved[key] = (res[key] && res[key].isLazy) ? res[key].resolve(ctx) : unwrapSignal(res[key]);
1136
+ }
1137
+ return resolved;
1138
+ });
1139
+ }
1140
+ return res;
1141
+ }
1142
+
1143
+ case 'ArrayLiteral': {
1144
+ const elements = ast.elements.map(el => evaluateAST(el, context, forMutation));
1145
+ const hasLazy = elements.some(el => el && el.isLazy);
1146
+ if (hasLazy) {
1147
+ return new LazyValue((ctx) => {
1148
+ return elements.map(el => (el && el.isLazy) ? el.resolve(ctx) : unwrapSignal(el));
1149
+ });
1150
+ }
1151
+ return elements.map(el => unwrapSignal(el));
979
1152
  }
980
1153
 
981
1154
  case 'Prefix': {
982
1155
  const opInfo = operators.prefix.get(ast.operator);
983
- if (!opInfo) {
984
- throw new Error(`JPRX: Unknown prefix operator: ${ast.operator}`);
985
- }
1156
+ if (!opInfo) throw new Error(`JPRX: Unknown prefix operator: ${ast.operator}`);
986
1157
  const helper = helpers.get(opInfo.helper);
987
- if (!helper) {
988
- throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
989
- }
990
-
991
- // Check if helper needs BindingTarget (pathAware)
1158
+ if (!helper) throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
992
1159
  const opts = helperOptions.get(opInfo.helper) || {};
993
1160
  const operand = evaluateAST(ast.operand, context, opts.pathAware);
994
- return helper(operand);
1161
+
1162
+ if (operand && operand.isLazy && !opts.lazyAware) {
1163
+ return new LazyValue((ctx) => {
1164
+ const resolved = operand.resolve(ctx);
1165
+ return helper(opts.pathAware ? resolved : unwrapSignal(resolved));
1166
+ });
1167
+ }
1168
+ return helper(opts.pathAware ? operand : unwrapSignal(operand));
995
1169
  }
996
1170
 
997
1171
  case 'Postfix': {
998
1172
  const opInfo = operators.postfix.get(ast.operator);
999
- if (!opInfo) {
1000
- throw new Error(`JPRX: Unknown postfix operator: ${ast.operator}`);
1001
- }
1173
+ if (!opInfo) throw new Error(`JPRX: Unknown postfix operator: ${ast.operator}`);
1002
1174
  const helper = helpers.get(opInfo.helper);
1003
- if (!helper) {
1004
- throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
1005
- }
1006
-
1175
+ if (!helper) throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
1007
1176
  const opts = helperOptions.get(opInfo.helper) || {};
1008
1177
  const operand = evaluateAST(ast.operand, context, opts.pathAware);
1009
- return helper(operand);
1178
+
1179
+ if (operand && operand.isLazy && !opts.lazyAware) {
1180
+ return new LazyValue((ctx) => {
1181
+ const resolved = operand.resolve(ctx);
1182
+ return helper(opts.pathAware ? resolved : unwrapSignal(resolved));
1183
+ });
1184
+ }
1185
+ return helper(opts.pathAware ? operand : unwrapSignal(operand));
1010
1186
  }
1011
1187
 
1012
1188
  case 'Infix': {
1013
1189
  const opInfo = operators.infix.get(ast.operator);
1014
- if (!opInfo) {
1015
- throw new Error(`JPRX: Unknown infix operator: ${ast.operator}`);
1016
- }
1190
+ if (!opInfo) throw new Error(`JPRX: Unknown infix operator: ${ast.operator}`);
1017
1191
  const helper = helpers.get(opInfo.helper);
1018
- if (!helper) {
1019
- throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
1020
- }
1021
-
1192
+ if (!helper) throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
1022
1193
  const opts = helperOptions.get(opInfo.helper) || {};
1023
- // For infix, typically first arg might be pathAware
1194
+
1024
1195
  const left = evaluateAST(ast.left, context, opts.pathAware);
1025
1196
  const right = evaluateAST(ast.right, context, false);
1026
1197
 
1027
- const finalArgs = [];
1198
+ if (((left && left.isLazy) || (right && right.isLazy)) && !opts.lazyAware) {
1199
+ return new LazyValue((ctx) => {
1200
+ const l = (left && left.isLazy) ? left.resolve(ctx) : left;
1201
+ const r = (right && right.isLazy) ? right.resolve(ctx) : right;
1202
+ return helper(opts.pathAware ? l : unwrapSignal(l), unwrapSignal(r));
1203
+ });
1204
+ }
1205
+ return helper(opts.pathAware ? left : unwrapSignal(left), unwrapSignal(right));
1206
+ }
1028
1207
 
1029
- // Handle potentially exploded arguments (like in sum(/items...p))
1030
- // Although infix operators usually take exactly 2 args, we treat them consistently
1031
- if (Array.isArray(left) && ast.left.type === 'Explosion') finalArgs.push(...left);
1032
- else finalArgs.push(unwrapSignal(left));
1208
+ case 'Call': {
1209
+ const helperName = ast.helper.replace(/^=/, '');
1210
+ const helper = helpers.get(helperName);
1211
+ if (!helper) {
1212
+ globalThis.console?.warn(`JPRX: Helper "${helperName}" not found.`);
1213
+ return undefined;
1214
+ }
1215
+ const opts = helperOptions.get(helperName) || {};
1216
+ const args = ast.args.map((arg, i) => evaluateAST(arg, context, opts.pathAware && i === 0));
1217
+
1218
+ const hasLazy = args.some(arg => arg && arg.isLazy);
1219
+ if (hasLazy && !opts.lazyAware) {
1220
+ return new LazyValue((ctx) => {
1221
+ const finalArgs = args.map((arg, i) => {
1222
+ const val = (arg && arg.isLazy) ? arg.resolve(ctx) : arg;
1223
+ if (ast.args[i].type === 'Explosion' && Array.isArray(val)) {
1224
+ return val.map(v => unwrapSignal(v));
1225
+ }
1226
+ return (opts.pathAware && i === 0) ? val : unwrapSignal(val);
1227
+ });
1228
+ // Flatten explosions
1229
+ const flatArgs = [];
1230
+ for (let i = 0; i < finalArgs.length; i++) {
1231
+ if (ast.args[i].type === 'Explosion' && Array.isArray(finalArgs[i])) {
1232
+ flatArgs.push(...finalArgs[i]);
1233
+ } else {
1234
+ flatArgs.push(finalArgs[i]);
1235
+ }
1236
+ }
1237
+ return helper.apply(context?.__node__ || null, flatArgs);
1238
+ });
1239
+ }
1033
1240
 
1034
- if (Array.isArray(right) && ast.right.type === 'Explosion') finalArgs.push(...right);
1035
- else finalArgs.push(unwrapSignal(right));
1241
+ // Non-lazy path
1242
+ const finalArgs = [];
1243
+ for (let i = 0; i < args.length; i++) {
1244
+ const arg = args[i];
1245
+ if (ast.args[i].type === 'Explosion' && Array.isArray(arg)) {
1246
+ finalArgs.push(...arg.map(v => unwrapSignal(v)));
1247
+ } else {
1248
+ finalArgs.push((opts.pathAware && i === 0) ? arg : unwrapSignal(arg));
1249
+ }
1250
+ }
1251
+ return helper.apply(context?.__node__ || null, finalArgs);
1252
+ }
1036
1253
 
1037
- return helper(...finalArgs);
1254
+ case 'Explosion': {
1255
+ const result = resolveArgument(ast.path + '...', context, false);
1256
+ return result.value;
1038
1257
  }
1039
1258
 
1040
1259
  default:
@@ -1261,12 +1480,14 @@ export const parseCDOMC = (input) => {
1261
1480
  let bDepth = 0;
1262
1481
  let brDepth = 0;
1263
1482
  let quote = null;
1483
+ const startChar = input[start];
1484
+ const isExpression = startChar === '=' || startChar === '#';
1264
1485
 
1265
1486
  while (i < len) {
1266
1487
  const char = input[i];
1267
1488
 
1268
1489
  if (quote) {
1269
- if (char === quote) quote = null;
1490
+ if (char === quote && input[i - 1] !== '\\') quote = null;
1270
1491
  i++;
1271
1492
  continue;
1272
1493
  } else if (char === '"' || char === "'" || char === "`") {
@@ -1286,8 +1507,42 @@ export const parseCDOMC = (input) => {
1286
1507
 
1287
1508
  // Termination at depth 0
1288
1509
  if (pDepth === 0 && bDepth === 0 && brDepth === 0) {
1289
- if (/[\s:,{}\[\]"'`()]/.test(char)) {
1290
- break;
1510
+ // For expressions, we're more permissive - only break on certain chars
1511
+ if (isExpression) {
1512
+ if (/[{}[\]"'`()]/.test(char)) {
1513
+ break;
1514
+ }
1515
+ // Check for comma at top level (end of property value)
1516
+ if (char === ',') {
1517
+ break;
1518
+ }
1519
+ // For whitespace or colon, peek ahead to see if next property starts
1520
+ if (/[\s:]/.test(char)) {
1521
+ let j = i + 1;
1522
+ while (j < len && /\s/.test(input[j])) j++;
1523
+ // Check if what follows looks like a property key
1524
+ if (j < len) {
1525
+ const nextChar = input[j];
1526
+ // If it's a closing brace or comma, we're done
1527
+ if (nextChar === '}' || nextChar === ',') {
1528
+ break;
1529
+ }
1530
+ // If it's a word followed by ':', it's a new property
1531
+ let wordStart = j;
1532
+ while (j < len && /[a-zA-Z0-9_$-]/.test(input[j])) j++;
1533
+ if (j > wordStart) {
1534
+ while (j < len && /\s/.test(input[j])) j++;
1535
+ if (j < len && input[j] === ':') {
1536
+ break; // Next property found
1537
+ }
1538
+ }
1539
+ }
1540
+ }
1541
+ } else {
1542
+ // For non-expressions, break on structural chars or whitespace
1543
+ if (/[:,{}[\]"'`()\s]/.test(char)) {
1544
+ break;
1545
+ }
1291
1546
  }
1292
1547
  }
1293
1548
 
@@ -1296,8 +1551,8 @@ export const parseCDOMC = (input) => {
1296
1551
 
1297
1552
  const word = input.slice(start, i);
1298
1553
 
1299
- // If word starts with =, preserve it as a string for cDOM expression parsing
1300
- if (word.startsWith('=')) {
1554
+ // If word starts with = or #, preserve it as a string for cDOM expression parsing
1555
+ if (word.startsWith('=') || word.startsWith('#')) {
1301
1556
  return word;
1302
1557
  }
1303
1558
 
@@ -1491,7 +1746,31 @@ export const parseJPRX = (input) => {
1491
1746
  } else {
1492
1747
  // Check for break BEFORE updating depth
1493
1748
  if (parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) {
1494
- if (/[\s,}\]:]/.test(c) && expr.length > 1) break;
1749
+ // Break on structural characters at depth 0
1750
+ if (/[}[\]:]/.test(c) && expr.length > 1) break;
1751
+ // Check for comma at top level
1752
+ if (c === ',') break;
1753
+ // For whitespace, peek ahead to see if next property starts
1754
+ if (/\s/.test(c)) {
1755
+ let j = i + 1;
1756
+ while (j < len && /\s/.test(input[j])) j++;
1757
+ if (j < len) {
1758
+ const nextChar = input[j];
1759
+ // If it's a closing brace or comma, we're done
1760
+ if (nextChar === '}' || nextChar === ',' || nextChar === ']') {
1761
+ break;
1762
+ }
1763
+ // If it's a word followed by ':', it's a new property
1764
+ let wordStart = j;
1765
+ while (j < len && /[a-zA-Z0-9_$-]/.test(input[j])) j++;
1766
+ if (j > wordStart) {
1767
+ while (j < len && /\s/.test(input[j])) j++;
1768
+ if (j < len && input[j] === ':') {
1769
+ break; // Next property found
1770
+ }
1771
+ }
1772
+ }
1773
+ }
1495
1774
  }
1496
1775
 
1497
1776
  if (c === '(') parenDepth++;