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 +1 -1
- package/helpers/calc.js +82 -0
- package/helpers/dom.js +69 -0
- package/helpers/logic.js +9 -3
- package/helpers/lookup.js +39 -0
- package/helpers/math.js +4 -0
- package/index.js +4 -0
- package/package.json +2 -2
- package/parser.js +394 -83
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# JPRX (JSON Reactive
|
|
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
|
|
package/helpers/calc.js
ADDED
|
@@ -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
|
|
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/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.
|
|
4
|
-
"description": "JSON Reactive
|
|
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
|
|
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
|
|
|
@@ -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
|
|
506
|
+
let matchedPrefix = null;
|
|
491
507
|
for (const op of prefixOps) {
|
|
492
508
|
if (expr.slice(i + 1, i + 1 + op.length) === op) {
|
|
493
|
-
|
|
509
|
+
matchedPrefix = op;
|
|
494
510
|
break;
|
|
495
511
|
}
|
|
496
512
|
}
|
|
497
|
-
if (
|
|
498
|
-
// 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)) {
|
|
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
|
|
533
|
-
const
|
|
534
|
-
const
|
|
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
|
-
//
|
|
537
|
-
//
|
|
538
|
-
|
|
539
|
-
|
|
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
|
|
662
|
-
const
|
|
663
|
-
const
|
|
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 (
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
//
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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+([
|
|
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 '
|
|
978
|
-
const
|
|
979
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1194
|
+
|
|
1025
1195
|
const left = evaluateAST(ast.left, context, opts.pathAware);
|
|
1026
1196
|
const right = evaluateAST(ast.right, context, false);
|
|
1027
1197
|
|
|
1028
|
-
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1291
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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') {
|