jprx 1.2.0 → 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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # JPRX (JSON Reactive Path eXpressions)
1
+ # JPRX (JSON Path Reactive eXpressions)
2
2
 
3
3
  **JPRX** is a declarative, reactive expression syntax designed for JSON-based data structures. It extends [JSON Pointer (RFC 6901)](https://www.rfc-editor.org/rfc/rfc6901) with reactivity, relative paths, operator syntax, and a rich library of helper functions.
4
4
 
@@ -0,0 +1,82 @@
1
+ /**
2
+ * JPRX CALC HELPER
3
+ * Safe expression evaluation using expr-eval.
4
+ * Uses the global $ helper for reactive path lookups.
5
+ */
6
+
7
+ import { Parser } from 'expr-eval';
8
+ import { resolvePath, unwrapSignal } from '../parser.js';
9
+
10
+ /**
11
+ * Evaluates a mathematical expression string.
12
+ * Supports $() for reactive path lookups within the expression.
13
+ *
14
+ * @param {string} expression - The expression to evaluate (e.g., "$('/price') * 1.08")
15
+ * @param {object} context - The JPRX context for path resolution
16
+ * @returns {number|string} - The result of the evaluation
17
+ *
18
+ * @example
19
+ * =calc("$('/width') * $('/height')")
20
+ * =calc("5 + 3 * 2")
21
+ * =calc("($('/subtotal') + $('/tax')) * 0.9")
22
+ */
23
+ export const calc = (expression, context) => {
24
+ if (typeof expression !== 'string') {
25
+ return expression;
26
+ }
27
+
28
+ let processedExpression = expression;
29
+ try {
30
+ const pathResolver = (path) => {
31
+ let currentPath = path;
32
+ let value;
33
+ let depth = 0;
34
+
35
+ // Recursively resolve if the value is another path string (e.g., "/c/display")
36
+ while (typeof currentPath === 'string' && (currentPath.startsWith('/') || currentPath.startsWith('=/')) && depth < 5) {
37
+ const normalizedPath = currentPath.startsWith('/') ? '=' + currentPath : currentPath;
38
+ const resolved = resolvePath(normalizedPath, context);
39
+ value = unwrapSignal(resolved);
40
+
41
+ // If the new value is a different path string, keep going
42
+ if (typeof value === 'string' && (value.startsWith('/') || value.startsWith('=/')) && value !== currentPath) {
43
+ currentPath = value;
44
+ depth++;
45
+ } else {
46
+ break;
47
+ }
48
+ }
49
+
50
+ if (typeof value === 'number') return value;
51
+ if (typeof value === 'string') {
52
+ const num = parseFloat(value);
53
+ if (!isNaN(num) && isFinite(Number(value))) return num;
54
+ return value === '' ? 0 : `"${value.replace(/"/g, '\\"')}"`;
55
+ }
56
+ return value === undefined || value === null ? 0 : value;
57
+ };
58
+
59
+ const pathRegex = /\$\(\s*['"](.*?)['"]\s*\)/g;
60
+ processedExpression = expression.replace(pathRegex, (match, path) => {
61
+ const val = pathResolver(path);
62
+ return val;
63
+ });
64
+
65
+ const parser = new Parser();
66
+ const parsed = parser.parse(processedExpression);
67
+ return parsed.evaluate();
68
+
69
+ } catch (error) {
70
+ console.error('JPRX calc error:', error.message);
71
+ console.error('Original expression:', expression);
72
+ console.error('Processed expression:', processedExpression);
73
+ return NaN;
74
+ }
75
+ };
76
+
77
+ /**
78
+ * Register the calc helper.
79
+ */
80
+ export const registerCalcHelpers = (register) => {
81
+ register('calc', calc, { pathAware: true });
82
+ };
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/helpers/lookup.js CHANGED
@@ -2,6 +2,8 @@
2
2
  * cdom LOOKUP HELPERS
3
3
  */
4
4
 
5
+ import { resolvePath, unwrapSignal } from '../parser.js';
6
+
5
7
  export const lookup = (val, searchArr, resultArr) => {
6
8
  if (!Array.isArray(searchArr)) return undefined;
7
9
  const idx = searchArr.indexOf(val);
@@ -17,9 +19,46 @@ export const vlookup = (val, table, colIdx) => {
17
19
  export const index = (arr, idx) => Array.isArray(arr) ? arr[idx] : undefined;
18
20
  export const match = (val, arr) => Array.isArray(arr) ? arr.indexOf(val) : -1;
19
21
 
22
+ /**
23
+ * $ - Reactive path lookup helper.
24
+ * Resolves a JPRX path string and returns the unwrapped value.
25
+ *
26
+ * @param {string} path - The path to resolve (e.g., '/state/count')
27
+ * @param {object} context - The JPRX context (automatically provided by pathAware)
28
+ * @returns {any} - The resolved and unwrapped value
29
+ *
30
+ * @example
31
+ * =calc("$('/price') * 1.08")
32
+ * =$('/user/name')
33
+ */
34
+ export const pathRef = (path, context) => {
35
+ // If path is already a BindingTarget or has a .value property, use it directly
36
+ if (path && typeof path === 'object' && 'value' in path) {
37
+ return unwrapSignal(path.value);
38
+ }
39
+
40
+ // Fallback for string paths
41
+ if (typeof path === 'string') {
42
+ const normalized = path.startsWith('=') ? path : '=' + path;
43
+ const resolved = resolvePath(normalized, context);
44
+ const value = unwrapSignal(resolved);
45
+ // Convert to number if it looks like a number for calc compatibility
46
+ if (typeof value === 'number') return value;
47
+ if (typeof value === 'string' && value !== '' && !isNaN(parseFloat(value)) && isFinite(Number(value))) {
48
+ return parseFloat(value);
49
+ }
50
+ return value;
51
+ }
52
+
53
+ return unwrapSignal(path);
54
+ };
55
+
20
56
  export const registerLookupHelpers = (register) => {
21
57
  register('lookup', lookup);
22
58
  register('vlookup', vlookup);
23
59
  register('index', index);
24
60
  register('match', match);
61
+ register('$', pathRef, { pathAware: true });
62
+ register('val', pathRef, { pathAware: true });
63
+ register('indirect', pathRef, { pathAware: true });
25
64
  };
package/helpers/math.js CHANGED
@@ -14,6 +14,8 @@ export const abs = (val) => Math.abs(val);
14
14
  export const mod = (a, b) => a % b;
15
15
  export const pow = (a, b) => Math.pow(a, b);
16
16
  export const sqrt = (val) => Math.sqrt(val);
17
+ export const negate = (val) => -Number(val);
18
+ export const toPercent = (val) => Number(val) / 100;
17
19
 
18
20
  export const registerMathHelpers = (register) => {
19
21
  register('+', add);
@@ -31,4 +33,6 @@ export const registerMathHelpers = (register) => {
31
33
  register('mod', mod);
32
34
  register('pow', pow);
33
35
  register('sqrt', sqrt);
36
+ register('negate', negate);
37
+ register('toPercent', toPercent);
34
38
  };
package/index.js CHANGED
@@ -38,6 +38,8 @@ export { registerLookupHelpers } from './helpers/lookup.js';
38
38
  export { registerStatsHelpers } from './helpers/stats.js';
39
39
  export { registerStateHelpers, set } from './helpers/state.js';
40
40
  export { registerNetworkHelpers } from './helpers/network.js';
41
+ export { registerCalcHelpers, calc } from './helpers/calc.js';
42
+ export { registerDOMHelpers } from './helpers/dom.js';
41
43
 
42
44
  // Convenience function to register all standard helpers
43
45
  export const registerAllHelpers = (registerFn) => {
@@ -53,6 +55,7 @@ export const registerAllHelpers = (registerFn) => {
53
55
  const { registerStatsHelpers } = require('./helpers/stats.js');
54
56
  const { registerStateHelpers } = require('./helpers/state.js');
55
57
  const { registerNetworkHelpers } = require('./helpers/network.js');
58
+ const { registerCalcHelpers } = require('./helpers/calc.js');
56
59
 
57
60
  registerMathHelpers(registerFn);
58
61
  registerLogicHelpers(registerFn);
@@ -66,4 +69,5 @@ export const registerAllHelpers = (registerFn) => {
66
69
  registerStatsHelpers(registerFn);
67
70
  registerStateHelpers((name, fn) => registerFn(name, fn, { pathAware: true }));
68
71
  registerNetworkHelpers(registerFn);
72
+ registerCalcHelpers(registerFn);
69
73
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jprx",
3
- "version": "1.2.0",
4
- "description": "JSON Reactive Path eXpressions - A reactive expression language for JSON data",
3
+ "version": "1.3.0",
4
+ "description": "JSON Path Reactive eXpressions - A reactive expression language for JSON data",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
7
  "keywords": [
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
 
@@ -164,7 +171,6 @@ export const resolvePath = (path, context) => {
164
171
  const unwrappedContext = unwrapSignal(context);
165
172
  if (unwrappedContext && typeof unwrappedContext === 'object') {
166
173
  if (path in unwrappedContext || unwrappedContext[path] !== undefined) {
167
- // Use traverse with one segment to ensure signal unwrapping if context[path] is a signal
168
174
  return traverse(unwrappedContext, [path]);
169
175
  }
170
176
  }
@@ -184,9 +190,9 @@ export const resolvePathAsContext = (path, context) => {
184
190
  // Current context: .
185
191
  if (path === '.') return context;
186
192
 
187
- // Global absolute path: =/something
188
- if (path.startsWith('=/')) {
189
- 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(/[/.]/);
190
196
  const rootName = segments.shift();
191
197
  const LV = getLV();
192
198
  const root = LV ? LV.get(rootName, { scope: context?.__node__ || context }) : registry?.get(rootName);
@@ -206,6 +212,11 @@ export const resolvePathAsContext = (path, context) => {
206
212
 
207
213
  // Path with separators
208
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
+ }
209
220
  return traverseAsContext(context, path.split(/[\/.]/));
210
221
  }
211
222
 
@@ -438,6 +449,11 @@ const TokenType = {
438
449
  PLACEHOLDER: 'PLACEHOLDER', // _, _/path
439
450
  THIS: 'THIS', // $this
440
451
  EVENT: 'EVENT', // $event, $event.target
452
+ LBRACE: 'LBRACE', // {
453
+ RBRACE: 'RBRACE', // }
454
+ LBRACKET: 'LBRACKET', // [
455
+ RBRACKET: 'RBRACKET', // ]
456
+ COLON: 'COLON', // :
441
457
  EOF: 'EOF'
442
458
  };
443
459
 
@@ -484,18 +500,27 @@ const tokenize = (expr) => {
484
500
  // Special: = followed immediately by an operator symbol
485
501
  // In expressions like "=++/count", the = is just the JPRX delimiter
486
502
  // and ++ is a prefix operator applied to /count
487
- if (expr[i] === '=' && i + 1 < len) {
503
+ if (expr[i] === '=' && i === 0 && i + 1 < len) {
488
504
  // Check if next chars are a PREFIX operator (sort by length to match longest first)
489
505
  const prefixOps = [...operators.prefix.keys()].sort((a, b) => b.length - a.length);
490
- let isPrefixOp = false;
506
+ let matchedPrefix = null;
491
507
  for (const op of prefixOps) {
492
508
  if (expr.slice(i + 1, i + 1 + op.length) === op) {
493
- isPrefixOp = true;
509
+ matchedPrefix = op;
494
510
  break;
495
511
  }
496
512
  }
497
- if (isPrefixOp) {
498
- // 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)) {
499
524
  i++;
500
525
  continue;
501
526
  }
@@ -520,6 +545,33 @@ const tokenize = (expr) => {
520
545
  continue;
521
546
  }
522
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
+
523
575
  // Check for operators (longest match first)
524
576
  let matchedOp = null;
525
577
  for (const op of opSymbols) {
@@ -529,18 +581,37 @@ const tokenize = (expr) => {
529
581
  const before = i > 0 ? expr[i - 1] : ' ';
530
582
  const after = i + op.length < len ? expr[i + op.length] : ' ';
531
583
 
532
- const isInfix = operators.infix.has(op);
533
- const isPrefix = operators.prefix.has(op);
534
- 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);
587
+
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
+ }
535
596
 
536
- // For infix-only operators (like /, +, -, >, <, >=, <=, !=), we now REQUIRE surrounding whitespace
537
- // This prevents collision with path separators (especially for /)
538
- if (isInfix && !isPrefix && !isPostfix) {
539
- if (/\s/.test(before) && /\s/.test(after)) {
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) {
540
612
  matchedOp = op;
541
613
  break;
542
614
  }
543
- continue;
544
615
  }
545
616
 
546
617
  // Accept prefix/postfix operator if:
@@ -658,18 +729,20 @@ const tokenize = (expr) => {
658
729
  let isOp = false;
659
730
  for (const op of opSymbols) {
660
731
  if (expr.slice(i, i + op.length) === op) {
661
- const isInfix = operators.infix.has(op);
662
- const isPrefix = operators.prefix.has(op);
663
- 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);
664
735
 
665
736
  // Strict infix (like /) MUST have spaces to break the path
666
- if (isInfix && !isPrefix && !isPostfix) {
667
- const after = i + op.length < len ? expr[i + op.length] : ' ';
668
- if (/\s/.test(expr[i - 1]) && /\s/.test(after)) {
669
- isOp = true;
670
- 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;
671
745
  }
672
- continue;
673
746
  }
674
747
 
675
748
  // Prefix/Postfix: if they appear after a path, they are operators
@@ -739,12 +812,10 @@ const tokenize = (expr) => {
739
812
  const hasOperatorSyntax = (expr) => {
740
813
  if (!expr || typeof expr !== 'string') return false;
741
814
 
742
- // Skip function calls - they use legacy parser
743
- if (expr.includes('(')) return false;
744
-
745
- // Check for prefix operator pattern: =++ or =-- followed by /
746
- // This catches: =++/counter, =--/value
747
- 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)) {
748
819
  return true;
749
820
  }
750
821
 
@@ -756,7 +827,17 @@ const hasOperatorSyntax = (expr) => {
756
827
 
757
828
  // Check for infix with explicit whitespace: =/a + =/b
758
829
  // The spaces make it unambiguous that the symbol is an operator, not part of a path
759
- 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
760
841
  return true;
761
842
  }
762
843
 
@@ -914,9 +995,32 @@ class PrattParser {
914
995
  this.consume();
915
996
  return { type: 'Explosion', path: tok.value };
916
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
+ }
917
1011
  return { type: 'Path', value: tok.value };
918
1012
  }
919
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
+
920
1024
  // EOF or unknown
921
1025
  if (tok.type === TokenType.EOF) {
922
1026
  return { type: 'Literal', value: undefined };
@@ -924,6 +1028,48 @@ class PrattParser {
924
1028
 
925
1029
  throw new Error(`JPRX: Unexpected token ${tok.type}: ${tok.value}`);
926
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
+ }
927
1073
  }
928
1074
 
929
1075
  /**
@@ -974,68 +1120,140 @@ const evaluateAST = (ast, context, forMutation = false) => {
974
1120
  });
975
1121
  }
976
1122
 
977
- case 'Explosion': {
978
- const result = resolveArgument(ast.path + '...', context, false);
979
- 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));
980
1152
  }
981
1153
 
982
1154
  case 'Prefix': {
983
1155
  const opInfo = operators.prefix.get(ast.operator);
984
- if (!opInfo) {
985
- throw new Error(`JPRX: Unknown prefix operator: ${ast.operator}`);
986
- }
1156
+ if (!opInfo) throw new Error(`JPRX: Unknown prefix operator: ${ast.operator}`);
987
1157
  const helper = helpers.get(opInfo.helper);
988
- if (!helper) {
989
- throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
990
- }
991
-
992
- // Check if helper needs BindingTarget (pathAware)
1158
+ if (!helper) throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
993
1159
  const opts = helperOptions.get(opInfo.helper) || {};
994
1160
  const operand = evaluateAST(ast.operand, context, opts.pathAware);
995
- 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));
996
1169
  }
997
1170
 
998
1171
  case 'Postfix': {
999
1172
  const opInfo = operators.postfix.get(ast.operator);
1000
- if (!opInfo) {
1001
- throw new Error(`JPRX: Unknown postfix operator: ${ast.operator}`);
1002
- }
1173
+ if (!opInfo) throw new Error(`JPRX: Unknown postfix operator: ${ast.operator}`);
1003
1174
  const helper = helpers.get(opInfo.helper);
1004
- if (!helper) {
1005
- throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
1006
- }
1007
-
1175
+ if (!helper) throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
1008
1176
  const opts = helperOptions.get(opInfo.helper) || {};
1009
1177
  const operand = evaluateAST(ast.operand, context, opts.pathAware);
1010
- 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));
1011
1186
  }
1012
1187
 
1013
1188
  case 'Infix': {
1014
1189
  const opInfo = operators.infix.get(ast.operator);
1015
- if (!opInfo) {
1016
- throw new Error(`JPRX: Unknown infix operator: ${ast.operator}`);
1017
- }
1190
+ if (!opInfo) throw new Error(`JPRX: Unknown infix operator: ${ast.operator}`);
1018
1191
  const helper = helpers.get(opInfo.helper);
1019
- if (!helper) {
1020
- throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
1021
- }
1022
-
1192
+ if (!helper) throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
1023
1193
  const opts = helperOptions.get(opInfo.helper) || {};
1024
- // For infix, typically first arg might be pathAware
1194
+
1025
1195
  const left = evaluateAST(ast.left, context, opts.pathAware);
1026
1196
  const right = evaluateAST(ast.right, context, false);
1027
1197
 
1028
- 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
+ }
1029
1207
 
1030
- // Handle potentially exploded arguments (like in sum(/items...p))
1031
- // Although infix operators usually take exactly 2 args, we treat them consistently
1032
- if (Array.isArray(left) && ast.left.type === 'Explosion') finalArgs.push(...left);
1033
- 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
+ }
1034
1240
 
1035
- if (Array.isArray(right) && ast.right.type === 'Explosion') finalArgs.push(...right);
1036
- 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
+ }
1037
1253
 
1038
- return helper(...finalArgs);
1254
+ case 'Explosion': {
1255
+ const result = resolveArgument(ast.path + '...', context, false);
1256
+ return result.value;
1039
1257
  }
1040
1258
 
1041
1259
  default:
@@ -1262,12 +1480,14 @@ export const parseCDOMC = (input) => {
1262
1480
  let bDepth = 0;
1263
1481
  let brDepth = 0;
1264
1482
  let quote = null;
1483
+ const startChar = input[start];
1484
+ const isExpression = startChar === '=' || startChar === '#';
1265
1485
 
1266
1486
  while (i < len) {
1267
1487
  const char = input[i];
1268
1488
 
1269
1489
  if (quote) {
1270
- if (char === quote) quote = null;
1490
+ if (char === quote && input[i - 1] !== '\\') quote = null;
1271
1491
  i++;
1272
1492
  continue;
1273
1493
  } else if (char === '"' || char === "'" || char === "`") {
@@ -1287,8 +1507,42 @@ export const parseCDOMC = (input) => {
1287
1507
 
1288
1508
  // Termination at depth 0
1289
1509
  if (pDepth === 0 && bDepth === 0 && brDepth === 0) {
1290
- if (/[\s:,{}\[\]"'`()]/.test(char)) {
1291
- 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
+ }
1292
1546
  }
1293
1547
  }
1294
1548
 
@@ -1297,8 +1551,8 @@ export const parseCDOMC = (input) => {
1297
1551
 
1298
1552
  const word = input.slice(start, i);
1299
1553
 
1300
- // If word starts with =, preserve it as a string for cDOM expression parsing
1301
- if (word.startsWith('=')) {
1554
+ // If word starts with = or #, preserve it as a string for cDOM expression parsing
1555
+ if (word.startsWith('=') || word.startsWith('#')) {
1302
1556
  return word;
1303
1557
  }
1304
1558
 
@@ -1492,7 +1746,31 @@ export const parseJPRX = (input) => {
1492
1746
  } else {
1493
1747
  // Check for break BEFORE updating depth
1494
1748
  if (parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) {
1495
- 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
+ }
1496
1774
  }
1497
1775
 
1498
1776
  if (c === '(') parenDepth++;
@@ -1512,7 +1790,7 @@ export const parseJPRX = (input) => {
1512
1790
  continue;
1513
1791
  }
1514
1792
 
1515
- // Handle unquoted property names, identifiers, and paths
1793
+ // Handle unquoted property names, identifiers, paths, and FUNCTION CALLS
1516
1794
  if (/[a-zA-Z_$/./]/.test(char)) {
1517
1795
  let word = '';
1518
1796
  while (i < len && /[a-zA-Z0-9_$/.-]/.test(input[i])) {
@@ -1520,13 +1798,46 @@ export const parseJPRX = (input) => {
1520
1798
  i++;
1521
1799
  }
1522
1800
 
1523
- // Skip whitespace to check for :
1801
+ // Skip whitespace to check what follows
1524
1802
  let j = i;
1525
1803
  while (j < len && /\s/.test(input[j])) j++;
1526
1804
 
1527
1805
  if (input[j] === ':') {
1528
1806
  // It's a property name - quote it
1529
1807
  result += `"${word}"`;
1808
+ } else if (input[j] === '(') {
1809
+ // It's a FUNCTION CALL - capture the entire expression with balanced parens
1810
+ let expr = word;
1811
+ i = j; // move to the opening paren
1812
+ let parenDepth = 0;
1813
+ let inQuote = null;
1814
+
1815
+ while (i < len) {
1816
+ const c = input[i];
1817
+
1818
+ // Track quotes to avoid false paren matches inside strings
1819
+ if (inQuote) {
1820
+ if (c === inQuote && input[i - 1] !== '\\') inQuote = null;
1821
+ } else if (c === '"' || c === "'") {
1822
+ inQuote = c;
1823
+ } else {
1824
+ if (c === '(') parenDepth++;
1825
+ else if (c === ')') {
1826
+ parenDepth--;
1827
+ if (parenDepth === 0) {
1828
+ expr += c;
1829
+ i++;
1830
+ break;
1831
+ }
1832
+ }
1833
+ }
1834
+
1835
+ expr += c;
1836
+ i++;
1837
+ }
1838
+
1839
+ // Treat the function call as a JPRX expression by prefixing with =
1840
+ result += JSON.stringify('=' + expr);
1530
1841
  } else {
1531
1842
  // It's a value - check if it's a keyword
1532
1843
  if (word === 'true' || word === 'false' || word === 'null') {