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 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**: Because JPRX expressions are strings within JSON structures, they are easily serialized, stored, and sent over the wire.
14
- - **Platform Agnostic**: While Lightview is the first implementation, JPRX can be used in any environment that manages reactive state.
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
- ### For AI: Structured & Concise
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
- ## Operators
40
+ ## State Management
44
41
 
45
- JPRX supports a wide range of operators that provide a more concise and familiar syntax than function calls.
42
+ JPRX utilizes explicit state initializers within lifecycle hooks:
46
43
 
47
- ### Arithmetic & Logic (Infix)
48
- Infix operators require surrounding whitespace in JPRX to avoid ambiguity with path separators.
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
- - **Arithmetic**: `+`, `-`, `*`, `/`, `mod`, `pow`
51
- - **Comparison**: `>`, `<`, `>=`, `<=`, `==`, `!=`
52
- - **Logic**: `&&`, `||`
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
- *Example:* `$/a + $/b * 10 > $/threshold`
53
+ ## Schema Registry & Validation
55
54
 
56
- ### Mutation & Unary (Prefix/Postfix)
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
- - **Increment**: `$++/count` (prefix) or `$/count++` (postfix)
60
- - **Decrement**: `$--/count` (prefix) or `$/count--` (postfix)
61
- - **Toggle**: `$!!/enabled` (logical NOT/toggle)
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
- ## Helper Functions
66
+ // 2. Reference the registered schema by name (Scoped)
67
+ const user = $state({}, { name: 'user', schema: 'UserProfile', scope: $this });
64
68
 
65
- JPRX includes a comprehensive library of built-in helpers. For security, only registered helpers are available—there is no access to the global JavaScript environment.
66
-
67
- ### Math
68
- `add`, `sub`, `mul`, `div`, `mod`, `pow`, `sqrt`, `abs`, `round`, `ceil`, `floor`, `min`, `max`
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
- ### String
74
- `upper`, `lower`, `trim`, `capitalize`, `titleCase`, `contains`, `startsWith`, `endsWith`, `replace`, `split`, `join`, `concat`, `len`, `default`
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
- ### Array
77
- `count`, `map`, `filter`, `find`, `unique`, `sort`, `reverse`, `first`, `last`, `slice`, `flatten`, `join`, `len`
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
- ### Logic & Comparison
80
- `if`, `and`, `or`, `not`, `eq`, `neq`, `gt`, `lt`, `gte`, `lte`, `between`, `in`
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
- ### Formatting
83
- `number`, `currency`, `percent`, `thousands`
96
+ ## Two-Way Binding with `$bind`
84
97
 
85
- ### DateTime
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
- ### Lookup
89
- `lookup`, `vlookup`, `index`, `match`
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
- ### State Mutation
92
- `set`, `increment`, `decrement`, `toggle`, `push`, `pop`, `assign`, `clear`
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
- ### Network
95
- `fetch(url, options?)` - *Auto-serializes JSON bodies and handles content-types.*
108
+ ---
96
109
 
97
110
  ## Example
98
111
 
99
- A simple reactive counter described in JPRX syntax:
112
+ A modern, lifecycle-based reactive counter:
100
113
 
101
114
  ```json
102
115
  {
103
116
  "div": {
104
- "cdom-state": { "count": 0 },
117
+ "onmount": $state({ count: 0 }, { name: 'counter', schema: 'auto', scope: $this }),
105
118
  "children": [
106
- { "h2": "Counter" },
107
- { "p": ["Current Count: ", "$/count"] },
108
- { "button": { "onclick": "$increment(/count)", "children": ["+"] } },
109
- { "button": { "onclick": "$decrement(/count)", "children": ["-"] } }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jprx",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "JSON Reactive Path eXpressions - A reactive expression language for JSON data",
5
5
  "main": "index.js",
6
6
  "type": "module",
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
- // Check local state chain first (via __state__ property set by handleCDOMState)
143
- let cur = context;
144
- while (cur) {
145
- const localState = cur.__state__;
146
- if (localState && rootName in localState) {
147
- return traverse(localState[rootName], rest);
148
- }
149
- cur = cur.__parent__;
150
- }
151
-
152
- // Then check global registry
153
- const rootSignal = registry?.get(rootName);
154
- if (!rootSignal) return undefined;
155
-
156
- // Root can be a signal or a state proxy
157
- return traverse(rootSignal, rest);
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
- // Check local state chain first
206
- let cur = context;
207
- while (cur) {
208
- const localState = cur.__state__;
209
- if (localState && rootName in localState) {
210
- return traverseAsContext(localState[rootName], segments);
211
- }
212
- cur = cur.__parent__;
213
- }
214
-
215
- // Then check global registry
216
- const rootSignal = registry?.get(rootName);
217
- if (!rootSignal) return undefined;
218
-
219
- return traverseAsContext(rootSignal, segments);
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. Event Placeholder ($event)
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((event) => {
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 an operator
485
- let isOpAfter = false;
486
- for (const op of opSymbols) {
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
- isOpAfter = true;
495
+ isPrefixOp = true;
489
496
  break;
490
497
  }
491
498
  }
492
- if (isOpAfter) {
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((event) => {
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
- return helper(unwrapSignal(left), unwrapSignal(right));
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(...resolvedArgs);
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
+ ]