jprx 1.0.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/parser.js ADDED
@@ -0,0 +1,1517 @@
1
+ /**
2
+ * LIGHTVIEW CDOM PARSER
3
+ * Responsible for resolving reactive paths and expressions.
4
+ */
5
+
6
+ const helpers = new Map();
7
+ const helperOptions = new Map();
8
+
9
+ /**
10
+ * Operator registration for JPRX.
11
+ * Operators map symbols to helper names and their positions.
12
+ */
13
+ const operators = {
14
+ prefix: new Map(), // e.g., '++' -> { helper: 'increment', precedence: 70 }
15
+ postfix: new Map(), // e.g., '++' -> { helper: 'increment', precedence: 70 }
16
+ infix: new Map() // e.g., '+' -> { helper: 'add', precedence: 50 }
17
+ };
18
+
19
+ // Default precedence levels
20
+ const DEFAULT_PRECEDENCE = {
21
+ prefix: 80,
22
+ postfix: 80,
23
+ infix: 50
24
+ };
25
+
26
+ /**
27
+ * Registers a global helper function.
28
+ */
29
+ export const registerHelper = (name, fn, options = {}) => {
30
+ helpers.set(name, fn);
31
+ if (options) helperOptions.set(name, options);
32
+ };
33
+
34
+ /**
35
+ * Registers a helper as an operator with specified position.
36
+ * @param {string} helperName - The name of the registered helper
37
+ * @param {string} symbol - The operator symbol (e.g., '++', '+', '-')
38
+ * @param {'prefix'|'postfix'|'infix'} position - Operator position
39
+ * @param {number} [precedence] - Optional precedence (higher = binds tighter)
40
+ */
41
+ export const registerOperator = (helperName, symbol, position, precedence) => {
42
+ if (!['prefix', 'postfix', 'infix'].includes(position)) {
43
+ throw new Error(`Invalid operator position: ${position}. Must be 'prefix', 'postfix', or 'infix'.`);
44
+ }
45
+ if (!helpers.has(helperName)) {
46
+ // Allow registration before helper exists (will be checked at parse time)
47
+ globalThis.console?.warn(`LightviewCDOM: Operator "${symbol}" registered for helper "${helperName}" which is not yet registered.`);
48
+ }
49
+ const prec = precedence ?? DEFAULT_PRECEDENCE[position];
50
+ operators[position].set(symbol, { helper: helperName, precedence: prec });
51
+ };
52
+
53
+ const getLV = () => globalThis.Lightview || null;
54
+ export const getRegistry = () => getLV()?.registry || null;
55
+
56
+ /**
57
+ * Represents a mutable target (a property on an object).
58
+ * Allows cdom-bind and mutation helpers to work with plain object properties
59
+ * by treating them as if they had a .value property.
60
+ */
61
+ export class BindingTarget {
62
+ constructor(parent, key) {
63
+ this.parent = parent;
64
+ this.key = key;
65
+ this.isBindingTarget = true; // Marker for duck-typing when instanceof fails
66
+ }
67
+ get value() { return this.parent[this.key]; }
68
+ set value(v) { this.parent[this.key] = v; }
69
+ get __parent__() { return this.parent; }
70
+ }
71
+
72
+ /**
73
+ * Unwraps a signal-like value to its raw value.
74
+ * This should be used to establish reactive dependencies within a computed context.
75
+ */
76
+ export const unwrapSignal = (val) => {
77
+ if (val && typeof val === 'function' && 'value' in val) {
78
+ return val.value;
79
+ }
80
+ if (val && typeof val === 'object' && !(globalThis.Node && val instanceof globalThis.Node) && 'value' in val) {
81
+ return val.value;
82
+ }
83
+ return val;
84
+ };
85
+
86
+
87
+ /**
88
+ * Resolves segments of a path against a root object, unwrapping signals as it goes.
89
+ */
90
+ const traverse = (root, segments) => {
91
+ let current = root;
92
+ for (const segment of segments) {
93
+ if (!segment) continue;
94
+ current = unwrapSignal(current);
95
+ if (current == null) return undefined;
96
+
97
+ const key = segment.startsWith('[') ? segment.slice(1, -1) : segment;
98
+ current = current[key];
99
+ }
100
+ return unwrapSignal(current);
101
+ };
102
+
103
+ /**
104
+ * Resolves segments but keeps the final value as a proxy/signal for use as context.
105
+ * Only unwraps intermediate values during traversal.
106
+ */
107
+ const traverseAsContext = (root, segments) => {
108
+ let current = root;
109
+ for (let i = 0; i < segments.length; i++) {
110
+ const segment = segments[i];
111
+ if (!segment) continue;
112
+ const key = segment.startsWith('[') ? segment.slice(1, -1) : segment;
113
+
114
+ const unwrapped = unwrapSignal(current);
115
+ if (unwrapped == null) return undefined;
116
+
117
+ if (i === segments.length - 1) {
118
+ return new BindingTarget(unwrapped, key);
119
+ }
120
+ current = unwrapped[key];
121
+ }
122
+ return current;
123
+ };
124
+
125
+ /**
126
+ * Resolves a path against a context and the global registry.
127
+ */
128
+ export const resolvePath = (path, context) => {
129
+ if (typeof path !== 'string') return path;
130
+
131
+ const registry = getRegistry();
132
+
133
+ // Current context: .
134
+ if (path === '.') return unwrapSignal(context);
135
+
136
+ // Global absolute path: $/something
137
+ // First check if the root is in the local context's state (cdom-state)
138
+ // This allows $/cart to resolve from cdom-state: { cart: {...} }
139
+ if (path.startsWith('$/')) {
140
+ const [rootName, ...rest] = path.slice(2).split('/');
141
+
142
+ // Check local state chain first (via __state__ property set by handleCDOMState)
143
+ let cur = context;
144
+ while (cur) {
145
+ const localState = cur.__state__;
146
+ if (localState && rootName in localState) {
147
+ return traverse(localState[rootName], rest);
148
+ }
149
+ cur = cur.__parent__;
150
+ }
151
+
152
+ // Then check global registry
153
+ const rootSignal = registry?.get(rootName);
154
+ if (!rootSignal) return undefined;
155
+
156
+ // Root can be a signal or a state proxy
157
+ return traverse(rootSignal, rest);
158
+ }
159
+
160
+ // Relative path from current context
161
+ if (path.startsWith('./')) {
162
+ return traverse(context, path.slice(2).split('/'));
163
+ }
164
+
165
+ // Parent path
166
+ if (path.startsWith('../')) {
167
+ return traverse(context?.__parent__, path.slice(3).split('/'));
168
+ }
169
+
170
+ // Path with separators - treat as relative
171
+ if (path.includes('/') || path.includes('.')) {
172
+ return traverse(context, path.split(/[\/.]/));
173
+ }
174
+
175
+ // Check if it's a single word that exists in the context
176
+ const unwrappedContext = unwrapSignal(context);
177
+ if (unwrappedContext && typeof unwrappedContext === 'object') {
178
+ if (path in unwrappedContext || unwrappedContext[path] !== undefined) {
179
+ // Use traverse with one segment to ensure signal unwrapping if context[path] is a signal
180
+ return traverse(unwrappedContext, [path]);
181
+ }
182
+ }
183
+
184
+ // Return as literal
185
+ return path;
186
+ };
187
+
188
+ /**
189
+ * Like resolvePath, but preserves proxy/signal wrappers for use as evaluation context.
190
+ */
191
+ export const resolvePathAsContext = (path, context) => {
192
+ if (typeof path !== 'string') return path;
193
+
194
+ const registry = getRegistry();
195
+
196
+ // Current context: .
197
+ if (path === '.') return context;
198
+
199
+ // Global absolute path: $/something
200
+ // First check if the root is in the local context's state (cdom-state)
201
+ if (path.startsWith('$/')) {
202
+ const segments = path.slice(2).split(/[/.]/);
203
+ const rootName = segments.shift();
204
+
205
+ // Check local state chain first
206
+ let cur = context;
207
+ while (cur) {
208
+ const localState = cur.__state__;
209
+ if (localState && rootName in localState) {
210
+ return traverseAsContext(localState[rootName], segments);
211
+ }
212
+ cur = cur.__parent__;
213
+ }
214
+
215
+ // Then check global registry
216
+ const rootSignal = registry?.get(rootName);
217
+ if (!rootSignal) return undefined;
218
+
219
+ return traverseAsContext(rootSignal, segments);
220
+ }
221
+
222
+ // Relative path from current context
223
+ if (path.startsWith('./')) {
224
+ return traverseAsContext(context, path.slice(2).split(/[\/.]/));
225
+ }
226
+
227
+ // Parent path
228
+ if (path.startsWith('../')) {
229
+ return traverseAsContext(context?.__parent__, path.slice(3).split(/[\/.]/));
230
+ }
231
+
232
+ // Path with separators
233
+ if (path.includes('/') || path.includes('.')) {
234
+ return traverseAsContext(context, path.split(/[\/.]/));
235
+ }
236
+
237
+ // Single property access
238
+ const unwrappedContext = unwrapSignal(context);
239
+ if (unwrappedContext && typeof unwrappedContext === 'object') {
240
+ // If it looks like a variable name, assume it's a property on the context
241
+ if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(path)) {
242
+ return new BindingTarget(unwrappedContext, path);
243
+ }
244
+ }
245
+
246
+ return path;
247
+ };
248
+
249
+ /**
250
+ * Represents a lazy value that will be resolved later with a specific context.
251
+ * Used for iteration placeholders like '_' and '$event'.
252
+ */
253
+ class LazyValue {
254
+ constructor(fn) {
255
+ this.fn = fn;
256
+ this.isLazy = true;
257
+ }
258
+ resolve(context) {
259
+ return this.fn(context);
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Helper to resolve an argument which could be a literal, a path, or an explosion.
265
+ * @param {string} arg - The argument string
266
+ * @param {object} context - The local context object
267
+ * @param {boolean} globalMode - If true, bare paths are resolved against global registry
268
+ */
269
+ const resolveArgument = (arg, context, globalMode = false) => {
270
+ // 1. Quoted Strings
271
+ if ((arg.startsWith("'") && arg.endsWith("'")) || (arg.startsWith('"') && arg.endsWith('"'))) {
272
+ return { value: arg.slice(1, -1), isLiteral: true };
273
+ }
274
+
275
+ // 2. Numbers
276
+ if (arg !== '' && !isNaN(Number(arg))) {
277
+ return { value: Number(arg), isLiteral: true };
278
+ }
279
+
280
+ // 3. Booleans / Null
281
+ if (arg === 'true') return { value: true, isLiteral: true };
282
+ if (arg === 'false') return { value: false, isLiteral: true };
283
+ if (arg === 'null') return { value: null, isLiteral: true };
284
+
285
+ // 4. Placeholder / Lazy Evaluation (_)
286
+ if (arg === '_' || arg.startsWith('_/') || arg.startsWith('_.')) {
287
+ return {
288
+ value: new LazyValue((item) => {
289
+ if (arg === '_') return item;
290
+ const path = arg.startsWith('_.') ? arg.slice(2) : arg.slice(2);
291
+ return resolvePath(path, item);
292
+ }),
293
+ isLazy: true
294
+ };
295
+ }
296
+
297
+ // 5. Event Placeholder ($event)
298
+ if (arg === '$event' || arg.startsWith('$event/') || arg.startsWith('$event.')) {
299
+ return {
300
+ value: new LazyValue((event) => {
301
+ if (arg === '$event') return event;
302
+ const path = arg.startsWith('$event.') ? arg.slice(7) : arg.slice(7);
303
+ return resolvePath(path, event);
304
+ }),
305
+ isLazy: true
306
+ };
307
+ }
308
+
309
+ // 6. Object / Array Literals (Concise)
310
+ if (arg.startsWith('{') || arg.startsWith('[')) {
311
+ try {
312
+ const data = parseJPRX(arg);
313
+
314
+ // Define a recursive resolver for template objects
315
+ const resolveTemplate = (node, context) => {
316
+ if (typeof node === 'string') {
317
+ if (node.startsWith('$')) {
318
+ const res = resolveExpression(node, context);
319
+ const final = (res instanceof LazyValue) ? res.resolve(context) : res;
320
+ return unwrapSignal(final);
321
+ }
322
+ if (node === '_' || node.startsWith('_/') || node.startsWith('_.')) {
323
+ const path = (node.startsWith('_.') || node.startsWith('_/')) ? node.slice(2) : node.slice(2);
324
+ const res = node === '_' ? context : resolvePath(path, context);
325
+ return unwrapSignal(res);
326
+ }
327
+ if (node.startsWith('../')) return unwrapSignal(resolvePath(node, context));
328
+ }
329
+ if (Array.isArray(node)) return node.map(n => resolveTemplate(n, context));
330
+ if (node && typeof node === 'object') {
331
+ const res = {};
332
+ for (const k in node) res[k] = resolveTemplate(node[k], context);
333
+ return res;
334
+ }
335
+ return node;
336
+ };
337
+
338
+ // Check if it contains any reactive parts
339
+ const hasReactive = (obj) => {
340
+ if (typeof obj === 'string') {
341
+ return obj.startsWith('$') || obj.startsWith('_') || obj.startsWith('../');
342
+ }
343
+ if (Array.isArray(obj)) return obj.some(hasReactive);
344
+ if (obj && typeof obj === 'object') return Object.values(obj).some(hasReactive);
345
+ return false;
346
+ };
347
+
348
+ if (hasReactive(data)) {
349
+ return {
350
+ value: new LazyValue((context) => resolveTemplate(data, context)),
351
+ isLazy: true
352
+ };
353
+ }
354
+ return { value: data, isLiteral: true };
355
+ } catch (e) {
356
+ // Fallback to path resolution if JSON parse fails
357
+ }
358
+ }
359
+
360
+ // 7. Nested Function Calls
361
+ if (arg.includes('(')) {
362
+ let nestedExpr = arg;
363
+ if (arg.startsWith('/')) {
364
+ nestedExpr = '$' + arg;
365
+ } else if (globalMode && !arg.startsWith('$') && !arg.startsWith('./')) {
366
+ nestedExpr = `$/${arg}`;
367
+ }
368
+
369
+ const val = resolveExpression(nestedExpr, context);
370
+ if (val instanceof LazyValue) {
371
+ return { value: val, isLazy: true };
372
+ }
373
+ return { value: val, isSignal: false };
374
+ }
375
+
376
+ // 8. Path normalization
377
+ let normalizedPath;
378
+ if (arg.startsWith('/')) {
379
+ normalizedPath = '$' + arg;
380
+ } else if (arg.startsWith('$') || arg.startsWith('./') || arg.startsWith('../')) {
381
+ normalizedPath = arg;
382
+ } else if (globalMode) {
383
+ normalizedPath = `$/${arg}`;
384
+ } else {
385
+ normalizedPath = `./${arg}`;
386
+ }
387
+
388
+ // 9. Explosion operator (path... or path...prop)
389
+ const explosionIdx = arg.indexOf('...');
390
+ if (explosionIdx !== -1) {
391
+ // Use normalizedPath up to the explosion point
392
+ // Note: indexOf('...') might be Different in normalizedPath if we added $/
393
+ const normExplosionIdx = normalizedPath.indexOf('...');
394
+ const pathPart = normalizedPath.slice(0, normExplosionIdx);
395
+ const propName = arg.slice(explosionIdx + 3);
396
+
397
+ const parent = resolvePath(pathPart, context);
398
+ const unwrappedParent = unwrapSignal(parent);
399
+
400
+ if (Array.isArray(unwrappedParent)) {
401
+ const values = unwrappedParent.map(item => {
402
+ const unwrappedItem = unwrapSignal(item);
403
+ if (!propName) return unwrappedItem;
404
+ return unwrappedItem && typeof unwrappedItem === 'object' ? unwrapSignal(unwrappedItem[propName]) : undefined;
405
+ });
406
+ return { value: values, isExplosion: true };
407
+ } else if (unwrappedParent && typeof unwrappedParent === 'object') {
408
+ if (!propName) return { value: unwrappedParent, isExplosion: true };
409
+ const val = unwrappedParent[propName];
410
+ return { value: unwrapSignal(val), isExplosion: true };
411
+ }
412
+ return { value: undefined, isExplosion: true };
413
+ }
414
+
415
+ const value = resolvePathAsContext(normalizedPath, context);
416
+ return { value, isExplosion: false };
417
+ };
418
+
419
+
420
+ // ============================================================================
421
+ // JPRX TOKENIZER & PRATT PARSER
422
+ // ============================================================================
423
+
424
+ /**
425
+ * Token types for JPRX expressions.
426
+ */
427
+ const TokenType = {
428
+ PATH: 'PATH', // $/user/age, ./name, ../parent
429
+ LITERAL: 'LITERAL', // 123, "hello", true, false, null
430
+ OPERATOR: 'OPERATOR', // +, -, *, /, ++, --, etc.
431
+ LPAREN: 'LPAREN', // (
432
+ RPAREN: 'RPAREN', // )
433
+ COMMA: 'COMMA', // ,
434
+ EXPLOSION: 'EXPLOSION', // ... suffix
435
+ PLACEHOLDER: 'PLACEHOLDER', // _, _/path
436
+ EVENT: 'EVENT', // $event, $event.target
437
+ EOF: 'EOF'
438
+ };
439
+
440
+ /**
441
+ * Get all registered operator symbols sorted by length (longest first).
442
+ * This ensures we match '++' before '+'.
443
+ */
444
+ const getOperatorSymbols = () => {
445
+ const allOps = new Set([
446
+ ...operators.prefix.keys(),
447
+ ...operators.postfix.keys(),
448
+ ...operators.infix.keys()
449
+ ]);
450
+ return [...allOps].sort((a, b) => b.length - a.length);
451
+ };
452
+
453
+ /**
454
+ * Checks if a symbol is registered as any type of operator.
455
+ */
456
+ const isOperator = (symbol) => {
457
+ return operators.prefix.has(symbol) ||
458
+ operators.postfix.has(symbol) ||
459
+ operators.infix.has(symbol);
460
+ };
461
+
462
+ /**
463
+ * Tokenizes a JPRX expression into an array of tokens.
464
+ * @param {string} expr - The expression to tokenize
465
+ * @returns {Array<{type: string, value: any}>}
466
+ */
467
+ const tokenize = (expr) => {
468
+ const tokens = [];
469
+ let i = 0;
470
+ const len = expr.length;
471
+ const opSymbols = getOperatorSymbols();
472
+
473
+ while (i < len) {
474
+ // Skip whitespace
475
+ if (/\s/.test(expr[i])) {
476
+ i++;
477
+ continue;
478
+ }
479
+
480
+ // Special: $ followed immediately by an operator symbol
481
+ // In expressions like "$++/count", the $ is just the JPRX delimiter
482
+ // and ++ is a prefix operator applied to /count
483
+ if (expr[i] === '$' && i + 1 < len) {
484
+ // Check if next chars are an operator
485
+ let isOpAfter = false;
486
+ for (const op of opSymbols) {
487
+ if (expr.slice(i + 1, i + 1 + op.length) === op) {
488
+ isOpAfter = true;
489
+ break;
490
+ }
491
+ }
492
+ if (isOpAfter) {
493
+ // Skip the $, it's just a delimiter
494
+ i++;
495
+ continue;
496
+ }
497
+ }
498
+
499
+ // Parentheses
500
+ if (expr[i] === '(') {
501
+ tokens.push({ type: TokenType.LPAREN, value: '(' });
502
+ i++;
503
+ continue;
504
+ }
505
+ if (expr[i] === ')') {
506
+ tokens.push({ type: TokenType.RPAREN, value: ')' });
507
+ i++;
508
+ continue;
509
+ }
510
+
511
+ // Comma
512
+ if (expr[i] === ',') {
513
+ tokens.push({ type: TokenType.COMMA, value: ',' });
514
+ i++;
515
+ continue;
516
+ }
517
+
518
+ // Check for operators (longest match first)
519
+ let matchedOp = null;
520
+ for (const op of opSymbols) {
521
+ if (expr.slice(i, i + op.length) === op) {
522
+ // Make sure it's not part of a path (e.g., don't match + in $/a+b if that's a path)
523
+ // Operators should be surrounded by whitespace or other tokens
524
+ const before = i > 0 ? expr[i - 1] : ' ';
525
+ const after = i + op.length < len ? expr[i + op.length] : ' ';
526
+
527
+ const isInfix = operators.infix.has(op);
528
+ const isPrefix = operators.prefix.has(op);
529
+ const isPostfix = operators.postfix.has(op);
530
+
531
+ // For infix-only operators (like /, +, -, >, <, >=, <=, !=), we now REQUIRE surrounding whitespace
532
+ // This prevents collision with path separators (especially for /)
533
+ if (isInfix && !isPrefix && !isPostfix) {
534
+ if (/\s/.test(before) && /\s/.test(after)) {
535
+ matchedOp = op;
536
+ break;
537
+ }
538
+ continue;
539
+ }
540
+
541
+ // Accept prefix/postfix operator if:
542
+ // - Previous char is whitespace, ), or another operator end
543
+ // - Or we're at start of expression
544
+ // - And next char is whitespace, (, $, ., /, digit, quote, or another operator start
545
+ const validBefore = /[\s)]/.test(before) || i === 0 ||
546
+ tokens.length === 0 ||
547
+ tokens[tokens.length - 1].type === TokenType.LPAREN ||
548
+ tokens[tokens.length - 1].type === TokenType.COMMA ||
549
+ tokens[tokens.length - 1].type === TokenType.OPERATOR;
550
+ const validAfter = /[\s($./'"0-9_]/.test(after) ||
551
+ i + op.length >= len ||
552
+ opSymbols.some(o => expr.slice(i + op.length).startsWith(o));
553
+
554
+ if (validBefore || validAfter) {
555
+ matchedOp = op;
556
+ break;
557
+ }
558
+ }
559
+ }
560
+
561
+ if (matchedOp) {
562
+ tokens.push({ type: TokenType.OPERATOR, value: matchedOp });
563
+ i += matchedOp.length;
564
+ continue;
565
+ }
566
+
567
+ // Quoted strings
568
+ if (expr[i] === '"' || expr[i] === "'") {
569
+ const quote = expr[i];
570
+ let str = '';
571
+ i++; // skip opening quote
572
+ while (i < len && expr[i] !== quote) {
573
+ if (expr[i] === '\\' && i + 1 < len) {
574
+ i++;
575
+ if (expr[i] === 'n') str += '\n';
576
+ else if (expr[i] === 't') str += '\t';
577
+ else str += expr[i];
578
+ } else {
579
+ str += expr[i];
580
+ }
581
+ i++;
582
+ }
583
+ i++; // skip closing quote
584
+ tokens.push({ type: TokenType.LITERAL, value: str });
585
+ continue;
586
+ }
587
+
588
+ // Numbers (including negative numbers at start or after operator)
589
+ if (/\d/.test(expr[i]) ||
590
+ (expr[i] === '-' && /\d/.test(expr[i + 1]) &&
591
+ (tokens.length === 0 ||
592
+ tokens[tokens.length - 1].type === TokenType.OPERATOR ||
593
+ tokens[tokens.length - 1].type === TokenType.LPAREN ||
594
+ tokens[tokens.length - 1].type === TokenType.COMMA))) {
595
+ let num = '';
596
+ if (expr[i] === '-') {
597
+ num = '-';
598
+ i++;
599
+ }
600
+ while (i < len && /[\d.]/.test(expr[i])) {
601
+ num += expr[i];
602
+ i++;
603
+ }
604
+ tokens.push({ type: TokenType.LITERAL, value: parseFloat(num) });
605
+ continue;
606
+ }
607
+
608
+ // Placeholder (_)
609
+ if (expr[i] === '_' && (i + 1 >= len || !/[a-zA-Z0-9]/.test(expr[i + 1]) || expr[i + 1] === '/' || expr[i + 1] === '.')) {
610
+ let placeholder = '_';
611
+ i++;
612
+ // Check for path after placeholder: _/path or _.path
613
+ if (i < len && (expr[i] === '/' || expr[i] === '.')) {
614
+ while (i < len && !/[\s,)(]/.test(expr[i])) {
615
+ placeholder += expr[i];
616
+ i++;
617
+ }
618
+ }
619
+ tokens.push({ type: TokenType.PLACEHOLDER, value: placeholder });
620
+ continue;
621
+ }
622
+
623
+ // $event placeholder
624
+ if (expr.slice(i, i + 6) === '$event') {
625
+ let eventPath = '$event';
626
+ i += 6;
627
+ while (i < len && /[a-zA-Z0-9_./]/.test(expr[i])) {
628
+ eventPath += expr[i];
629
+ i++;
630
+ }
631
+ tokens.push({ type: TokenType.EVENT, value: eventPath });
632
+ continue;
633
+ }
634
+
635
+ // Paths: start with $, ., or /
636
+ if (expr[i] === '$' || expr[i] === '.' || expr[i] === '/') {
637
+ let path = '';
638
+ // Consume the path, but stop at operators
639
+ while (i < len) {
640
+ // Check if we've hit an operator
641
+ let isOp = false;
642
+ for (const op of opSymbols) {
643
+ if (expr.slice(i, i + op.length) === op) {
644
+ const isInfix = operators.infix.has(op);
645
+ const isPrefix = operators.prefix.has(op);
646
+ const isPostfix = operators.postfix.has(op);
647
+
648
+ // Strict infix (like /) MUST have spaces to break the path
649
+ if (isInfix && !isPrefix && !isPostfix) {
650
+ const after = i + op.length < len ? expr[i + op.length] : ' ';
651
+ if (/\s/.test(expr[i - 1]) && /\s/.test(after)) {
652
+ isOp = true;
653
+ break;
654
+ }
655
+ continue;
656
+ }
657
+
658
+ // Prefix/Postfix: if they appear after a path, they are operators
659
+ // (e.g., $/count++)
660
+ if (path.length > 0 && path[path.length - 1] !== '/') {
661
+ isOp = true;
662
+ break;
663
+ }
664
+ }
665
+ }
666
+ if (isOp) break;
667
+
668
+ // Stop at whitespace, comma, or parentheses
669
+ if (/[\s,()]/.test(expr[i])) break;
670
+
671
+ // Check for explosion operator
672
+ if (expr.slice(i, i + 3) === '...') {
673
+ break;
674
+ }
675
+
676
+ path += expr[i];
677
+ i++;
678
+ }
679
+
680
+ // Check for explosion suffix
681
+ if (expr.slice(i, i + 3) === '...') {
682
+ tokens.push({ type: TokenType.PATH, value: path });
683
+ tokens.push({ type: TokenType.EXPLOSION, value: '...' });
684
+ i += 3;
685
+ } else {
686
+ tokens.push({ type: TokenType.PATH, value: path });
687
+ }
688
+ continue;
689
+ }
690
+
691
+ // Boolean/null literals or identifiers
692
+ if (/[a-zA-Z]/.test(expr[i])) {
693
+ let ident = '';
694
+ while (i < len && /[a-zA-Z0-9_]/.test(expr[i])) {
695
+ ident += expr[i];
696
+ i++;
697
+ }
698
+ if (ident === 'true') tokens.push({ type: TokenType.LITERAL, value: true });
699
+ else if (ident === 'false') tokens.push({ type: TokenType.LITERAL, value: false });
700
+ else if (ident === 'null') tokens.push({ type: TokenType.LITERAL, value: null });
701
+ else tokens.push({ type: TokenType.PATH, value: ident }); // treat as path/identifier
702
+ continue;
703
+ }
704
+
705
+ // Unknown character, skip
706
+ i++;
707
+ }
708
+
709
+ tokens.push({ type: TokenType.EOF, value: null });
710
+ return tokens;
711
+ };
712
+
713
+ /**
714
+ * Checks if an expression contains operator syntax (not just function calls).
715
+ * Used to determine whether to use Pratt parser or legacy parser.
716
+ *
717
+ * CONSERVATIVE: Only detect explicit patterns to avoid false positives.
718
+ * - Prefix: $++/path, $--/path, $!!/path (operator immediately after $ before path)
719
+ * - Postfix: $/path++ or $/path-- (operator at end of expression, not followed by ()
720
+ * - Infix with spaces: $/path + $/other (spaces around operator)
721
+ */
722
+ const hasOperatorSyntax = (expr) => {
723
+ if (!expr || typeof expr !== 'string') return false;
724
+
725
+ // Skip function calls - they use legacy parser
726
+ if (expr.includes('(')) return false;
727
+
728
+ // Check for prefix operator pattern: $++ or $-- followed by /
729
+ // This catches: $++/counter, $--/value
730
+ if (/^\$(\+\+|--|!!)\/?/.test(expr)) {
731
+ return true;
732
+ }
733
+
734
+ // Check for postfix operator pattern: path ending with ++ or --
735
+ // This catches: $/counter++, $/value--
736
+ if (/(\+\+|--)$/.test(expr)) {
737
+ return true;
738
+ }
739
+
740
+ // Check for infix with explicit whitespace: $/a + $/b
741
+ // The spaces make it unambiguous that the symbol is an operator, not part of a path
742
+ if (/\s+([+\-*/]|>|<|>=|<=|!=)\s+/.test(expr)) {
743
+ return true;
744
+ }
745
+
746
+ return false;
747
+ };
748
+
749
+ /**
750
+ * Pratt Parser for JPRX expressions.
751
+ * Parses tokens into an AST respecting operator precedence.
752
+ */
753
+ class PrattParser {
754
+ constructor(tokens, context, isGlobalMode = false) {
755
+ this.tokens = tokens;
756
+ this.pos = 0;
757
+ this.context = context;
758
+ this.isGlobalMode = isGlobalMode;
759
+ }
760
+
761
+ peek() {
762
+ return this.tokens[this.pos] || { type: TokenType.EOF, value: null };
763
+ }
764
+
765
+ consume() {
766
+ return this.tokens[this.pos++];
767
+ }
768
+
769
+ expect(type) {
770
+ const tok = this.consume();
771
+ if (tok.type !== type) {
772
+ throw new Error(`JPRX: Expected ${type} but got ${tok.type}`);
773
+ }
774
+ return tok;
775
+ }
776
+
777
+ /**
778
+ * Get binding power (precedence) for an infix or postfix operator.
779
+ */
780
+ getInfixPrecedence(op) {
781
+ const infixInfo = operators.infix.get(op);
782
+ if (infixInfo) return infixInfo.precedence;
783
+ const postfixInfo = operators.postfix.get(op);
784
+ if (postfixInfo) return postfixInfo.precedence;
785
+ return 0;
786
+ }
787
+
788
+ /**
789
+ * Parse an expression with given minimum precedence.
790
+ */
791
+ parseExpression(minPrecedence = 0) {
792
+ let left = this.parsePrefix();
793
+ let tok = this.peek();
794
+
795
+ while (tok.type === TokenType.OPERATOR) {
796
+ const prec = this.getInfixPrecedence(tok.value);
797
+ if (prec < minPrecedence) break;
798
+
799
+ // Check if it's a postfix operator
800
+ if (operators.postfix.has(tok.value) && !operators.infix.has(tok.value)) {
801
+ this.consume();
802
+ left = { type: 'Postfix', operator: tok.value, operand: left };
803
+ tok = this.peek();
804
+ continue;
805
+ }
806
+
807
+ // Check if it's an infix operator
808
+ if (operators.infix.has(tok.value)) {
809
+ this.consume();
810
+ // Right associativity would use prec, left uses prec + 1
811
+ const right = this.parseExpression(prec + 1);
812
+ left = { type: 'Infix', operator: tok.value, left, right };
813
+ tok = this.peek();
814
+ continue;
815
+ }
816
+
817
+ // Postfix that's also infix - context determines
818
+ // If next token is a value, treat as infix
819
+ this.consume();
820
+ const nextTok = this.peek();
821
+ if (nextTok.type === TokenType.PATH ||
822
+ nextTok.type === TokenType.LITERAL ||
823
+ nextTok.type === TokenType.LPAREN ||
824
+ nextTok.type === TokenType.PLACEHOLDER ||
825
+ nextTok.type === TokenType.EVENT ||
826
+ (nextTok.type === TokenType.OPERATOR && operators.prefix.has(nextTok.value))) {
827
+ // Infix
828
+ const right = this.parseExpression(prec + 1);
829
+ left = { type: 'Infix', operator: tok.value, left, right };
830
+ } else {
831
+ // Postfix
832
+ left = { type: 'Postfix', operator: tok.value, operand: left };
833
+ }
834
+ tok = this.peek();
835
+ }
836
+
837
+ return left;
838
+ }
839
+
840
+ /**
841
+ * Parse a prefix expression (literals, paths, prefix operators, groups).
842
+ */
843
+ parsePrefix() {
844
+ const tok = this.peek();
845
+
846
+ // Prefix operator
847
+ if (tok.type === TokenType.OPERATOR && operators.prefix.has(tok.value)) {
848
+ this.consume();
849
+ const prefixInfo = operators.prefix.get(tok.value);
850
+ const operand = this.parseExpression(prefixInfo.precedence);
851
+ return { type: 'Prefix', operator: tok.value, operand };
852
+ }
853
+
854
+ // Grouped expression
855
+ if (tok.type === TokenType.LPAREN) {
856
+ this.consume();
857
+ const inner = this.parseExpression(0);
858
+ this.expect(TokenType.RPAREN);
859
+ return inner;
860
+ }
861
+
862
+ // Literal
863
+ if (tok.type === TokenType.LITERAL) {
864
+ this.consume();
865
+ return { type: 'Literal', value: tok.value };
866
+ }
867
+
868
+ // Placeholder
869
+ if (tok.type === TokenType.PLACEHOLDER) {
870
+ this.consume();
871
+ return { type: 'Placeholder', value: tok.value };
872
+ }
873
+
874
+ // Event
875
+ if (tok.type === TokenType.EVENT) {
876
+ this.consume();
877
+ return { type: 'Event', value: tok.value };
878
+ }
879
+
880
+ // Path (possibly with explosion)
881
+ if (tok.type === TokenType.PATH) {
882
+ this.consume();
883
+ const nextTok = this.peek();
884
+ if (nextTok.type === TokenType.EXPLOSION) {
885
+ this.consume();
886
+ return { type: 'Explosion', path: tok.value };
887
+ }
888
+ return { type: 'Path', value: tok.value };
889
+ }
890
+
891
+ // EOF or unknown
892
+ if (tok.type === TokenType.EOF) {
893
+ return { type: 'Literal', value: undefined };
894
+ }
895
+
896
+ throw new Error(`JPRX: Unexpected token ${tok.type}: ${tok.value}`);
897
+ }
898
+ }
899
+
900
+ /**
901
+ * Evaluates a Pratt parser AST node into a value.
902
+ * @param {object} ast - The AST node
903
+ * @param {object} context - The evaluation context
904
+ * @param {boolean} forMutation - Whether to preserve BindingTarget for mutation
905
+ * @returns {any}
906
+ */
907
+ const evaluateAST = (ast, context, forMutation = false) => {
908
+ if (!ast) return undefined;
909
+
910
+ switch (ast.type) {
911
+ case 'Literal':
912
+ return ast.value;
913
+
914
+ case 'Path': {
915
+ const resolved = forMutation
916
+ ? resolvePathAsContext(ast.value, context)
917
+ : resolvePath(ast.value, context);
918
+ return forMutation ? resolved : unwrapSignal(resolved);
919
+ }
920
+
921
+ case 'Placeholder': {
922
+ // Return a LazyValue for placeholder resolution
923
+ return new LazyValue((item) => {
924
+ if (ast.value === '_') return item;
925
+ const path = ast.value.startsWith('_.') ? ast.value.slice(2) : ast.value.slice(2);
926
+ return resolvePath(path, item);
927
+ });
928
+ }
929
+
930
+ case 'Event': {
931
+ return new LazyValue((event) => {
932
+ if (ast.value === '$event') return event;
933
+ const path = ast.value.startsWith('$event.') ? ast.value.slice(7) : ast.value.slice(7);
934
+ return resolvePath(path, event);
935
+ });
936
+ }
937
+
938
+ case 'Explosion': {
939
+ const result = resolveArgument(ast.path + '...', context, false);
940
+ return result.value;
941
+ }
942
+
943
+ case 'Prefix': {
944
+ const opInfo = operators.prefix.get(ast.operator);
945
+ if (!opInfo) {
946
+ throw new Error(`JPRX: Unknown prefix operator: ${ast.operator}`);
947
+ }
948
+ const helper = helpers.get(opInfo.helper);
949
+ if (!helper) {
950
+ throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
951
+ }
952
+
953
+ // Check if helper needs BindingTarget (pathAware)
954
+ const opts = helperOptions.get(opInfo.helper) || {};
955
+ const operand = evaluateAST(ast.operand, context, opts.pathAware);
956
+ return helper(operand);
957
+ }
958
+
959
+ case 'Postfix': {
960
+ const opInfo = operators.postfix.get(ast.operator);
961
+ if (!opInfo) {
962
+ throw new Error(`JPRX: Unknown postfix operator: ${ast.operator}`);
963
+ }
964
+ const helper = helpers.get(opInfo.helper);
965
+ if (!helper) {
966
+ throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
967
+ }
968
+
969
+ const opts = helperOptions.get(opInfo.helper) || {};
970
+ const operand = evaluateAST(ast.operand, context, opts.pathAware);
971
+ return helper(operand);
972
+ }
973
+
974
+ case 'Infix': {
975
+ const opInfo = operators.infix.get(ast.operator);
976
+ if (!opInfo) {
977
+ throw new Error(`JPRX: Unknown infix operator: ${ast.operator}`);
978
+ }
979
+ const helper = helpers.get(opInfo.helper);
980
+ if (!helper) {
981
+ throw new Error(`JPRX: Helper "${opInfo.helper}" for operator "${ast.operator}" not found.`);
982
+ }
983
+
984
+ const opts = helperOptions.get(opInfo.helper) || {};
985
+ // For infix, typically first arg might be pathAware
986
+ const left = evaluateAST(ast.left, context, opts.pathAware);
987
+ const right = evaluateAST(ast.right, context, false);
988
+ return helper(unwrapSignal(left), unwrapSignal(right));
989
+ }
990
+
991
+ default:
992
+ throw new Error(`JPRX: Unknown AST node type: ${ast.type}`);
993
+ }
994
+ };
995
+
996
+ /**
997
+ * Parses and evaluates a JPRX expression using the Pratt parser.
998
+ * @param {string} expr - The expression string
999
+ * @param {object} context - The evaluation context
1000
+ * @returns {any}
1001
+ */
1002
+ const parseWithPratt = (expr, context) => {
1003
+ const tokens = tokenize(expr);
1004
+ const parser = new PrattParser(tokens, context);
1005
+ const ast = parser.parseExpression(0);
1006
+ return evaluateAST(ast, context);
1007
+ };
1008
+
1009
+
1010
+ /**
1011
+ * Core logic to resolve an CDOM expression.
1012
+ * This can be called recursively and will register all accessed dependencies
1013
+ * against the currently active Lightview effect.
1014
+ */
1015
+ export const resolveExpression = (expr, context) => {
1016
+ if (typeof expr !== 'string') return expr;
1017
+
1018
+ // Check if this expression uses operator syntax (prefix/postfix/infix operators)
1019
+ // If so, use the Pratt parser for proper precedence handling
1020
+ if (hasOperatorSyntax(expr)) {
1021
+ try {
1022
+ return parseWithPratt(expr, context);
1023
+ } catch (e) {
1024
+ // Fall back to legacy parsing if Pratt fails
1025
+ globalThis.console?.warn('JPRX: Pratt parser failed, falling back to legacy:', e.message);
1026
+ }
1027
+ }
1028
+
1029
+ const funcStart = expr.indexOf('(');
1030
+ if (funcStart !== -1 && expr.endsWith(')')) {
1031
+ const fullPath = expr.slice(0, funcStart).trim();
1032
+ const argsStr = expr.slice(funcStart + 1, -1);
1033
+
1034
+ const segments = fullPath.split('/');
1035
+ let funcName = segments.pop().replace(/^\$/, '');
1036
+
1037
+ // Handle case where path ends in / (like $/ for division helper)
1038
+ if (funcName === '' && (segments.length > 0 || fullPath === '/')) {
1039
+ funcName = '/';
1040
+ }
1041
+
1042
+ const navPath = segments.join('/');
1043
+
1044
+ const isGlobalExpr = expr.startsWith('$/') || expr.startsWith('$');
1045
+
1046
+ let baseContext = context;
1047
+ if (navPath && navPath !== '$') {
1048
+ baseContext = resolvePathAsContext(navPath, context);
1049
+ }
1050
+
1051
+ const helper = helpers.get(funcName);
1052
+ if (!helper) {
1053
+ globalThis.console?.warn(`LightviewCDOM: Helper "${funcName}" not found.`);
1054
+ return expr;
1055
+ }
1056
+
1057
+ const options = helperOptions.get(funcName) || {};
1058
+
1059
+ // Split arguments respecting quotes, parentheses, curly braces, and square brackets
1060
+ const argsList = [];
1061
+ let current = '', parenDepth = 0, braceDepth = 0, bracketDepth = 0, quote = null;
1062
+ for (let i = 0; i < argsStr.length; i++) {
1063
+ const char = argsStr[i];
1064
+ if (char === quote) quote = null;
1065
+ else if (!quote && (char === "'" || char === '"')) quote = char;
1066
+ else if (!quote && char === '(') parenDepth++;
1067
+ else if (!quote && char === ')') parenDepth--;
1068
+ else if (!quote && char === '{') braceDepth++;
1069
+ else if (!quote && char === '}') braceDepth--;
1070
+ else if (!quote && char === '[') bracketDepth++;
1071
+ else if (!quote && char === ']') bracketDepth--;
1072
+ else if (!quote && char === ',' && parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) {
1073
+ argsList.push(current.trim());
1074
+ current = '';
1075
+ continue;
1076
+ }
1077
+ current += char;
1078
+ }
1079
+ if (current) argsList.push(current.trim());
1080
+
1081
+ const resolvedArgs = [];
1082
+ let hasLazy = false;
1083
+ for (let i = 0; i < argsList.length; i++) {
1084
+ const arg = argsList[i];
1085
+ const useGlobalMode = isGlobalExpr && (navPath === '$' || !navPath);
1086
+ const res = resolveArgument(arg, baseContext, useGlobalMode);
1087
+
1088
+ if (res.isLazy) hasLazy = true;
1089
+
1090
+ // For mutation helpers, skip unwrapping for specific arguments (usually the first)
1091
+ const shouldUnwrap = !(options.pathAware && i === 0);
1092
+
1093
+ // Don't unwrap LazyValues - pass them directly to helpers
1094
+ // Helpers like map() need the LazyValue.resolve method
1095
+ let val = res.value;
1096
+ if (shouldUnwrap && !(val && val.isLazy)) {
1097
+ val = unwrapSignal(val);
1098
+ }
1099
+
1100
+ if (res.isExplosion && Array.isArray(val)) {
1101
+ resolvedArgs.push(...val.map(v => (shouldUnwrap && !(v && v.isLazy)) ? unwrapSignal(v) : v));
1102
+ } else {
1103
+ resolvedArgs.push(val);
1104
+ }
1105
+ }
1106
+
1107
+ if (hasLazy && !options.lazyAware) {
1108
+ // Return a new LazyValue that resolves all its lazy arguments
1109
+ // Only for helpers that don't know how to handle LazyValue internally
1110
+ return new LazyValue((contextOverride) => {
1111
+ const finalArgs = resolvedArgs.map((arg, i) => {
1112
+ const shouldUnwrap = !(options.pathAware && i === 0);
1113
+ const resolved = arg instanceof LazyValue ? arg.resolve(contextOverride) : arg;
1114
+ return shouldUnwrap ? unwrapSignal(resolved) : resolved;
1115
+ });
1116
+ return helper(...finalArgs);
1117
+ });
1118
+ }
1119
+
1120
+ const result = helper(...resolvedArgs);
1121
+ return unwrapSignal(result);
1122
+ }
1123
+
1124
+ return unwrapSignal(resolvePath(expr, context));
1125
+ };
1126
+
1127
+ /**
1128
+ * Parses an CDOM expression into a reactive signal.
1129
+ */
1130
+ export const parseExpression = (expr, context) => {
1131
+ const LV = getLV();
1132
+ if (!LV || typeof expr !== 'string') return expr;
1133
+
1134
+ return LV.computed(() => resolveExpression(expr, context));
1135
+ };
1136
+
1137
+
1138
+
1139
+ /**
1140
+ * Parses CDOMC (Concise CDOM) content into a JSON object.
1141
+ * Supports unquoted keys/values and strictly avoids 'eval'.
1142
+ */
1143
+ export const parseCDOMC = (input) => {
1144
+ let i = 0;
1145
+ const len = input.length;
1146
+
1147
+ const skipWhitespace = () => {
1148
+ while (i < len) {
1149
+ const char = input[i];
1150
+
1151
+ // Standard whitespace
1152
+ if (/\s/.test(char)) {
1153
+ i++;
1154
+ continue;
1155
+ }
1156
+
1157
+ // Comments
1158
+ if (char === '/') {
1159
+ const next = input[i + 1];
1160
+ if (next === '/') {
1161
+ // Single-line comment
1162
+ i += 2;
1163
+ while (i < len && input[i] !== '\n' && input[i] !== '\r') i++;
1164
+ continue;
1165
+ } else if (next === '*') {
1166
+ // Multi-line comment (non-nested)
1167
+ i += 2;
1168
+ while (i < len) {
1169
+ if (input[i] === '*' && input[i + 1] === '/') {
1170
+ i += 2;
1171
+ break;
1172
+ }
1173
+ i++;
1174
+ }
1175
+ continue;
1176
+ }
1177
+ }
1178
+
1179
+ break;
1180
+ }
1181
+ };
1182
+
1183
+ const parseString = () => {
1184
+ const quote = input[i++];
1185
+ let res = '';
1186
+ while (i < len) {
1187
+ const char = input[i++];
1188
+ if (char === quote) return res;
1189
+ if (char === '\\') {
1190
+ const next = input[i++];
1191
+ if (next === 'n') res += '\n';
1192
+ else if (next === 't') res += '\t';
1193
+ else if (next === '"') res += '"';
1194
+ else if (next === "'") res += "'";
1195
+ else if (next === '\\') res += '\\';
1196
+ else res += next;
1197
+ } else {
1198
+ res += char;
1199
+ }
1200
+ }
1201
+ throw new Error("Unterminated string");
1202
+ };
1203
+
1204
+ /**
1205
+ * Parses an unquoted word (identifier, path, or literal).
1206
+ * Supports dashes in identifiers (e.g. cdom-state).
1207
+ * Words starting with $ are preserved as strings for cDOM expression parsing.
1208
+ */
1209
+ const parseWord = () => {
1210
+ const start = i;
1211
+ let pDepth = 0;
1212
+ let bDepth = 0;
1213
+ let brDepth = 0;
1214
+ let quote = null;
1215
+
1216
+ while (i < len) {
1217
+ const char = input[i];
1218
+
1219
+ if (quote) {
1220
+ if (char === quote) quote = null;
1221
+ i++;
1222
+ continue;
1223
+ } else if (char === '"' || char === "'" || char === "`") {
1224
+ quote = char;
1225
+ i++;
1226
+ continue;
1227
+ }
1228
+
1229
+ // Nesting
1230
+ if (char === '(') { pDepth++; i++; continue; }
1231
+ if (char === '{') { bDepth++; i++; continue; }
1232
+ if (char === '[') { brDepth++; i++; continue; }
1233
+
1234
+ if (char === ')') { if (pDepth > 0) { pDepth--; i++; continue; } }
1235
+ if (char === '}') { if (bDepth > 0) { bDepth--; i++; continue; } }
1236
+ if (char === ']') { if (brDepth > 0) { brDepth--; i++; continue; } }
1237
+
1238
+ // Termination at depth 0
1239
+ if (pDepth === 0 && bDepth === 0 && brDepth === 0) {
1240
+ if (/[\s:,{}\[\]"'`()]/.test(char)) {
1241
+ break;
1242
+ }
1243
+ }
1244
+
1245
+ i++;
1246
+ }
1247
+
1248
+ const word = input.slice(start, i);
1249
+
1250
+ // If word starts with $, preserve it as a string for cDOM expression parsing
1251
+ if (word.startsWith('$')) {
1252
+ return word;
1253
+ }
1254
+
1255
+ if (word === 'true') return true;
1256
+ if (word === 'false') return false;
1257
+ if (word === 'null') return null;
1258
+ // Check if valid number
1259
+ if (word.trim() !== '' && !isNaN(Number(word))) return Number(word);
1260
+ return word;
1261
+ };
1262
+
1263
+ const parseValue = () => {
1264
+ skipWhitespace();
1265
+ if (i >= len) return undefined;
1266
+ const char = input[i];
1267
+
1268
+ if (char === '{') return parseObject();
1269
+ if (char === '[') return parseArray();
1270
+ if (char === '"' || char === "'") return parseString();
1271
+
1272
+ return parseWord();
1273
+ };
1274
+
1275
+ const parseObject = () => {
1276
+ i++; // skip '{'
1277
+ const obj = {};
1278
+ skipWhitespace();
1279
+ if (i < len && input[i] === '}') {
1280
+ i++;
1281
+ return obj;
1282
+ }
1283
+
1284
+ while (i < len) {
1285
+ skipWhitespace();
1286
+ let key;
1287
+ if (input[i] === '"' || input[i] === "'") key = parseString();
1288
+ else key = parseWord(); // No longer need special key handling
1289
+
1290
+ skipWhitespace();
1291
+ if (input[i] !== ':') throw new Error(`Expected ':' at position ${i}, found '${input[i]}'`);
1292
+ i++; // skip ':'
1293
+
1294
+ const value = parseValue();
1295
+ obj[String(key)] = value;
1296
+
1297
+ skipWhitespace();
1298
+ if (input[i] === '}') {
1299
+ i++;
1300
+ return obj;
1301
+ }
1302
+ if (input[i] === ',') {
1303
+ i++;
1304
+ skipWhitespace();
1305
+ if (input[i] === '}') {
1306
+ i++;
1307
+ return obj;
1308
+ }
1309
+ continue;
1310
+ }
1311
+ throw new Error(`Expected '}' or ',' at position ${i}, found '${input[i]}'`);
1312
+ }
1313
+ };
1314
+
1315
+ const parseArray = () => {
1316
+ i++; // skip '['
1317
+ const arr = [];
1318
+ skipWhitespace();
1319
+ if (i < len && input[i] === ']') {
1320
+ i++;
1321
+ return arr;
1322
+ }
1323
+
1324
+ while (i < len) {
1325
+ const val = parseValue();
1326
+ arr.push(val);
1327
+
1328
+ skipWhitespace();
1329
+ if (input[i] === ']') {
1330
+ i++;
1331
+ return arr;
1332
+ }
1333
+ if (input[i] === ',') {
1334
+ i++;
1335
+ skipWhitespace();
1336
+ if (input[i] === ']') {
1337
+ i++;
1338
+ return arr;
1339
+ }
1340
+ continue;
1341
+ }
1342
+ throw new Error(`Expected ']' or ',' at position ${i}, found '${input[i]}'`);
1343
+ }
1344
+ };
1345
+
1346
+ skipWhitespace();
1347
+ const res = parseValue();
1348
+ return res;
1349
+ };
1350
+
1351
+ /**
1352
+ * JPRXC Preprocessor: Converts concise JPRX format to valid JSON.
1353
+ *
1354
+ * JPRXC allows:
1355
+ * - Unquoted property names (tag, children, onclick)
1356
+ * - Unquoted JPRX expressions ($++/counter, $/path)
1357
+ * - Single-line and multi-line comments
1358
+ *
1359
+ * This preprocessor transforms JPRXC to JSON string, then uses native JSON.parse.
1360
+ *
1361
+ * @param {string} input - JPRXC content
1362
+ * @returns {object} - Parsed JSON object
1363
+ */
1364
+ export const parseJPRX = (input) => {
1365
+ let result = '';
1366
+ let i = 0;
1367
+ const len = input.length;
1368
+
1369
+ while (i < len) {
1370
+ const char = input[i];
1371
+
1372
+ // Handle // single-line comments
1373
+ if (char === '/' && input[i + 1] === '/') {
1374
+ while (i < len && input[i] !== '\n') i++;
1375
+ continue;
1376
+ }
1377
+
1378
+ // Handle /* multi-line comments */
1379
+ if (char === '/' && input[i + 1] === '*') {
1380
+ i += 2;
1381
+ while (i < len && !(input[i] === '*' && input[i + 1] === '/')) i++;
1382
+ i += 2; // skip */
1383
+ continue;
1384
+ }
1385
+
1386
+ // Handle quoted strings
1387
+ if (char === '"' || char === "'") {
1388
+ const quote = char;
1389
+ result += '"'; // Start double quoted string
1390
+ i++; // skip opening quote
1391
+
1392
+ while (i < len && input[i] !== quote) {
1393
+ const c = input[i];
1394
+ if (c === '\\') {
1395
+ // Handle existing specific escapes
1396
+ result += '\\'; // Preserved backslash
1397
+ i++;
1398
+ if (i < len) {
1399
+ const next = input[i];
1400
+ // If it's a quote that matches our output quote ("), we need to ensure it's escaped
1401
+ if (next === '"') result += '\\"';
1402
+ else result += next;
1403
+ i++;
1404
+ }
1405
+ } else if (c === '"') {
1406
+ result += '\\"'; // Escape double quotes since we're outputting "
1407
+ i++;
1408
+ } else if (c === '\n') {
1409
+ result += '\\n';
1410
+ i++;
1411
+ } else if (c === '\r') {
1412
+ result += '\\r';
1413
+ i++;
1414
+ } else if (c === '\t') {
1415
+ result += '\\t';
1416
+ i++;
1417
+ } else {
1418
+ result += c;
1419
+ i++;
1420
+ }
1421
+ }
1422
+ result += '"'; // End double quoted string
1423
+ i++; // skip closing quote
1424
+ continue;
1425
+ }
1426
+
1427
+ // Handle JPRX expressions starting with $ (MUST come before word handler!)
1428
+ if (char === '$') {
1429
+ let expr = '';
1430
+ let parenDepth = 0;
1431
+ let braceDepth = 0;
1432
+ let bracketDepth = 0;
1433
+ let inExprQuote = null;
1434
+
1435
+ while (i < len) {
1436
+ const c = input[i];
1437
+
1438
+ if (inExprQuote) {
1439
+ if (c === inExprQuote && input[i - 1] !== '\\') inExprQuote = null;
1440
+ } else if (c === '"' || c === "'") {
1441
+ inExprQuote = c;
1442
+ } else {
1443
+ // Check for break BEFORE updating depth
1444
+ if (parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) {
1445
+ if (/[\s,}\]:]/.test(c) && expr.length > 1) break;
1446
+ }
1447
+
1448
+ if (c === '(') parenDepth++;
1449
+ else if (c === ')') parenDepth--;
1450
+ else if (c === '{') braceDepth++;
1451
+ else if (c === '}') braceDepth--;
1452
+ else if (c === '[') bracketDepth++;
1453
+ else if (c === ']') bracketDepth--;
1454
+ }
1455
+
1456
+ expr += c;
1457
+ i++;
1458
+ }
1459
+
1460
+ // Use JSON.stringify to safely quote and escape the expression
1461
+ result += JSON.stringify(expr);
1462
+ continue;
1463
+ }
1464
+
1465
+ // Handle unquoted property names, identifiers, and paths
1466
+ if (/[a-zA-Z_./]/.test(char)) {
1467
+ let word = '';
1468
+ while (i < len && /[a-zA-Z0-9_$/.-]/.test(input[i])) {
1469
+ word += input[i];
1470
+ i++;
1471
+ }
1472
+
1473
+ // Skip whitespace to check for :
1474
+ let j = i;
1475
+ while (j < len && /\s/.test(input[j])) j++;
1476
+
1477
+ if (input[j] === ':') {
1478
+ // It's a property name - quote it
1479
+ result += `"${word}"`;
1480
+ } else {
1481
+ // It's a value - check if it's a keyword
1482
+ if (word === 'true' || word === 'false' || word === 'null') {
1483
+ result += word;
1484
+ } else if (!isNaN(Number(word))) {
1485
+ result += word;
1486
+ } else {
1487
+ // Quote as string value
1488
+ result += `"${word}"`;
1489
+ }
1490
+ }
1491
+ continue;
1492
+ }
1493
+
1494
+ // Handle numbers
1495
+ if (/[\d]/.test(char) || (char === '-' && /\d/.test(input[i + 1]))) {
1496
+ let num = '';
1497
+ while (i < len && /[\d.\-eE]/.test(input[i])) {
1498
+ num += input[i];
1499
+ i++;
1500
+ }
1501
+ result += num;
1502
+ continue;
1503
+ }
1504
+
1505
+ // Pass through structural characters and whitespace
1506
+ result += char;
1507
+ i++;
1508
+ }
1509
+
1510
+ try {
1511
+ return JSON.parse(result);
1512
+ } catch (e) {
1513
+ globalThis.console?.error('parseJPRX: JSON parse failed', e);
1514
+ globalThis.console?.error('Transformed input:', result);
1515
+ throw e;
1516
+ }
1517
+ };