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 +69 -0
- package/helpers/logic.js +9 -3
- package/index.js +1 -0
- package/package.json +1 -1
- package/parser.js +359 -80
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
|
|
10
|
-
export const
|
|
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('===',
|
|
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
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
|
|
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,
|
|
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
|
|
506
|
+
let matchedPrefix = null;
|
|
490
507
|
for (const op of prefixOps) {
|
|
491
508
|
if (expr.slice(i + 1, i + 1 + op.length) === op) {
|
|
492
|
-
|
|
509
|
+
matchedPrefix = op;
|
|
493
510
|
break;
|
|
494
511
|
}
|
|
495
512
|
}
|
|
496
|
-
if (
|
|
497
|
-
// Skip the =, it's just a delimiter
|
|
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
|
|
532
|
-
const
|
|
533
|
-
const
|
|
584
|
+
const infixConf = operators.infix.get(op);
|
|
585
|
+
const prefixConf = operators.prefix.get(op);
|
|
586
|
+
const postfixConf = operators.postfix.get(op);
|
|
534
587
|
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
if (
|
|
538
|
-
if (
|
|
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
|
|
661
|
-
const
|
|
662
|
-
const
|
|
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 (
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
//
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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+([
|
|
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 '
|
|
977
|
-
const
|
|
978
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1194
|
+
|
|
1024
1195
|
const left = evaluateAST(ast.left, context, opts.pathAware);
|
|
1025
1196
|
const right = evaluateAST(ast.right, context, false);
|
|
1026
1197
|
|
|
1027
|
-
|
|
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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
1035
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1290
|
-
|
|
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
|
|
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
|
-
|
|
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++;
|