jprx 1.0.0 → 1.1.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 +72 -73
- package/helpers/state.js +21 -0
- package/package.json +1 -1
- package/parser.js +100 -48
- package/specs/expressions.json +71 -0
- package/specs/helpers.json +150 -0
package/README.md
CHANGED
|
@@ -10,8 +10,15 @@ JPRX is a **syntax** and an **expression engine**. While this repository provide
|
|
|
10
10
|
|
|
11
11
|
- **Declarative Power**: Define relationships between data points as easily as writing an Excel formula.
|
|
12
12
|
- **Security**: JPRX strictly avoids `eval()`. Expressions are handled by a custom high-performance Pratt parser and a registry of pre-defined helpers, making it safe for dynamic content.
|
|
13
|
-
- **Portability**:
|
|
14
|
-
- **
|
|
13
|
+
- **Portability**: JPRX expressions are strings within JSON, making them easily serialized and platform-agnostic.
|
|
14
|
+
- **Schema-First**: Integrated support for JSON Schema and shorthand descriptors provides industrial-strength validation and "future-proof" reactivity.
|
|
15
|
+
|
|
16
|
+
## UI Library Requirements
|
|
17
|
+
|
|
18
|
+
To fully support JPRX, an underlying UI library **MUST** provide:
|
|
19
|
+
- **Mount Lifecycle**: A hook (e.g., `onmount`) where state initialization can occur. JPRX relies on the library to trigger these initializers.
|
|
20
|
+
- **Event Handling**: Support for standard event handlers (like `oninput`, `onclick`) **SHOULD** be provided, though exact implementations may vary by platform.
|
|
21
|
+
- **Reactivity**: A way to resolve paths to reactive primitives (e.g., Signals or Proxies).
|
|
15
22
|
|
|
16
23
|
## Syntax & Features
|
|
17
24
|
|
|
@@ -21,110 +28,102 @@ JPRX extends the base JSON Pointer syntax with:
|
|
|
21
28
|
|---------|--------|-------------|
|
|
22
29
|
| **Global Path** | `$/user/name` | Access global state via an absolute path. |
|
|
23
30
|
| **Relative Path** | `./count` | Access properties relative to the current context. |
|
|
24
|
-
| **Parent Path** | `../id` | Traverse up the state hierarchy. |
|
|
31
|
+
| **Parent Path** | `../id` | Traverse up the state hierarchy (UP-tree search). |
|
|
25
32
|
| **Functions** | `$sum(/items...price)` | Call registered core helpers. |
|
|
26
33
|
| **Explosion** | `/items...name` | Extract a property from every object in an array (spread). |
|
|
27
34
|
| **Operators** | `$++/count`, `$/a + $/b` | Familiar JS-style prefix, postfix, and infix operators. |
|
|
28
|
-
| **Placeholders** | `_` (item), `$event` | Context-aware placeholders for iteration and interaction. |
|
|
29
|
-
|
|
30
|
-
## Human & AI Utility
|
|
31
|
-
|
|
32
|
-
JPRX is uniquely positioned to bridge the gap between human developers and AI coding assistants:
|
|
33
|
-
|
|
34
|
-
### For Humans: "The Excel Paradigm"
|
|
35
|
-
Humans are often familiar with the "recalculation" model of spreadsheets. JPRX brings this to UI development. Instead of writing complex "glue code" (event listeners, state updates, DOM manipulation), developers specify the *formula* for a UI element once, and it stays updated forever.
|
|
35
|
+
| **Placeholders** | `_` (item), `$this`, `$event` | Context-aware placeholders for iteration and interaction. |
|
|
36
|
+
| **Two-Way Binding**| `$bind(/user/name)`| Create a managed, two-way reactive link for inputs. |
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
Large Language Models (LLMs) are exceptionally good at generating structured data (JSON) and formulaic expressions. They are often prone to errors when generating large blocks of imperative JavaScript logic. JPRX provides a high-level, declarative "target" for AI to aim at, resulting in:
|
|
39
|
-
- **Higher Accuracy**: Less boilerplate means fewer places for the AI to hallucinate.
|
|
40
|
-
- **Safety**: AI can generate UI logic that remains sandboxed and secure.
|
|
41
|
-
- **Compactness**: Entire interactive components can be described in a few lines of JSON.
|
|
38
|
+
Once inside a JPRX expression, the `$` prefix is only needed at the start of the expression for paths or function names.
|
|
42
39
|
|
|
43
|
-
##
|
|
40
|
+
## State Management
|
|
44
41
|
|
|
45
|
-
JPRX
|
|
42
|
+
JPRX utilizes explicit state initializers within lifecycle hooks:
|
|
46
43
|
|
|
47
|
-
###
|
|
48
|
-
|
|
44
|
+
### Scoped State
|
|
45
|
+
States can be attached to specific scopes (such as a DOM node or Component instance) using the `scope` property in the options argument.
|
|
46
|
+
- **Up-tree Resolution**: When resolving a path, JPRX walks up the provided scope chain looking for the nearest owner of that name.
|
|
47
|
+
- **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`.
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
49
|
+
### The `$state` and `$signal` Helpers
|
|
50
|
+
- `$state(value, { name: 'user', schema: 'UserProfile', scope: event.target })`
|
|
51
|
+
- `$signal(0, { name: 'count', schema: 'auto' })`
|
|
53
52
|
|
|
54
|
-
|
|
53
|
+
## Schema Registry & Validation
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
These operators are typically used in event handlers or for immediate state transformation.
|
|
55
|
+
JPRX integrates with a global Schema Registry via `jprx.registerSchema(name, definition)`.
|
|
58
56
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
### Registering and Using Schemas
|
|
58
|
+
```javascript
|
|
59
|
+
// 1. Register a schema centrally
|
|
60
|
+
jprx.registerSchema('UserProfile', {
|
|
61
|
+
name: "string",
|
|
62
|
+
age: "number",
|
|
63
|
+
email: { type: "string", format: "email" }
|
|
64
|
+
});
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
// 2. Reference the registered schema by name (Scoped)
|
|
67
|
+
const user = $state({}, { name: 'user', schema: 'UserProfile', scope: $this });
|
|
64
68
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
### Stats
|
|
71
|
-
`sum`, `avg`, `min`, `max`, `median`, `stdev`, `var`
|
|
69
|
+
// 3. Use the 'polymorphic' shorthand for auto-coercion
|
|
70
|
+
const settings = $state({ volume: 50 }, { name: 'settings', schema: 'polymorphic' });
|
|
71
|
+
// Result: settings.volume = "60" will automatically coerce to the number 60.
|
|
72
|
+
```
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
- **Polymorphic Schemas**:
|
|
75
|
+
- **`"auto"`**: Infers the fixed schema from the initial value. Strict type checking (e.g., setting a number to a string throws). New properties are not allowed.
|
|
76
|
+
- **`"dynamic"`**: Like `auto`, but allows new properties to be added to the state object.
|
|
77
|
+
- **`"polymorphic"`**: Includes **`dynamic`** behavior and automatically coerces values to match the inferred type (e.g., "50" -> 50) rather than throwing.
|
|
78
|
+
- **Shorthand**: A simple object like `{ name: "string" }` is internally normalized to a JSON Schema.
|
|
75
79
|
|
|
76
|
-
###
|
|
77
|
-
|
|
80
|
+
### Transformation Schemas
|
|
81
|
+
Schemas can define transformations that occur during state updates, ensuring data remains in a consistent format regardless of how it was input.
|
|
78
82
|
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"type": "object",
|
|
86
|
+
"properties": {
|
|
87
|
+
"username": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"transform": "lower"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
*Note: The `$bind` helper uses these transformations to automatically clean data as the user types.*
|
|
81
95
|
|
|
82
|
-
|
|
83
|
-
`number`, `currency`, `percent`, `thousands`
|
|
96
|
+
## Two-Way Binding with `$bind`
|
|
84
97
|
|
|
85
|
-
|
|
86
|
-
`now`, `today`, `date`, `formatDate`, `year`, `month`, `day`, `weekday`, `addDays`, `dateDiff`
|
|
98
|
+
The `$bind(path)` helper creates a managed, two-way link between the UI and a state path.
|
|
87
99
|
|
|
88
|
-
###
|
|
89
|
-
|
|
100
|
+
### Strictness
|
|
101
|
+
To ensure unambiguous data flow, `$bind` only accepts direct paths. It cannot be used directly with computed expressions like `$bind(upper(/name))`.
|
|
90
102
|
|
|
91
|
-
###
|
|
92
|
-
|
|
103
|
+
### Handling Transformations
|
|
104
|
+
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., `$set(/name, upper($event/target/value))`.
|
|
106
|
+
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.
|
|
93
107
|
|
|
94
|
-
|
|
95
|
-
`fetch(url, options?)` - *Auto-serializes JSON bodies and handles content-types.*
|
|
108
|
+
---
|
|
96
109
|
|
|
97
110
|
## Example
|
|
98
111
|
|
|
99
|
-
A
|
|
112
|
+
A modern, lifecycle-based reactive counter:
|
|
100
113
|
|
|
101
114
|
```json
|
|
102
115
|
{
|
|
103
116
|
"div": {
|
|
104
|
-
"
|
|
117
|
+
"onmount": $state({ count: 0 }, { name: 'counter', schema: 'auto', scope: $this }),
|
|
105
118
|
"children": [
|
|
106
|
-
{ "h2": "Counter" },
|
|
107
|
-
{ "p": ["Current Count: ",
|
|
108
|
-
{ "button": { "onclick":
|
|
109
|
-
{ "button": { "onclick":
|
|
119
|
+
{ "h2": "Modern JPRX Counter" },
|
|
120
|
+
{ "p": ["Current Count: ", $/counter/count] },
|
|
121
|
+
{ "button": { "onclick": $++/counter/count, "children": ["+"] } },
|
|
122
|
+
{ "button": { "onclick": $--/counter/count, "children": ["-"] } }
|
|
110
123
|
]
|
|
111
124
|
}
|
|
112
125
|
}
|
|
113
126
|
```
|
|
114
127
|
|
|
115
|
-
## Reference Implementation: Lightview
|
|
116
|
-
|
|
117
|
-
JPRX was originally developed for [Lightview](https://github.com/anywhichway/lightview) to power its **Computational DOM (cDOM)**. Lightview serves as the primary example of how a UI library can hydrate JPRX expressions into a live, reactive interface.
|
|
118
|
-
|
|
119
|
-
If you are building a UI library and want to support reactive JSON structures, this parser provides the foundation.
|
|
120
|
-
|
|
121
|
-
## Getting Started
|
|
122
|
-
|
|
123
|
-
The JPRX package contains:
|
|
124
|
-
1. `parser.js`: The core Pratt parser and path resolution logic.
|
|
125
|
-
2. `helpers/`: A comprehensive library of math, logic, string, array, formatting, and state helpers.
|
|
126
|
-
|
|
127
|
-
To use JPRX, you typically register your state-management primitives (like Signals or Proxies) with the parser's registry, and then call `hydrate()` or `resolveExpression()` to activate the logic.
|
|
128
|
-
|
|
129
128
|
---
|
|
130
129
|
© 2026 Simon Y. Blackwell, AnyWhichWay LLC. Licensed under MIT.
|
package/helpers/state.js
CHANGED
|
@@ -64,6 +64,24 @@ export const clear = (target) => {
|
|
|
64
64
|
return set(target, null);
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
+
export function $state(val, options) {
|
|
68
|
+
if (globalThis.Lightview) {
|
|
69
|
+
const finalOptions = typeof options === 'string' ? { name: options } : options;
|
|
70
|
+
return globalThis.Lightview.state(val, finalOptions);
|
|
71
|
+
}
|
|
72
|
+
throw new Error('JPRX: $state requires a UI library implementation.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function $signal(val, options) {
|
|
76
|
+
if (globalThis.Lightview) {
|
|
77
|
+
const finalOptions = typeof options === 'string' ? { name: options } : options;
|
|
78
|
+
return globalThis.Lightview.signal(val, finalOptions);
|
|
79
|
+
}
|
|
80
|
+
throw new Error('JPRX: $signal requires a UI library implementation.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const $bind = (path, options) => ({ __JPRX_BIND__: true, path, options });
|
|
84
|
+
|
|
67
85
|
export const registerStateHelpers = (register) => {
|
|
68
86
|
const opts = { pathAware: true };
|
|
69
87
|
register('set', set, opts);
|
|
@@ -77,4 +95,7 @@ export const registerStateHelpers = (register) => {
|
|
|
77
95
|
register('pop', pop, opts);
|
|
78
96
|
register('assign', assign, opts);
|
|
79
97
|
register('clear', clear, opts);
|
|
98
|
+
register('state', $state);
|
|
99
|
+
register('signal', $signal);
|
|
100
|
+
register('bind', $bind);
|
|
80
101
|
};
|
package/package.json
CHANGED
package/parser.js
CHANGED
|
@@ -28,6 +28,9 @@ const DEFAULT_PRECEDENCE = {
|
|
|
28
28
|
*/
|
|
29
29
|
export const registerHelper = (name, fn, options = {}) => {
|
|
30
30
|
helpers.set(name, fn);
|
|
31
|
+
if (globalThis.__LIGHTVIEW_INTERNALS__) {
|
|
32
|
+
globalThis.__LIGHTVIEW_INTERNALS__.helpers.set(name, fn);
|
|
33
|
+
}
|
|
31
34
|
if (options) helperOptions.set(name, options);
|
|
32
35
|
};
|
|
33
36
|
|
|
@@ -134,27 +137,12 @@ export const resolvePath = (path, context) => {
|
|
|
134
137
|
if (path === '.') return unwrapSignal(context);
|
|
135
138
|
|
|
136
139
|
// 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
140
|
if (path.startsWith('$/')) {
|
|
140
141
|
const [rootName, ...rest] = path.slice(2).split('/');
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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);
|
|
142
|
+
const LV = getLV();
|
|
143
|
+
const root = LV ? LV.get(rootName, { scope: context?.__node__ || context }) : registry?.get(rootName);
|
|
144
|
+
if (!root) return undefined;
|
|
145
|
+
return traverse(root, rest);
|
|
158
146
|
}
|
|
159
147
|
|
|
160
148
|
// Relative path from current context
|
|
@@ -197,26 +185,13 @@ export const resolvePathAsContext = (path, context) => {
|
|
|
197
185
|
if (path === '.') return context;
|
|
198
186
|
|
|
199
187
|
// Global absolute path: $/something
|
|
200
|
-
// First check if the root is in the local context's state (cdom-state)
|
|
201
188
|
if (path.startsWith('$/')) {
|
|
202
189
|
const segments = path.slice(2).split(/[/.]/);
|
|
203
190
|
const rootName = segments.shift();
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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);
|
|
191
|
+
const LV = getLV();
|
|
192
|
+
const root = LV ? LV.get(rootName, { scope: context?.__node__ || context }) : registry?.get(rootName);
|
|
193
|
+
if (!root) return undefined;
|
|
194
|
+
return traverseAsContext(root, segments);
|
|
220
195
|
}
|
|
221
196
|
|
|
222
197
|
// Relative path from current context
|
|
@@ -260,6 +235,11 @@ class LazyValue {
|
|
|
260
235
|
}
|
|
261
236
|
}
|
|
262
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Node helper - identifies if a value is a DOM node.
|
|
240
|
+
*/
|
|
241
|
+
const isNode = (val) => val && typeof val === 'object' && globalThis.Node && val instanceof globalThis.Node;
|
|
242
|
+
|
|
263
243
|
/**
|
|
264
244
|
* Helper to resolve an argument which could be a literal, a path, or an explosion.
|
|
265
245
|
* @param {string} arg - The argument string
|
|
@@ -294,10 +274,23 @@ const resolveArgument = (arg, context, globalMode = false) => {
|
|
|
294
274
|
};
|
|
295
275
|
}
|
|
296
276
|
|
|
297
|
-
// 5.
|
|
277
|
+
// 5. Context Identifiers ($this, $event)
|
|
278
|
+
if (arg === '$this' || arg.startsWith('$this/') || arg.startsWith('$this.')) {
|
|
279
|
+
return {
|
|
280
|
+
value: new LazyValue((context) => {
|
|
281
|
+
const node = context?.__node__ || context;
|
|
282
|
+
if (arg === '$this') return node;
|
|
283
|
+
const path = arg.startsWith('$this.') ? arg.slice(6) : arg.slice(6);
|
|
284
|
+
return resolvePath(path, node);
|
|
285
|
+
}),
|
|
286
|
+
isLazy: true
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
298
290
|
if (arg === '$event' || arg.startsWith('$event/') || arg.startsWith('$event.')) {
|
|
299
291
|
return {
|
|
300
|
-
value: new LazyValue((
|
|
292
|
+
value: new LazyValue((context) => {
|
|
293
|
+
const event = context?.$event || context?.event || context;
|
|
301
294
|
if (arg === '$event') return event;
|
|
302
295
|
const path = arg.startsWith('$event.') ? arg.slice(7) : arg.slice(7);
|
|
303
296
|
return resolvePath(path, event);
|
|
@@ -319,6 +312,18 @@ const resolveArgument = (arg, context, globalMode = false) => {
|
|
|
319
312
|
const final = (res instanceof LazyValue) ? res.resolve(context) : res;
|
|
320
313
|
return unwrapSignal(final);
|
|
321
314
|
}
|
|
315
|
+
if (node === '$this' || node.startsWith('$this/') || node.startsWith('$this.')) {
|
|
316
|
+
const path = (node.startsWith('$this.') || node.startsWith('$this/')) ? node.slice(6) : node.slice(6);
|
|
317
|
+
const ctxNode = context?.__node__ || context;
|
|
318
|
+
const res = node === '$this' ? ctxNode : resolvePath(path, ctxNode);
|
|
319
|
+
return unwrapSignal(res);
|
|
320
|
+
}
|
|
321
|
+
if (node === '$event' || node.startsWith('$event/') || node.startsWith('$event.')) {
|
|
322
|
+
const path = (node.startsWith('$event.') || node.startsWith('$event/')) ? node.slice(7) : node.slice(7);
|
|
323
|
+
const event = context?.$event || context?.event || (context && !isNode(context) ? context : null);
|
|
324
|
+
const res = node === '$event' ? event : resolvePath(path, event);
|
|
325
|
+
return unwrapSignal(res);
|
|
326
|
+
}
|
|
322
327
|
if (node === '_' || node.startsWith('_/') || node.startsWith('_.')) {
|
|
323
328
|
const path = (node.startsWith('_.') || node.startsWith('_/')) ? node.slice(2) : node.slice(2);
|
|
324
329
|
const res = node === '_' ? context : resolvePath(path, context);
|
|
@@ -433,6 +438,7 @@ const TokenType = {
|
|
|
433
438
|
COMMA: 'COMMA', // ,
|
|
434
439
|
EXPLOSION: 'EXPLOSION', // ... suffix
|
|
435
440
|
PLACEHOLDER: 'PLACEHOLDER', // _, _/path
|
|
441
|
+
THIS: 'THIS', // $this
|
|
436
442
|
EVENT: 'EVENT', // $event, $event.target
|
|
437
443
|
EOF: 'EOF'
|
|
438
444
|
};
|
|
@@ -481,16 +487,17 @@ const tokenize = (expr) => {
|
|
|
481
487
|
// In expressions like "$++/count", the $ is just the JPRX delimiter
|
|
482
488
|
// and ++ is a prefix operator applied to /count
|
|
483
489
|
if (expr[i] === '$' && i + 1 < len) {
|
|
484
|
-
// Check if next chars are
|
|
485
|
-
|
|
486
|
-
|
|
490
|
+
// Check if next chars are a PREFIX operator (sort by length to match longest first)
|
|
491
|
+
const prefixOps = [...operators.prefix.keys()].sort((a, b) => b.length - a.length);
|
|
492
|
+
let isPrefixOp = false;
|
|
493
|
+
for (const op of prefixOps) {
|
|
487
494
|
if (expr.slice(i + 1, i + 1 + op.length) === op) {
|
|
488
|
-
|
|
495
|
+
isPrefixOp = true;
|
|
489
496
|
break;
|
|
490
497
|
}
|
|
491
498
|
}
|
|
492
|
-
if (
|
|
493
|
-
// Skip the $, it's just a delimiter
|
|
499
|
+
if (isPrefixOp) {
|
|
500
|
+
// Skip the $, it's just a delimiter for a prefix operator (e.g., $++/count)
|
|
494
501
|
i++;
|
|
495
502
|
continue;
|
|
496
503
|
}
|
|
@@ -620,6 +627,18 @@ const tokenize = (expr) => {
|
|
|
620
627
|
continue;
|
|
621
628
|
}
|
|
622
629
|
|
|
630
|
+
// $this placeholder
|
|
631
|
+
if (expr.slice(i, i + 5) === '$this') {
|
|
632
|
+
let thisPath = '$this';
|
|
633
|
+
i += 5;
|
|
634
|
+
while (i < len && /[a-zA-Z0-9_./]/.test(expr[i])) {
|
|
635
|
+
thisPath += expr[i];
|
|
636
|
+
i++;
|
|
637
|
+
}
|
|
638
|
+
tokens.push({ type: TokenType.THIS, value: thisPath });
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
|
|
623
642
|
// $event placeholder
|
|
624
643
|
if (expr.slice(i, i + 6) === '$event') {
|
|
625
644
|
let eventPath = '$event';
|
|
@@ -796,7 +815,7 @@ class PrattParser {
|
|
|
796
815
|
const prec = this.getInfixPrecedence(tok.value);
|
|
797
816
|
if (prec < minPrecedence) break;
|
|
798
817
|
|
|
799
|
-
// Check if it's a postfix operator
|
|
818
|
+
// Check if it's a postfix-only operator
|
|
800
819
|
if (operators.postfix.has(tok.value) && !operators.infix.has(tok.value)) {
|
|
801
820
|
this.consume();
|
|
802
821
|
left = { type: 'Postfix', operator: tok.value, operand: left };
|
|
@@ -814,6 +833,12 @@ class PrattParser {
|
|
|
814
833
|
continue;
|
|
815
834
|
}
|
|
816
835
|
|
|
836
|
+
// Operator not registered as postfix or infix - treat as unknown and stop
|
|
837
|
+
// This prevents infinite loops when operators are tokenized but not registered
|
|
838
|
+
if (!operators.postfix.has(tok.value) && !operators.infix.has(tok.value)) {
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
|
|
817
842
|
// Postfix that's also infix - context determines
|
|
818
843
|
// If next token is a value, treat as infix
|
|
819
844
|
this.consume();
|
|
@@ -871,6 +896,12 @@ class PrattParser {
|
|
|
871
896
|
return { type: 'Placeholder', value: tok.value };
|
|
872
897
|
}
|
|
873
898
|
|
|
899
|
+
// This
|
|
900
|
+
if (tok.type === TokenType.THIS) {
|
|
901
|
+
this.consume();
|
|
902
|
+
return { type: 'This', value: tok.value };
|
|
903
|
+
}
|
|
904
|
+
|
|
874
905
|
// Event
|
|
875
906
|
if (tok.type === TokenType.EVENT) {
|
|
876
907
|
this.consume();
|
|
@@ -927,8 +958,18 @@ const evaluateAST = (ast, context, forMutation = false) => {
|
|
|
927
958
|
});
|
|
928
959
|
}
|
|
929
960
|
|
|
961
|
+
case 'This': {
|
|
962
|
+
return new LazyValue((context) => {
|
|
963
|
+
const node = context?.__node__ || context;
|
|
964
|
+
if (ast.value === '$this') return node;
|
|
965
|
+
const path = ast.value.startsWith('$this.') ? ast.value.slice(6) : ast.value.slice(6);
|
|
966
|
+
return resolvePath(path, node);
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
930
970
|
case 'Event': {
|
|
931
|
-
return new LazyValue((
|
|
971
|
+
return new LazyValue((context) => {
|
|
972
|
+
const event = context?.$event || context?.event || context;
|
|
932
973
|
if (ast.value === '$event') return event;
|
|
933
974
|
const path = ast.value.startsWith('$event.') ? ast.value.slice(7) : ast.value.slice(7);
|
|
934
975
|
return resolvePath(path, event);
|
|
@@ -985,7 +1026,18 @@ const evaluateAST = (ast, context, forMutation = false) => {
|
|
|
985
1026
|
// For infix, typically first arg might be pathAware
|
|
986
1027
|
const left = evaluateAST(ast.left, context, opts.pathAware);
|
|
987
1028
|
const right = evaluateAST(ast.right, context, false);
|
|
988
|
-
|
|
1029
|
+
|
|
1030
|
+
const finalArgs = [];
|
|
1031
|
+
|
|
1032
|
+
// Handle potentially exploded arguments (like in sum(/items...p))
|
|
1033
|
+
// Although infix operators usually take exactly 2 args, we treat them consistently
|
|
1034
|
+
if (Array.isArray(left) && ast.left.type === 'Explosion') finalArgs.push(...left);
|
|
1035
|
+
else finalArgs.push(unwrapSignal(left));
|
|
1036
|
+
|
|
1037
|
+
if (Array.isArray(right) && ast.right.type === 'Explosion') finalArgs.push(...right);
|
|
1038
|
+
else finalArgs.push(unwrapSignal(right));
|
|
1039
|
+
|
|
1040
|
+
return helper(...finalArgs);
|
|
989
1041
|
}
|
|
990
1042
|
|
|
991
1043
|
default:
|
|
@@ -1117,7 +1169,7 @@ export const resolveExpression = (expr, context) => {
|
|
|
1117
1169
|
});
|
|
1118
1170
|
}
|
|
1119
1171
|
|
|
1120
|
-
const result = helper(
|
|
1172
|
+
const result = helper.apply(context?.__node__ || null, resolvedArgs);
|
|
1121
1173
|
return unwrapSignal(result);
|
|
1122
1174
|
}
|
|
1123
1175
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "Global Path Resolution",
|
|
4
|
+
"state": {
|
|
5
|
+
"userName": "Alice"
|
|
6
|
+
},
|
|
7
|
+
"expression": "$/userName",
|
|
8
|
+
"expected": "Alice"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "Deep Global Path Resolution",
|
|
12
|
+
"state": {
|
|
13
|
+
"user": {
|
|
14
|
+
"profile": {
|
|
15
|
+
"name": "Bob"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"expression": "$/user/profile/name",
|
|
20
|
+
"expected": "Bob"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "Relative Path Resolution",
|
|
24
|
+
"context": {
|
|
25
|
+
"age": 30
|
|
26
|
+
},
|
|
27
|
+
"expression": "./age",
|
|
28
|
+
"expected": 30
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "Math: Addition",
|
|
32
|
+
"state": {
|
|
33
|
+
"a": 5,
|
|
34
|
+
"b": 10
|
|
35
|
+
},
|
|
36
|
+
"expression": "$+(/a, /b)",
|
|
37
|
+
"expected": 15
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "Operator: Addition (Infix)",
|
|
41
|
+
"state": {
|
|
42
|
+
"a": 5,
|
|
43
|
+
"b": 10
|
|
44
|
+
},
|
|
45
|
+
"expression": "$/a + $/b",
|
|
46
|
+
"expected": 15
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "Logic: If (True)",
|
|
50
|
+
"state": {
|
|
51
|
+
"isVip": true
|
|
52
|
+
},
|
|
53
|
+
"expression": "$if(/isVip, \"Gold\", \"Silver\")",
|
|
54
|
+
"expected": "Gold"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"name": "Explosion Operator",
|
|
58
|
+
"state": {
|
|
59
|
+
"items": [
|
|
60
|
+
{
|
|
61
|
+
"p": 10
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"p": 20
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
"expression": "$sum(/items...p)",
|
|
69
|
+
"expected": 30
|
|
70
|
+
}
|
|
71
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "Math: Subtraction",
|
|
4
|
+
"expression": "$-(10, 3)",
|
|
5
|
+
"expected": 7
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"name": "Math: Multiplication",
|
|
9
|
+
"expression": "$*(4, 5)",
|
|
10
|
+
"expected": 20
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"name": "Math: Division",
|
|
14
|
+
"expression": "$/(20, 4)",
|
|
15
|
+
"expected": 5
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "Math: Round",
|
|
19
|
+
"expression": "$round(3.7)",
|
|
20
|
+
"expected": 4
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "String: Upper",
|
|
24
|
+
"expression": "$upper('hello')",
|
|
25
|
+
"expected": "HELLO"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "String: Lower",
|
|
29
|
+
"expression": "$lower('WORLD')",
|
|
30
|
+
"expected": "world"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "String: Concat",
|
|
34
|
+
"expression": "$concat('Hello', ' ', 'World')",
|
|
35
|
+
"expected": "Hello World"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "String: Trim",
|
|
39
|
+
"expression": "$trim(' spaced ')",
|
|
40
|
+
"expected": "spaced"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "Logic: And (true)",
|
|
44
|
+
"expression": "$and(true, true)",
|
|
45
|
+
"expected": true
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "Logic: And (false)",
|
|
49
|
+
"expression": "$and(true, false)",
|
|
50
|
+
"expected": false
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "Logic: Or",
|
|
54
|
+
"expression": "$or(false, true)",
|
|
55
|
+
"expected": true
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "Logic: Not",
|
|
59
|
+
"expression": "$not(false)",
|
|
60
|
+
"expected": true
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "Compare: Greater Than",
|
|
64
|
+
"expression": "$gt(10, 5)",
|
|
65
|
+
"expected": true
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "Compare: Less Than",
|
|
69
|
+
"expression": "$lt(3, 7)",
|
|
70
|
+
"expected": true
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "Compare: Equal",
|
|
74
|
+
"expression": "$eq(5, 5)",
|
|
75
|
+
"expected": true
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"name": "Conditional: If (true branch)",
|
|
79
|
+
"expression": "$if(true, 'Yes', 'No')",
|
|
80
|
+
"expected": "Yes"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "Conditional: If (false branch)",
|
|
84
|
+
"expression": "$if(false, 'Yes', 'No')",
|
|
85
|
+
"expected": "No"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"name": "Stats: Average",
|
|
89
|
+
"expression": "$avg(10, 20, 30)",
|
|
90
|
+
"expected": 20
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "Stats: Min",
|
|
94
|
+
"expression": "$min(5, 2, 8)",
|
|
95
|
+
"expected": 2
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"name": "Stats: Max",
|
|
99
|
+
"expression": "$max(5, 2, 8)",
|
|
100
|
+
"expected": 8
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"name": "Array: Length",
|
|
104
|
+
"state": {
|
|
105
|
+
"items": [
|
|
106
|
+
"a",
|
|
107
|
+
"b",
|
|
108
|
+
"c"
|
|
109
|
+
]
|
|
110
|
+
},
|
|
111
|
+
"expression": "$len(/items)",
|
|
112
|
+
"expected": 3
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"name": "Array: Join",
|
|
116
|
+
"state": {
|
|
117
|
+
"items": [
|
|
118
|
+
"a",
|
|
119
|
+
"b",
|
|
120
|
+
"c"
|
|
121
|
+
]
|
|
122
|
+
},
|
|
123
|
+
"expression": "$join(/items, '-')",
|
|
124
|
+
"expected": "a-b-c"
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"name": "Array: First",
|
|
128
|
+
"state": {
|
|
129
|
+
"items": [
|
|
130
|
+
"a",
|
|
131
|
+
"b",
|
|
132
|
+
"c"
|
|
133
|
+
]
|
|
134
|
+
},
|
|
135
|
+
"expression": "$first(/items)",
|
|
136
|
+
"expected": "a"
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"name": "Array: Last",
|
|
140
|
+
"state": {
|
|
141
|
+
"items": [
|
|
142
|
+
"a",
|
|
143
|
+
"b",
|
|
144
|
+
"c"
|
|
145
|
+
]
|
|
146
|
+
},
|
|
147
|
+
"expression": "$last(/items)",
|
|
148
|
+
"expected": "c"
|
|
149
|
+
}
|
|
150
|
+
]
|