jprx 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -21
- package/helpers/calc.js +82 -0
- package/helpers/lookup.js +39 -0
- package/helpers/math.js +4 -0
- package/helpers/state.js +6 -6
- package/index.js +3 -0
- package/package.json +2 -2
- package/parser.js +72 -42
- package/specs/expressions.json +6 -6
- package/specs/helpers.json +24 -24
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
|
|
|
@@ -26,16 +26,18 @@ JPRX extends the base JSON Pointer syntax with:
|
|
|
26
26
|
|
|
27
27
|
| Feature | Syntax | Description |
|
|
28
28
|
|---------|--------|-------------|
|
|
29
|
-
| **Global Path** |
|
|
29
|
+
| **Global Path** | `=/user/name` | Access global state via an absolute path. |
|
|
30
30
|
| **Relative Path** | `./count` | Access properties relative to the current context. |
|
|
31
31
|
| **Parent Path** | `../id` | Traverse up the state hierarchy (UP-tree search). |
|
|
32
|
-
| **Functions** |
|
|
32
|
+
| **Functions** | `=sum(/items...price)` | Call registered core helpers. |
|
|
33
33
|
| **Explosion** | `/items...name` | Extract a property from every object in an array (spread). |
|
|
34
|
-
| **Operators** |
|
|
34
|
+
| **Operators** | `=++/count`, `=/a + =/b` | Familiar JS-style prefix, postfix, and infix operators. |
|
|
35
35
|
| **Placeholders** | `_` (item), `$this`, `$event` | Context-aware placeholders for iteration and interaction. |
|
|
36
|
-
| **Two-Way Binding**|
|
|
36
|
+
| **Two-Way Binding**| `=bind(/user/name)`| Create a managed, two-way reactive link for inputs. |
|
|
37
|
+
| **DOM Patches** | `=move(target, loc)`| Decentralized layout: Move/replace host element into a target. |
|
|
38
|
+
|
|
39
|
+
Once inside a JPRX expression, the `=` prefix is only needed at the start of the expression for paths or function names.
|
|
37
40
|
|
|
38
|
-
Once inside a JPRX expression, the `$` prefix is only needed at the start of the expression for paths or function names.
|
|
39
41
|
|
|
40
42
|
## State Management
|
|
41
43
|
|
|
@@ -46,9 +48,9 @@ States can be attached to specific scopes (such as a DOM node or Component insta
|
|
|
46
48
|
- **Up-tree Resolution**: When resolving a path, JPRX walks up the provided scope chain looking for the nearest owner of that name.
|
|
47
49
|
- **Future Signals**: JPRX allows subscription to a named signal *before* it is initialized. The system will automatically "hook up" once the state is created via `$state` or `$signal`.
|
|
48
50
|
|
|
49
|
-
### The
|
|
50
|
-
-
|
|
51
|
-
-
|
|
51
|
+
### The `=state` and `=signal` Helpers
|
|
52
|
+
- `=state(value, { name: 'user', schema: 'UserProfile', scope: event.target })`
|
|
53
|
+
- `=signal(0, { name: 'count', schema: 'auto' })`
|
|
52
54
|
|
|
53
55
|
## Schema Registry & Validation
|
|
54
56
|
|
|
@@ -64,10 +66,10 @@ jprx.registerSchema('UserProfile', {
|
|
|
64
66
|
});
|
|
65
67
|
|
|
66
68
|
// 2. Reference the registered schema by name (Scoped)
|
|
67
|
-
const user =
|
|
69
|
+
const user = =state({}, { name: 'user', schema: 'UserProfile', scope: $this });
|
|
68
70
|
|
|
69
71
|
// 3. Use the 'polymorphic' shorthand for auto-coercion
|
|
70
|
-
const settings =
|
|
72
|
+
const settings = =state({ volume: 50 }, { name: 'settings', schema: 'polymorphic' });
|
|
71
73
|
// Result: settings.volume = "60" will automatically coerce to the number 60.
|
|
72
74
|
```
|
|
73
75
|
|
|
@@ -91,19 +93,59 @@ Schemas can define transformations that occur during state updates, ensuring dat
|
|
|
91
93
|
}
|
|
92
94
|
}
|
|
93
95
|
```
|
|
94
|
-
*Note: The
|
|
96
|
+
*Note: The `=bind` helper uses these transformations to automatically clean data as the user types.*
|
|
95
97
|
|
|
96
|
-
## Two-Way Binding with
|
|
98
|
+
## Two-Way Binding with `=bind`
|
|
97
99
|
|
|
98
|
-
The
|
|
100
|
+
The `=bind(path)` helper creates a managed, two-way link between the UI and a state path.
|
|
99
101
|
|
|
100
102
|
### Strictness
|
|
101
|
-
To ensure unambiguous data flow,
|
|
103
|
+
To ensure unambiguous data flow, `=bind` only accepts direct paths. It cannot be used directly with computed expressions like `=bind(upper(/name))`.
|
|
102
104
|
|
|
103
105
|
### Handling Transformations
|
|
104
106
|
If you need to transform data during a two-way binding, there are two primary approaches:
|
|
105
|
-
1. **Event-Based**: Use a manual `oninput` handler to apply the transformation, e.g.,
|
|
106
|
-
2. **Schema-Based**: Define a `transform` or `pattern` in the schema for the path. The
|
|
107
|
+
1. **Event-Based**: Use a manual `oninput` handler to apply the transformation, e.g., `=set(/name, upper($event/target/value))`.
|
|
108
|
+
2. **Schema-Based**: Define a `transform` or `pattern` in the schema for the path. The `=bind` helper will respect the schema rules during the write-back phase.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## DOM Patches & Decentralized Layouts
|
|
113
|
+
|
|
114
|
+
One of the most powerful features of JPRX in UI environments (like Lightview) is the ability to perform **Decentralized DOM Patches** via the `=move(target, location)` helper.
|
|
115
|
+
|
|
116
|
+
### The Problem: LLM Streaming & Layouts
|
|
117
|
+
When an LLM streams UI components, it often knows *what* it is creating before it knows exactly *where* that item belongs in a complex dashboard, or it may need to update an existing element that was created minutes ago.
|
|
118
|
+
|
|
119
|
+
### The Solution: `=move`
|
|
120
|
+
The `=move` helper allows a component to "place itself" into the document upon mounting.
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"tag": "div",
|
|
125
|
+
"id": "weather-widget",
|
|
126
|
+
"onmount": "=move('#dashboard-sidebar', 'afterbegin')",
|
|
127
|
+
"content": "Sunny, 75°F"
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Key Behaviors:**
|
|
132
|
+
1. **Teleportation**: The host element is physically moved from the "Stream Container" (where it was born) to the specified `target` (e.g., `#dashboard-sidebar`).
|
|
133
|
+
2. **Idempotent Updates**: If the moving element has an `id` (e.g., `weather-widget`) and an element with that same ID already exists at the destination, the existing element is **replaced**. This allows the LLM to "patch" the UI simply by streaming the updated version of the component.
|
|
134
|
+
3. **Flicker-Free**: By rendering the stream in a hidden container, the element is moved and appears in its final destination instantly.
|
|
135
|
+
|
|
136
|
+
### The Delivery Vehicle: `=mount`
|
|
137
|
+
The `=mount(url, options?)` helper is the primary mechanism for fetching these decentralized updates.
|
|
138
|
+
|
|
139
|
+
1. **Arrival**: `=mount` fetches the content and "lands" it at the end of the `document.body` (by default).
|
|
140
|
+
2. **Mounting**: The content is hydrated and added to the DOM, triggering its `onmount` hook.
|
|
141
|
+
3. **Teleportation**: If the content contains `=move`, it immediately relocates itself to its destination.
|
|
142
|
+
|
|
143
|
+
This separation of concerns makes the system incredibly robust: `=mount` handles the **delivery**, while `=move` handles the **logic** of where the content belongs.
|
|
144
|
+
|
|
145
|
+
### Why Decentralized?
|
|
146
|
+
- **Low Overhead**: The LLM doesn't need to maintain a map of the entire DOM; it just needs to know the ID or Selector of where it wants to "push" its content.
|
|
147
|
+
- **Independence**: Components are self-contained. A "Notification" component knows how to display itself AND where notification stacks live.
|
|
148
|
+
- **Context Preservation**: In Lightview, moved elements retain their original reactive context ("backpack"), allowing them to stay linked to the stream that created them.
|
|
107
149
|
|
|
108
150
|
---
|
|
109
151
|
|
|
@@ -114,12 +156,12 @@ A modern, lifecycle-based reactive counter:
|
|
|
114
156
|
```json
|
|
115
157
|
{
|
|
116
158
|
"div": {
|
|
117
|
-
"onmount":
|
|
159
|
+
"onmount": "=state({ count: 0 }, { name: 'counter', schema: 'auto', scope: $this })",
|
|
118
160
|
"children": [
|
|
119
161
|
{ "h2": "Modern JPRX Counter" },
|
|
120
|
-
{ "p": ["Current Count: ",
|
|
121
|
-
{ "button": { "onclick":
|
|
122
|
-
{ "button": { "onclick":
|
|
162
|
+
{ "p": ["Current Count: ", "=/counter/count"] },
|
|
163
|
+
{ "button": { "onclick": "=++/counter/count", "children": ["+"] } },
|
|
164
|
+
{ "button": { "onclick": "=--/counter/count", "children": ["-"] } }
|
|
123
165
|
]
|
|
124
166
|
}
|
|
125
167
|
}
|
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/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/helpers/state.js
CHANGED
|
@@ -64,7 +64,7 @@ export const clear = (target) => {
|
|
|
64
64
|
return set(target, null);
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
-
export function
|
|
67
|
+
export function state(val, options) {
|
|
68
68
|
if (globalThis.Lightview) {
|
|
69
69
|
const finalOptions = typeof options === 'string' ? { name: options } : options;
|
|
70
70
|
return globalThis.Lightview.state(val, finalOptions);
|
|
@@ -72,7 +72,7 @@ export function $state(val, options) {
|
|
|
72
72
|
throw new Error('JPRX: $state requires a UI library implementation.');
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export function
|
|
75
|
+
export function signal(val, options) {
|
|
76
76
|
if (globalThis.Lightview) {
|
|
77
77
|
const finalOptions = typeof options === 'string' ? { name: options } : options;
|
|
78
78
|
return globalThis.Lightview.signal(val, finalOptions);
|
|
@@ -80,7 +80,7 @@ export function $signal(val, options) {
|
|
|
80
80
|
throw new Error('JPRX: $signal requires a UI library implementation.');
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export const
|
|
83
|
+
export const bind = (path, options) => ({ __JPRX_BIND__: true, path, options });
|
|
84
84
|
|
|
85
85
|
export const registerStateHelpers = (register) => {
|
|
86
86
|
const opts = { pathAware: true };
|
|
@@ -95,7 +95,7 @@ export const registerStateHelpers = (register) => {
|
|
|
95
95
|
register('pop', pop, opts);
|
|
96
96
|
register('assign', assign, opts);
|
|
97
97
|
register('clear', clear, opts);
|
|
98
|
-
register('state',
|
|
99
|
-
register('signal',
|
|
100
|
-
register('bind',
|
|
98
|
+
register('state', state);
|
|
99
|
+
register('signal', signal);
|
|
100
|
+
register('bind', bind);
|
|
101
101
|
};
|
package/index.js
CHANGED
|
@@ -38,6 +38,7 @@ 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';
|
|
41
42
|
|
|
42
43
|
// Convenience function to register all standard helpers
|
|
43
44
|
export const registerAllHelpers = (registerFn) => {
|
|
@@ -53,6 +54,7 @@ export const registerAllHelpers = (registerFn) => {
|
|
|
53
54
|
const { registerStatsHelpers } = require('./helpers/stats.js');
|
|
54
55
|
const { registerStateHelpers } = require('./helpers/state.js');
|
|
55
56
|
const { registerNetworkHelpers } = require('./helpers/network.js');
|
|
57
|
+
const { registerCalcHelpers } = require('./helpers/calc.js');
|
|
56
58
|
|
|
57
59
|
registerMathHelpers(registerFn);
|
|
58
60
|
registerLogicHelpers(registerFn);
|
|
@@ -66,4 +68,5 @@ export const registerAllHelpers = (registerFn) => {
|
|
|
66
68
|
registerStatsHelpers(registerFn);
|
|
67
69
|
registerStateHelpers((name, fn) => registerFn(name, fn, { pathAware: true }));
|
|
68
70
|
registerNetworkHelpers(registerFn);
|
|
71
|
+
registerCalcHelpers(registerFn);
|
|
69
72
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jprx",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "JSON Reactive
|
|
3
|
+
"version": "1.2.1",
|
|
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
|
@@ -136,8 +136,8 @@ export const resolvePath = (path, context) => {
|
|
|
136
136
|
// Current context: .
|
|
137
137
|
if (path === '.') return unwrapSignal(context);
|
|
138
138
|
|
|
139
|
-
// Global absolute path:
|
|
140
|
-
if (path.startsWith('
|
|
139
|
+
// Global absolute path: =/something
|
|
140
|
+
if (path.startsWith('=/')) {
|
|
141
141
|
const [rootName, ...rest] = path.slice(2).split('/');
|
|
142
142
|
const LV = getLV();
|
|
143
143
|
const root = LV ? LV.get(rootName, { scope: context?.__node__ || context }) : registry?.get(rootName);
|
|
@@ -164,7 +164,6 @@ export const resolvePath = (path, context) => {
|
|
|
164
164
|
const unwrappedContext = unwrapSignal(context);
|
|
165
165
|
if (unwrappedContext && typeof unwrappedContext === 'object') {
|
|
166
166
|
if (path in unwrappedContext || unwrappedContext[path] !== undefined) {
|
|
167
|
-
// Use traverse with one segment to ensure signal unwrapping if context[path] is a signal
|
|
168
167
|
return traverse(unwrappedContext, [path]);
|
|
169
168
|
}
|
|
170
169
|
}
|
|
@@ -184,8 +183,8 @@ export const resolvePathAsContext = (path, context) => {
|
|
|
184
183
|
// Current context: .
|
|
185
184
|
if (path === '.') return context;
|
|
186
185
|
|
|
187
|
-
// Global absolute path:
|
|
188
|
-
if (path.startsWith('
|
|
186
|
+
// Global absolute path: =/something
|
|
187
|
+
if (path.startsWith('=/')) {
|
|
189
188
|
const segments = path.slice(2).split(/[/.]/);
|
|
190
189
|
const rootName = segments.shift();
|
|
191
190
|
const LV = getLV();
|
|
@@ -304,10 +303,9 @@ const resolveArgument = (arg, context, globalMode = false) => {
|
|
|
304
303
|
try {
|
|
305
304
|
const data = parseJPRX(arg);
|
|
306
305
|
|
|
307
|
-
// Define a recursive resolver for template objects
|
|
308
306
|
const resolveTemplate = (node, context) => {
|
|
309
307
|
if (typeof node === 'string') {
|
|
310
|
-
if (node.startsWith('
|
|
308
|
+
if (node.startsWith('=')) {
|
|
311
309
|
const res = resolveExpression(node, context);
|
|
312
310
|
const final = (res instanceof LazyValue) ? res.resolve(context) : res;
|
|
313
311
|
return unwrapSignal(final);
|
|
@@ -340,10 +338,9 @@ const resolveArgument = (arg, context, globalMode = false) => {
|
|
|
340
338
|
return node;
|
|
341
339
|
};
|
|
342
340
|
|
|
343
|
-
// Check if it contains any reactive parts
|
|
344
341
|
const hasReactive = (obj) => {
|
|
345
342
|
if (typeof obj === 'string') {
|
|
346
|
-
return obj.startsWith('
|
|
343
|
+
return obj.startsWith('=') || obj.startsWith('_') || obj.startsWith('../');
|
|
347
344
|
}
|
|
348
345
|
if (Array.isArray(obj)) return obj.some(hasReactive);
|
|
349
346
|
if (obj && typeof obj === 'object') return Object.values(obj).some(hasReactive);
|
|
@@ -366,9 +363,9 @@ const resolveArgument = (arg, context, globalMode = false) => {
|
|
|
366
363
|
if (arg.includes('(')) {
|
|
367
364
|
let nestedExpr = arg;
|
|
368
365
|
if (arg.startsWith('/')) {
|
|
369
|
-
nestedExpr = '
|
|
370
|
-
} else if (globalMode && !arg.startsWith('
|
|
371
|
-
nestedExpr =
|
|
366
|
+
nestedExpr = '=' + arg;
|
|
367
|
+
} else if (globalMode && !arg.startsWith('=') && !arg.startsWith('./')) {
|
|
368
|
+
nestedExpr = `=/${arg}`;
|
|
372
369
|
}
|
|
373
370
|
|
|
374
371
|
const val = resolveExpression(nestedExpr, context);
|
|
@@ -381,11 +378,11 @@ const resolveArgument = (arg, context, globalMode = false) => {
|
|
|
381
378
|
// 8. Path normalization
|
|
382
379
|
let normalizedPath;
|
|
383
380
|
if (arg.startsWith('/')) {
|
|
384
|
-
normalizedPath = '
|
|
385
|
-
} else if (arg.startsWith('
|
|
381
|
+
normalizedPath = '=' + arg;
|
|
382
|
+
} else if (arg.startsWith('=') || arg.startsWith('./') || arg.startsWith('../')) {
|
|
386
383
|
normalizedPath = arg;
|
|
387
384
|
} else if (globalMode) {
|
|
388
|
-
normalizedPath =
|
|
385
|
+
normalizedPath = `=/${arg}`;
|
|
389
386
|
} else {
|
|
390
387
|
normalizedPath = `./${arg}`;
|
|
391
388
|
}
|
|
@@ -483,10 +480,10 @@ const tokenize = (expr) => {
|
|
|
483
480
|
continue;
|
|
484
481
|
}
|
|
485
482
|
|
|
486
|
-
// Special:
|
|
487
|
-
// In expressions like "
|
|
483
|
+
// Special: = followed immediately by an operator symbol
|
|
484
|
+
// In expressions like "=++/count", the = is just the JPRX delimiter
|
|
488
485
|
// and ++ is a prefix operator applied to /count
|
|
489
|
-
if (expr[i] === '
|
|
486
|
+
if (expr[i] === '=' && i + 1 < len) {
|
|
490
487
|
// Check if next chars are a PREFIX operator (sort by length to match longest first)
|
|
491
488
|
const prefixOps = [...operators.prefix.keys()].sort((a, b) => b.length - a.length);
|
|
492
489
|
let isPrefixOp = false;
|
|
@@ -497,7 +494,7 @@ const tokenize = (expr) => {
|
|
|
497
494
|
}
|
|
498
495
|
}
|
|
499
496
|
if (isPrefixOp) {
|
|
500
|
-
// Skip the
|
|
497
|
+
// Skip the =, it's just a delimiter for a prefix operator (e.g., =++/count)
|
|
501
498
|
i++;
|
|
502
499
|
continue;
|
|
503
500
|
}
|
|
@@ -554,7 +551,7 @@ const tokenize = (expr) => {
|
|
|
554
551
|
tokens[tokens.length - 1].type === TokenType.LPAREN ||
|
|
555
552
|
tokens[tokens.length - 1].type === TokenType.COMMA ||
|
|
556
553
|
tokens[tokens.length - 1].type === TokenType.OPERATOR;
|
|
557
|
-
const validAfter = /[\s(
|
|
554
|
+
const validAfter = /[\s(=./'"0-9_]/.test(after) ||
|
|
558
555
|
i + op.length >= len ||
|
|
559
556
|
opSymbols.some(o => expr.slice(i + op.length).startsWith(o));
|
|
560
557
|
|
|
@@ -651,8 +648,8 @@ const tokenize = (expr) => {
|
|
|
651
648
|
continue;
|
|
652
649
|
}
|
|
653
650
|
|
|
654
|
-
// Paths: start with
|
|
655
|
-
if (expr[i] === '
|
|
651
|
+
// Paths: start with =, ., or /
|
|
652
|
+
if (expr[i] === '=' || expr[i] === '.' || expr[i] === '/') {
|
|
656
653
|
let path = '';
|
|
657
654
|
// Consume the path, but stop at operators
|
|
658
655
|
while (i < len) {
|
|
@@ -734,9 +731,9 @@ const tokenize = (expr) => {
|
|
|
734
731
|
* Used to determine whether to use Pratt parser or legacy parser.
|
|
735
732
|
*
|
|
736
733
|
* CONSERVATIVE: Only detect explicit patterns to avoid false positives.
|
|
737
|
-
* - Prefix:
|
|
738
|
-
* - Postfix:
|
|
739
|
-
* - Infix with spaces:
|
|
734
|
+
* - Prefix: =++/path, =--/path, =!!/path (operator immediately after = before path)
|
|
735
|
+
* - Postfix: =/path++ or =/path-- (operator at end of expression, not followed by ()
|
|
736
|
+
* - Infix with spaces: =/path + =/other (spaces around operator)
|
|
740
737
|
*/
|
|
741
738
|
const hasOperatorSyntax = (expr) => {
|
|
742
739
|
if (!expr || typeof expr !== 'string') return false;
|
|
@@ -744,19 +741,19 @@ const hasOperatorSyntax = (expr) => {
|
|
|
744
741
|
// Skip function calls - they use legacy parser
|
|
745
742
|
if (expr.includes('(')) return false;
|
|
746
743
|
|
|
747
|
-
// Check for prefix operator pattern:
|
|
748
|
-
// This catches:
|
|
749
|
-
if (
|
|
744
|
+
// Check for prefix operator pattern: =++ or =-- followed by /
|
|
745
|
+
// This catches: =++/counter, =--/value
|
|
746
|
+
if (/^=(\+\+|--|!!)\/?/.test(expr)) {
|
|
750
747
|
return true;
|
|
751
748
|
}
|
|
752
749
|
|
|
753
750
|
// Check for postfix operator pattern: path ending with ++ or --
|
|
754
|
-
// This catches:
|
|
751
|
+
// This catches: =/counter++, =/value--
|
|
755
752
|
if (/(\+\+|--)$/.test(expr)) {
|
|
756
753
|
return true;
|
|
757
754
|
}
|
|
758
755
|
|
|
759
|
-
// Check for infix with explicit whitespace:
|
|
756
|
+
// Check for infix with explicit whitespace: =/a + =/b
|
|
760
757
|
// The spaces make it unambiguous that the symbol is an operator, not part of a path
|
|
761
758
|
if (/\s+([+\-*/]|>|<|>=|<=|!=)\s+/.test(expr)) {
|
|
762
759
|
return true;
|
|
@@ -1084,19 +1081,19 @@ export const resolveExpression = (expr, context) => {
|
|
|
1084
1081
|
const argsStr = expr.slice(funcStart + 1, -1);
|
|
1085
1082
|
|
|
1086
1083
|
const segments = fullPath.split('/');
|
|
1087
|
-
let funcName = segments.pop().replace(
|
|
1084
|
+
let funcName = segments.pop().replace(/^=/, '');
|
|
1088
1085
|
|
|
1089
|
-
// Handle case where path ends in / (like
|
|
1086
|
+
// Handle case where path ends in / (like =/ for division helper)
|
|
1090
1087
|
if (funcName === '' && (segments.length > 0 || fullPath === '/')) {
|
|
1091
1088
|
funcName = '/';
|
|
1092
1089
|
}
|
|
1093
1090
|
|
|
1094
1091
|
const navPath = segments.join('/');
|
|
1095
1092
|
|
|
1096
|
-
const isGlobalExpr = expr.startsWith('
|
|
1093
|
+
const isGlobalExpr = expr.startsWith('=/') || expr.startsWith('=');
|
|
1097
1094
|
|
|
1098
1095
|
let baseContext = context;
|
|
1099
|
-
if (navPath && navPath !== '
|
|
1096
|
+
if (navPath && navPath !== '=') {
|
|
1100
1097
|
baseContext = resolvePathAsContext(navPath, context);
|
|
1101
1098
|
}
|
|
1102
1099
|
|
|
@@ -1134,7 +1131,7 @@ export const resolveExpression = (expr, context) => {
|
|
|
1134
1131
|
let hasLazy = false;
|
|
1135
1132
|
for (let i = 0; i < argsList.length; i++) {
|
|
1136
1133
|
const arg = argsList[i];
|
|
1137
|
-
const useGlobalMode = isGlobalExpr && (navPath === '
|
|
1134
|
+
const useGlobalMode = isGlobalExpr && (navPath === '=' || !navPath);
|
|
1138
1135
|
const res = resolveArgument(arg, baseContext, useGlobalMode);
|
|
1139
1136
|
|
|
1140
1137
|
if (res.isLazy) hasLazy = true;
|
|
@@ -1299,8 +1296,8 @@ export const parseCDOMC = (input) => {
|
|
|
1299
1296
|
|
|
1300
1297
|
const word = input.slice(start, i);
|
|
1301
1298
|
|
|
1302
|
-
// If word starts with
|
|
1303
|
-
if (word.startsWith('
|
|
1299
|
+
// If word starts with =, preserve it as a string for cDOM expression parsing
|
|
1300
|
+
if (word.startsWith('=')) {
|
|
1304
1301
|
return word;
|
|
1305
1302
|
}
|
|
1306
1303
|
|
|
@@ -1476,8 +1473,8 @@ export const parseJPRX = (input) => {
|
|
|
1476
1473
|
continue;
|
|
1477
1474
|
}
|
|
1478
1475
|
|
|
1479
|
-
// Handle JPRX expressions starting with
|
|
1480
|
-
if (char === '
|
|
1476
|
+
// Handle JPRX expressions starting with = (MUST come before word handler!)
|
|
1477
|
+
if (char === '=') {
|
|
1481
1478
|
let expr = '';
|
|
1482
1479
|
let parenDepth = 0;
|
|
1483
1480
|
let braceDepth = 0;
|
|
@@ -1514,21 +1511,54 @@ export const parseJPRX = (input) => {
|
|
|
1514
1511
|
continue;
|
|
1515
1512
|
}
|
|
1516
1513
|
|
|
1517
|
-
// Handle unquoted property names, identifiers, and
|
|
1518
|
-
if (/[a-zA-Z_
|
|
1514
|
+
// Handle unquoted property names, identifiers, paths, and FUNCTION CALLS
|
|
1515
|
+
if (/[a-zA-Z_$/./]/.test(char)) {
|
|
1519
1516
|
let word = '';
|
|
1520
1517
|
while (i < len && /[a-zA-Z0-9_$/.-]/.test(input[i])) {
|
|
1521
1518
|
word += input[i];
|
|
1522
1519
|
i++;
|
|
1523
1520
|
}
|
|
1524
1521
|
|
|
1525
|
-
// Skip whitespace to check
|
|
1522
|
+
// Skip whitespace to check what follows
|
|
1526
1523
|
let j = i;
|
|
1527
1524
|
while (j < len && /\s/.test(input[j])) j++;
|
|
1528
1525
|
|
|
1529
1526
|
if (input[j] === ':') {
|
|
1530
1527
|
// It's a property name - quote it
|
|
1531
1528
|
result += `"${word}"`;
|
|
1529
|
+
} else if (input[j] === '(') {
|
|
1530
|
+
// It's a FUNCTION CALL - capture the entire expression with balanced parens
|
|
1531
|
+
let expr = word;
|
|
1532
|
+
i = j; // move to the opening paren
|
|
1533
|
+
let parenDepth = 0;
|
|
1534
|
+
let inQuote = null;
|
|
1535
|
+
|
|
1536
|
+
while (i < len) {
|
|
1537
|
+
const c = input[i];
|
|
1538
|
+
|
|
1539
|
+
// Track quotes to avoid false paren matches inside strings
|
|
1540
|
+
if (inQuote) {
|
|
1541
|
+
if (c === inQuote && input[i - 1] !== '\\') inQuote = null;
|
|
1542
|
+
} else if (c === '"' || c === "'") {
|
|
1543
|
+
inQuote = c;
|
|
1544
|
+
} else {
|
|
1545
|
+
if (c === '(') parenDepth++;
|
|
1546
|
+
else if (c === ')') {
|
|
1547
|
+
parenDepth--;
|
|
1548
|
+
if (parenDepth === 0) {
|
|
1549
|
+
expr += c;
|
|
1550
|
+
i++;
|
|
1551
|
+
break;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
expr += c;
|
|
1557
|
+
i++;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Treat the function call as a JPRX expression by prefixing with =
|
|
1561
|
+
result += JSON.stringify('=' + expr);
|
|
1532
1562
|
} else {
|
|
1533
1563
|
// It's a value - check if it's a keyword
|
|
1534
1564
|
if (word === 'true' || word === 'false' || word === 'null') {
|
package/specs/expressions.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"state": {
|
|
5
5
|
"userName": "Alice"
|
|
6
6
|
},
|
|
7
|
-
"expression": "
|
|
7
|
+
"expression": "=/userName",
|
|
8
8
|
"expected": "Alice"
|
|
9
9
|
},
|
|
10
10
|
{
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
|
-
"expression": "
|
|
19
|
+
"expression": "=/user/profile/name",
|
|
20
20
|
"expected": "Bob"
|
|
21
21
|
},
|
|
22
22
|
{
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"a": 5,
|
|
34
34
|
"b": 10
|
|
35
35
|
},
|
|
36
|
-
"expression": "
|
|
36
|
+
"expression": "=+(/a, /b)",
|
|
37
37
|
"expected": 15
|
|
38
38
|
},
|
|
39
39
|
{
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"a": 5,
|
|
43
43
|
"b": 10
|
|
44
44
|
},
|
|
45
|
-
"expression": "
|
|
45
|
+
"expression": "=/a + =/b",
|
|
46
46
|
"expected": 15
|
|
47
47
|
},
|
|
48
48
|
{
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"state": {
|
|
51
51
|
"isVip": true
|
|
52
52
|
},
|
|
53
|
-
"expression": "
|
|
53
|
+
"expression": "=if(/isVip, \"Gold\", \"Silver\")",
|
|
54
54
|
"expected": "Gold"
|
|
55
55
|
},
|
|
56
56
|
{
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
}
|
|
66
66
|
]
|
|
67
67
|
},
|
|
68
|
-
"expression": "
|
|
68
|
+
"expression": "=sum(/items...p)",
|
|
69
69
|
"expected": 30
|
|
70
70
|
}
|
|
71
71
|
]
|
package/specs/helpers.json
CHANGED
|
@@ -1,102 +1,102 @@
|
|
|
1
1
|
[
|
|
2
2
|
{
|
|
3
3
|
"name": "Math: Subtraction",
|
|
4
|
-
"expression": "
|
|
4
|
+
"expression": "=-(10, 3)",
|
|
5
5
|
"expected": 7
|
|
6
6
|
},
|
|
7
7
|
{
|
|
8
8
|
"name": "Math: Multiplication",
|
|
9
|
-
"expression": "
|
|
9
|
+
"expression": "=*(4, 5)",
|
|
10
10
|
"expected": 20
|
|
11
11
|
},
|
|
12
12
|
{
|
|
13
13
|
"name": "Math: Division",
|
|
14
|
-
"expression": "
|
|
14
|
+
"expression": "=/(20, 4)",
|
|
15
15
|
"expected": 5
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
"name": "Math: Round",
|
|
19
|
-
"expression": "
|
|
19
|
+
"expression": "=round(3.7)",
|
|
20
20
|
"expected": 4
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"name": "String: Upper",
|
|
24
|
-
"expression": "
|
|
24
|
+
"expression": "=upper('hello')",
|
|
25
25
|
"expected": "HELLO"
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
"name": "String: Lower",
|
|
29
|
-
"expression": "
|
|
29
|
+
"expression": "=lower('WORLD')",
|
|
30
30
|
"expected": "world"
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"name": "String: Concat",
|
|
34
|
-
"expression": "
|
|
34
|
+
"expression": "=concat('Hello', ' ', 'World')",
|
|
35
35
|
"expected": "Hello World"
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
"name": "String: Trim",
|
|
39
|
-
"expression": "
|
|
39
|
+
"expression": "=trim(' spaced ')",
|
|
40
40
|
"expected": "spaced"
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"name": "Logic: And (true)",
|
|
44
|
-
"expression": "
|
|
44
|
+
"expression": "=and(true, true)",
|
|
45
45
|
"expected": true
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"name": "Logic: And (false)",
|
|
49
|
-
"expression": "
|
|
49
|
+
"expression": "=and(true, false)",
|
|
50
50
|
"expected": false
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
"name": "Logic: Or",
|
|
54
|
-
"expression": "
|
|
54
|
+
"expression": "=or(false, true)",
|
|
55
55
|
"expected": true
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
"name": "Logic: Not",
|
|
59
|
-
"expression": "
|
|
59
|
+
"expression": "=not(false)",
|
|
60
60
|
"expected": true
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
"name": "Compare: Greater Than",
|
|
64
|
-
"expression": "
|
|
64
|
+
"expression": "=gt(10, 5)",
|
|
65
65
|
"expected": true
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
"name": "Compare: Less Than",
|
|
69
|
-
"expression": "
|
|
69
|
+
"expression": "=lt(3, 7)",
|
|
70
70
|
"expected": true
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
"name": "Compare: Equal",
|
|
74
|
-
"expression": "
|
|
74
|
+
"expression": "=eq(5, 5)",
|
|
75
75
|
"expected": true
|
|
76
76
|
},
|
|
77
77
|
{
|
|
78
78
|
"name": "Conditional: If (true branch)",
|
|
79
|
-
"expression": "
|
|
79
|
+
"expression": "=if(true, 'Yes', 'No')",
|
|
80
80
|
"expected": "Yes"
|
|
81
81
|
},
|
|
82
82
|
{
|
|
83
83
|
"name": "Conditional: If (false branch)",
|
|
84
|
-
"expression": "
|
|
84
|
+
"expression": "=if(false, 'Yes', 'No')",
|
|
85
85
|
"expected": "No"
|
|
86
86
|
},
|
|
87
87
|
{
|
|
88
88
|
"name": "Stats: Average",
|
|
89
|
-
"expression": "
|
|
89
|
+
"expression": "=avg(10, 20, 30)",
|
|
90
90
|
"expected": 20
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
"name": "Stats: Min",
|
|
94
|
-
"expression": "
|
|
94
|
+
"expression": "=min(5, 2, 8)",
|
|
95
95
|
"expected": 2
|
|
96
96
|
},
|
|
97
97
|
{
|
|
98
98
|
"name": "Stats: Max",
|
|
99
|
-
"expression": "
|
|
99
|
+
"expression": "=max(5, 2, 8)",
|
|
100
100
|
"expected": 8
|
|
101
101
|
},
|
|
102
102
|
{
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
"c"
|
|
109
109
|
]
|
|
110
110
|
},
|
|
111
|
-
"expression": "
|
|
111
|
+
"expression": "=len(/items)",
|
|
112
112
|
"expected": 3
|
|
113
113
|
},
|
|
114
114
|
{
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
"c"
|
|
121
121
|
]
|
|
122
122
|
},
|
|
123
|
-
"expression": "
|
|
123
|
+
"expression": "=join(/items, '-')",
|
|
124
124
|
"expected": "a-b-c"
|
|
125
125
|
},
|
|
126
126
|
{
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"c"
|
|
133
133
|
]
|
|
134
134
|
},
|
|
135
|
-
"expression": "
|
|
135
|
+
"expression": "=first(/items)",
|
|
136
136
|
"expected": "a"
|
|
137
137
|
},
|
|
138
138
|
{
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
"c"
|
|
145
145
|
]
|
|
146
146
|
},
|
|
147
|
-
"expression": "
|
|
147
|
+
"expression": "=last(/items)",
|
|
148
148
|
"expected": "c"
|
|
149
149
|
}
|
|
150
150
|
]
|