jprx 1.2.1 → 1.3.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/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
@@ -19,7 +19,8 @@ export {
19
19
  resolvePathAsContext,
20
20
  resolveExpression,
21
21
  parseCDOMC,
22
- parseJPRX,
22
+ parseCDOMC as parseJPRX,
23
+ parseJPRX as oldParseJPRX,
23
24
  unwrapSignal,
24
25
  getRegistry,
25
26
  BindingTarget
@@ -39,6 +40,7 @@ export { registerStatsHelpers } from './helpers/stats.js';
39
40
  export { registerStateHelpers, set } from './helpers/state.js';
40
41
  export { registerNetworkHelpers } from './helpers/network.js';
41
42
  export { registerCalcHelpers, calc } from './helpers/calc.js';
43
+ export { registerDOMHelpers } from './helpers/dom.js';
42
44
 
43
45
  // Convenience function to register all standard helpers
44
46
  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.1",
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:
@@ -1190,6 +1409,7 @@ export const parseExpression = (expr, context) => {
1190
1409
  * Supports unquoted keys/values and strictly avoids 'eval'.
1191
1410
  */
1192
1411
  export const parseCDOMC = (input) => {
1412
+ if (typeof input !== 'string') return input;
1193
1413
  let i = 0;
1194
1414
  const len = input.length;
1195
1415
 
@@ -1261,12 +1481,14 @@ export const parseCDOMC = (input) => {
1261
1481
  let bDepth = 0;
1262
1482
  let brDepth = 0;
1263
1483
  let quote = null;
1484
+ const startChar = input[start];
1485
+ const isExpression = startChar === '=' || startChar === '#';
1264
1486
 
1265
1487
  while (i < len) {
1266
1488
  const char = input[i];
1267
1489
 
1268
1490
  if (quote) {
1269
- if (char === quote) quote = null;
1491
+ if (char === quote && input[i - 1] !== '\\') quote = null;
1270
1492
  i++;
1271
1493
  continue;
1272
1494
  } else if (char === '"' || char === "'" || char === "`") {
@@ -1286,8 +1508,42 @@ export const parseCDOMC = (input) => {
1286
1508
 
1287
1509
  // Termination at depth 0
1288
1510
  if (pDepth === 0 && bDepth === 0 && brDepth === 0) {
1289
- if (/[\s:,{}\[\]"'`()]/.test(char)) {
1290
- break;
1511
+ // For expressions, we're more permissive - only break on certain chars
1512
+ if (isExpression) {
1513
+ if (/[{}[\]"'`()]/.test(char)) {
1514
+ break;
1515
+ }
1516
+ // Check for comma at top level (end of property value)
1517
+ if (char === ',') {
1518
+ break;
1519
+ }
1520
+ // For whitespace or colon, peek ahead to see if next property starts
1521
+ if (/[\s:]/.test(char)) {
1522
+ let j = i + 1;
1523
+ while (j < len && /\s/.test(input[j])) j++;
1524
+ // Check if what follows looks like a property key
1525
+ if (j < len) {
1526
+ const nextChar = input[j];
1527
+ // If it's a closing brace or comma, we're done
1528
+ if (nextChar === '}' || nextChar === ',') {
1529
+ break;
1530
+ }
1531
+ // If it's a word followed by ':', it's a new property
1532
+ let wordStart = j;
1533
+ while (j < len && /[a-zA-Z0-9_$-]/.test(input[j])) j++;
1534
+ if (j > wordStart) {
1535
+ while (j < len && /\s/.test(input[j])) j++;
1536
+ if (j < len && input[j] === ':') {
1537
+ break; // Next property found
1538
+ }
1539
+ }
1540
+ }
1541
+ }
1542
+ } else {
1543
+ // For non-expressions, break on structural chars or whitespace
1544
+ if (/[:,{}[\]"'`()\s]/.test(char)) {
1545
+ break;
1546
+ }
1291
1547
  }
1292
1548
  }
1293
1549
 
@@ -1296,8 +1552,8 @@ export const parseCDOMC = (input) => {
1296
1552
 
1297
1553
  const word = input.slice(start, i);
1298
1554
 
1299
- // If word starts with =, preserve it as a string for cDOM expression parsing
1300
- if (word.startsWith('=')) {
1555
+ // If word starts with = or #, preserve it as a string for cDOM expression parsing
1556
+ if (word.startsWith('=') || word.startsWith('#')) {
1301
1557
  return word;
1302
1558
  }
1303
1559
 
@@ -1411,6 +1667,7 @@ export const parseCDOMC = (input) => {
1411
1667
  * @returns {object} - Parsed JSON object
1412
1668
  */
1413
1669
  export const parseJPRX = (input) => {
1670
+ if (typeof input !== 'string') return input;
1414
1671
  let result = '';
1415
1672
  let i = 0;
1416
1673
  const len = input.length;
@@ -1491,7 +1748,31 @@ export const parseJPRX = (input) => {
1491
1748
  } else {
1492
1749
  // Check for break BEFORE updating depth
1493
1750
  if (parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) {
1494
- if (/[\s,}\]:]/.test(c) && expr.length > 1) break;
1751
+ // Break on structural characters at depth 0
1752
+ if (/[}[\]:]/.test(c) && expr.length > 1) break;
1753
+ // Check for comma at top level
1754
+ if (c === ',') break;
1755
+ // For whitespace, peek ahead to see if next property starts
1756
+ if (/\s/.test(c)) {
1757
+ let j = i + 1;
1758
+ while (j < len && /\s/.test(input[j])) j++;
1759
+ if (j < len) {
1760
+ const nextChar = input[j];
1761
+ // If it's a closing brace or comma, we're done
1762
+ if (nextChar === '}' || nextChar === ',' || nextChar === ']') {
1763
+ break;
1764
+ }
1765
+ // If it's a word followed by ':', it's a new property
1766
+ let wordStart = j;
1767
+ while (j < len && /[a-zA-Z0-9_$-]/.test(input[j])) j++;
1768
+ if (j > wordStart) {
1769
+ while (j < len && /\s/.test(input[j])) j++;
1770
+ if (j < len && input[j] === ':') {
1771
+ break; // Next property found
1772
+ }
1773
+ }
1774
+ }
1775
+ }
1495
1776
  }
1496
1777
 
1497
1778
  if (c === '(') parenDepth++;
@@ -1512,9 +1793,9 @@ export const parseJPRX = (input) => {
1512
1793
  }
1513
1794
 
1514
1795
  // Handle unquoted property names, identifiers, paths, and FUNCTION CALLS
1515
- if (/[a-zA-Z_$/./]/.test(char)) {
1796
+ if (/[a-zA-Z_$\/.\/]/.test(char)) {
1516
1797
  let word = '';
1517
- while (i < len && /[a-zA-Z0-9_$/.-]/.test(input[i])) {
1798
+ while (i < len && /[a-zA-Z0-9_$\/.-]/.test(input[i])) {
1518
1799
  word += input[i];
1519
1800
  i++;
1520
1801
  }