lightview 2.3.4 → 2.3.6

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/lightview-x.js CHANGED
@@ -734,14 +734,15 @@
734
734
  }
735
735
  };
736
736
  const parseElements = (content, isJson, isHtml, el, element, isCdom = false, ext = "") => {
737
- var _a2;
738
737
  if (isJson) return Array.isArray(content) ? content : [content];
739
738
  if (isCdom && ext === "cdomc") {
740
- const parser = (_a2 = globalThis.LightviewCDOM) == null ? void 0 : _a2.parseCDOMC;
739
+ const CDOM = globalThis.LightviewCDOM;
740
+ const parser = CDOM == null ? void 0 : CDOM.parseCDOMC;
741
741
  if (parser) {
742
742
  try {
743
743
  const obj = parser(content);
744
- return Array.isArray(obj) ? obj : [obj];
744
+ const hydrated = CDOM.hydrate ? CDOM.hydrate(obj) : obj;
745
+ return Array.isArray(hydrated) ? hydrated : [hydrated];
745
746
  } catch (e) {
746
747
  console.warn("LightviewX: Failed to parse .cdomc:", e);
747
748
  return [];
package/lightview.js CHANGED
@@ -586,6 +586,11 @@
586
586
  const makeReactiveAttributes = (attributes, domNode) => {
587
587
  const reactiveAttrs = {};
588
588
  for (let [key, value] of Object.entries(attributes)) {
589
+ if (value && typeof value === "object" && value.__xpath__ && value.__static__) {
590
+ domNode.setAttribute(`data-xpath-${key}`, value.__xpath__);
591
+ reactiveAttrs[key] = value;
592
+ continue;
593
+ }
589
594
  if (key === "onmount" || key === "onunmount") {
590
595
  const state2 = getOrSet(nodeState, domNode, nodeStateFactory);
591
596
  state2[key] = value;
@@ -639,6 +644,7 @@
639
644
  return reactiveAttrs;
640
645
  };
641
646
  const processChildren = (children, targetNode, clearExisting = true) => {
647
+ var _a;
642
648
  if (clearExisting && targetNode.innerHTML !== void 0) {
643
649
  targetNode.innerHTML = "";
644
650
  }
@@ -687,6 +693,11 @@
687
693
  runner = effect(update);
688
694
  trackEffect(startMarker, runner);
689
695
  childElements.push(child);
696
+ } else if (child && typeof child === "object" && child.__xpath__ && child.__static__) {
697
+ const textNode = document.createTextNode("");
698
+ textNode.__xpathExpr = child.__xpath__;
699
+ targetNode.appendChild(textNode);
700
+ childElements.push(child);
690
701
  } else if (["string", "number", "boolean", "symbol"].includes(type) || child && type === "object" && child instanceof String) {
691
702
  targetNode.appendChild(document.createTextNode(child));
692
703
  childElements.push(child);
@@ -706,6 +717,9 @@
706
717
  childElements.push(childEl);
707
718
  }
708
719
  }
720
+ if (typeof ((_a = globalThis.LightviewCDOM) == null ? void 0 : _a.resolveStaticXPath) === "function") {
721
+ globalThis.LightviewCDOM.resolveStaticXPath(targetNode);
722
+ }
709
723
  return childElements;
710
724
  };
711
725
  const setupChildrenInTarget = (children, targetNode) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightview",
3
- "version": "2.3.4",
3
+ "version": "2.3.6",
4
4
  "description": "A lightweight reactive UI library with features of Bau, Juris, and HTMX",
5
5
  "main": "lightview.js",
6
6
  "workspaces": [
@@ -35,7 +35,8 @@
35
35
  "wrangler": "^4.54.0"
36
36
  },
37
37
  "dependencies": {
38
- "jprx": "^1.2.0",
38
+ "expr-eval": "^2.0.2",
39
+ "jprx": "^1.3.0",
39
40
  "linkedom": "^0.18.12",
40
41
  "marked": "^17.0.1"
41
42
  }
@@ -16,6 +16,8 @@ import { registerLookupHelpers } from '../jprx/helpers/lookup.js';
16
16
  import { registerStatsHelpers } from '../jprx/helpers/stats.js';
17
17
  import { registerStateHelpers, set } from '../jprx/helpers/state.js';
18
18
  import { registerNetworkHelpers } from '../jprx/helpers/network.js';
19
+ import { registerCalcHelpers } from '../jprx/helpers/calc.js';
20
+ import { registerDOMHelpers } from '../jprx/helpers/dom.js';
19
21
 
20
22
  import { signal, effect, getRegistry } from './reactivity/signal.js';
21
23
  import { state } from './reactivity/state.js';
@@ -33,6 +35,8 @@ registerLookupHelpers(registerHelper);
33
35
  registerStatsHelpers(registerHelper);
34
36
  registerStateHelpers((name, fn) => registerHelper(name, fn, { pathAware: true }));
35
37
  registerNetworkHelpers(registerHelper);
38
+ registerCalcHelpers(registerHelper);
39
+ registerDOMHelpers(registerHelper);
36
40
  registerHelper('move', (selector, location = 'beforeend') => {
37
41
  return {
38
42
  isLazy: true,
@@ -131,13 +135,14 @@ registerOperator('increment', '++', 'postfix', 80);
131
135
  registerOperator('decrement', '--', 'prefix', 80);
132
136
  registerOperator('decrement', '--', 'postfix', 80);
133
137
  registerOperator('toggle', '!!', 'prefix', 80);
138
+ registerOperator('set', '=', 'infix', 20);
134
139
 
135
140
  // Math infix operators (for expression syntax like $/a + $/b)
136
141
  // These REQUIRE surrounding whitespace to avoid ambiguity with path separators (especially for /)
137
142
  registerOperator('+', '+', 'infix', 50);
138
- registerOperator('-', '-', 'infix', 50);
139
- registerOperator('*', '*', 'infix', 60);
140
- registerOperator('/', '/', 'infix', 60);
143
+ registerOperator('-', '-', 'infix', 50, { requiresWhitespace: true });
144
+ registerOperator('*', '*', 'infix', 60, { requiresWhitespace: true });
145
+ registerOperator('/', '/', 'infix', 60, { requiresWhitespace: true });
141
146
 
142
147
  // Comparison infix operators
143
148
  registerOperator('gt', '>', 'infix', 40);
@@ -145,13 +150,16 @@ registerOperator('lt', '<', 'infix', 40);
145
150
  registerOperator('gte', '>=', 'infix', 40);
146
151
  registerOperator('lte', '<=', 'infix', 40);
147
152
  registerOperator('neq', '!=', 'infix', 40);
153
+ registerOperator('strictNeq', '!==', 'infix', 40);
154
+ registerOperator('eq', '==', 'infix', 40);
155
+ registerOperator('strictEq', '===', 'infix', 40);
148
156
 
149
157
  const localStates = new WeakMap();
150
158
 
151
159
  /**
152
160
  * Builds a reactive context object for a node by chaining all ancestor states.
153
161
  */
154
- export const getContext = (node, event = null) => {
162
+ const getContext = (node, event = null) => {
155
163
  return new Proxy({}, {
156
164
  get(_, prop) {
157
165
  if (prop === '$event' || prop === 'event') return event;
@@ -212,14 +220,14 @@ globalThis.Lightview.hooks.processAttribute = (domNode, key, value) => {
212
220
  /**
213
221
  * Legacy activation no longer needed.
214
222
  */
215
- export const activate = (root = document.body) => { };
223
+ const activate = (root = document.body) => { };
216
224
 
217
225
  const makeEventHandler = (expr) => (eventOrNode) => {
218
226
  const isEvent = eventOrNode && typeof eventOrNode === 'object' && 'target' in eventOrNode;
219
227
  const target = isEvent ? (eventOrNode.currentTarget || eventOrNode.target) : eventOrNode;
220
228
  const context = getContext(target, isEvent ? eventOrNode : null);
221
229
  const result = resolveExpression(expr, context);
222
- if (result && typeof result === 'object' && result.isLazy) return result.resolve(eventOrNode);
230
+ if (result && typeof result === 'object' && result.isLazy) return result.resolve(context);
223
231
  return result;
224
232
  };
225
233
 
@@ -228,7 +236,7 @@ const makeEventHandler = (expr) => (eventOrNode) => {
228
236
  * Traverses the object, converting expression strings (=...) into Signals/Computeds.
229
237
  * Establishes a __parent__ link for relative path resolution.
230
238
  */
231
- export const hydrate = (node, parent = null) => {
239
+ const hydrate = (node, parent = null) => {
232
240
  if (!node) return node;
233
241
 
234
242
  // 1. Handle Escape and Expressions
@@ -236,6 +244,14 @@ export const hydrate = (node, parent = null) => {
236
244
  if (typeof node === 'string' && node.startsWith("'=")) {
237
245
  return node.slice(1); // Strip the ' and return as literal
238
246
  }
247
+ // Escape sequence: '# at start produces a literal string starting with #
248
+ if (typeof node === 'string' && node.startsWith("'#")) {
249
+ return node.slice(1); // Strip the ' and return as literal
250
+ }
251
+ // XPath expression: # at start marks for static resolution
252
+ if (typeof node === 'string' && node.startsWith('#')) {
253
+ return { __xpath__: node.slice(1), __static__: true };
254
+ }
239
255
  if (typeof node === 'string' && node.startsWith('=')) {
240
256
  return parseExpression(node, parent);
241
257
  }
@@ -295,6 +311,12 @@ export const hydrate = (node, parent = null) => {
295
311
  // Escape sequence: '= at start produces a literal string starting with =
296
312
  if (typeof attrVal === 'string' && attrVal.startsWith("'=")) {
297
313
  value[attrKey] = attrVal.slice(1);
314
+ // Escape sequence: '# at start produces a literal string starting with #
315
+ } else if (typeof attrVal === 'string' && attrVal.startsWith("'#")) {
316
+ value[attrKey] = attrVal.slice(1);
317
+ // XPath expression: # at start marks for static resolution
318
+ } else if (typeof attrVal === 'string' && attrVal.startsWith('#')) {
319
+ value[attrKey] = { __xpath__: attrVal.slice(1), __static__: true };
298
320
  } else if (typeof attrVal === 'string' && attrVal.startsWith('=')) {
299
321
  if (attrKey.startsWith('on')) {
300
322
  value[attrKey] = makeEventHandler(attrVal);
@@ -311,6 +333,12 @@ export const hydrate = (node, parent = null) => {
311
333
  // Escape sequence: '= at start produces a literal string starting with =
312
334
  if (typeof value === 'string' && value.startsWith("'=")) {
313
335
  node[key] = value.slice(1);
336
+ // Escape sequence: '# at start produces a literal string starting with #
337
+ } else if (typeof value === 'string' && value.startsWith("'#")) {
338
+ node[key] = value.slice(1);
339
+ // XPath expression: # at start marks for static resolution
340
+ } else if (typeof value === 'string' && value.startsWith('#')) {
341
+ node[key] = { __xpath__: value.slice(1), __static__: true };
314
342
  } else if (typeof value === 'string' && value.startsWith('=')) {
315
343
  if (key === 'onmount' || key === 'onunmount' || key.startsWith('on')) {
316
344
  node[key] = makeEventHandler(value);
@@ -327,6 +355,103 @@ export const hydrate = (node, parent = null) => {
327
355
  return node;
328
356
  };
329
357
 
358
+ /**
359
+ * Validates that an XPath expression only uses backward-looking axes.
360
+ * Throws an error if forward-looking axes are detected.
361
+ * @param {string} xpath - The XPath expression to validate
362
+ */
363
+ const validateXPath = (xpath) => {
364
+ // Check for forbidden forward-looking axes
365
+ const forbiddenAxes = /\b(child|descendant|following|following-sibling)::/;
366
+ if (forbiddenAxes.test(xpath)) {
367
+ throw new Error(`XPath: Forward-looking axes not allowed during DOM construction: ${xpath}`);
368
+ }
369
+
370
+ // Also check for shorthand forward references like /div (implies child axis)
371
+ // But allow / for document root like /html
372
+ const hasShorthandChild = /\/[a-zA-Z]/.test(xpath) && !xpath.startsWith('/html');
373
+ if (hasShorthandChild) {
374
+ throw new Error(`XPath: Shorthand child axis (/) not allowed during DOM construction: ${xpath}`);
375
+ }
376
+ };
377
+
378
+ /**
379
+ * Resolves static XPath expressions marked during hydration.
380
+ * This is called after the DOM tree is fully constructed.
381
+ * Walks the tree and resolves all __xpath__ markers.
382
+ * @param {Node} rootNode - The root DOM node to start walking from
383
+ */
384
+ const resolveStaticXPath = (rootNode) => {
385
+ if (!rootNode || !rootNode.nodeType) return;
386
+
387
+ const walker = document.createTreeWalker(
388
+ rootNode,
389
+ NodeFilter.SHOW_ALL
390
+ );
391
+
392
+ const nodesToProcess = [];
393
+ let node = walker.nextNode();
394
+ while (node) {
395
+ nodesToProcess.push(node);
396
+ node = walker.nextNode();
397
+ }
398
+
399
+ // Process all nodes
400
+ for (const node of nodesToProcess) {
401
+ // Check for XPath markers in attributes
402
+ if (node.nodeType === Node.ELEMENT_NODE) {
403
+ const attributes = [...node.attributes];
404
+ for (const attr of attributes) {
405
+ if (attr.name.startsWith('data-xpath-')) {
406
+ const realAttr = attr.name.replace('data-xpath-', '');
407
+ const xpath = attr.value;
408
+
409
+ try {
410
+ validateXPath(xpath);
411
+ const result = document.evaluate(
412
+ xpath,
413
+ node,
414
+ null,
415
+ XPathResult.STRING_TYPE,
416
+ null
417
+ );
418
+ node.setAttribute(realAttr, result.stringValue);
419
+ node.removeAttribute(attr.name);
420
+ } catch (e) {
421
+ globalThis.console?.error(`[Lightview-CDOM] XPath resolution failed for attribute "${realAttr}":`, e.message);
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ // Check for XPath markers in text nodes
428
+ if (node.__xpathExpr) {
429
+ const xpath = node.__xpathExpr;
430
+ try {
431
+ validateXPath(xpath);
432
+ const result = document.evaluate(
433
+ xpath,
434
+ node, // Use text node as context, not its parent!
435
+ null,
436
+ XPathResult.STRING_TYPE,
437
+ null
438
+ );
439
+ node.textContent = result.stringValue;
440
+ delete node.__xpathExpr;
441
+ } catch (e) {
442
+ globalThis.console?.error(`[Lightview-CDOM] XPath resolution failed for text node:`, e.message);
443
+ }
444
+ }
445
+ }
446
+ };
447
+
448
+
449
+ // Prevent tree-shaking of parser functions by creating a side-effect
450
+ // These are used externally by lightview-x.js for .cdomc file loading
451
+ // The typeof check creates a runtime branch the bundler can't eliminate
452
+ if (typeof parseCDOMC !== 'function') throw new Error('parseCDOMC not found');
453
+ if (typeof parseJPRX !== 'function') throw new Error('parseJPRX not found');
454
+
330
455
  const LightviewCDOM = {
331
456
  registerHelper,
332
457
  registerOperator,
@@ -342,12 +467,32 @@ const LightviewCDOM = {
342
467
  handleCDOMBind: () => { },
343
468
  activate,
344
469
  hydrate,
470
+ resolveStaticXPath,
345
471
  version: '1.0.0'
346
472
  };
347
473
 
348
474
  // Global export for non-module usage
349
475
  if (typeof window !== 'undefined') {
350
- globalThis.LightviewCDOM = LightviewCDOM;
476
+ globalThis.LightviewCDOM = {};
477
+ Object.assign(globalThis.LightviewCDOM, LightviewCDOM);
351
478
  }
352
479
 
480
+
481
+ export {
482
+ registerHelper,
483
+ registerOperator,
484
+ parseExpression,
485
+ resolvePath,
486
+ resolvePathAsContext,
487
+ resolveExpression,
488
+ parseCDOMC,
489
+ parseJPRX,
490
+ unwrapSignal,
491
+ BindingTarget,
492
+ getContext,
493
+ activate,
494
+ hydrate,
495
+ resolveStaticXPath
496
+ };
497
+
353
498
  export default LightviewCDOM;
@@ -587,11 +587,14 @@ const fetchContent = async (src) => {
587
587
  const parseElements = (content, isJson, isHtml, el, element, isCdom = false, ext = '') => {
588
588
  if (isJson) return Array.isArray(content) ? content : [content];
589
589
  if (isCdom && ext === 'cdomc') {
590
- const parser = globalThis.LightviewCDOM?.parseCDOMC;
590
+ const CDOM = globalThis.LightviewCDOM;
591
+ const parser = CDOM?.parseCDOMC;
591
592
  if (parser) {
592
593
  try {
593
594
  const obj = parser(content);
594
- return Array.isArray(obj) ? obj : [obj];
595
+ // Hydrate the parsed object to convert expression strings to reactive signals
596
+ const hydrated = CDOM.hydrate ? CDOM.hydrate(obj) : obj;
597
+ return Array.isArray(hydrated) ? hydrated : [hydrated];
595
598
  } catch (e) {
596
599
  console.warn('LightviewX: Failed to parse .cdomc:', e);
597
600
  return [];
package/src/lightview.js CHANGED
@@ -339,6 +339,14 @@ const makeReactiveAttributes = (attributes, domNode) => {
339
339
  const reactiveAttrs = {};
340
340
 
341
341
  for (let [key, value] of Object.entries(attributes)) {
342
+ // Handle XPath markers from hydration
343
+ if (value && typeof value === 'object' && value.__xpath__ && value.__static__) {
344
+ // Mark attribute for later XPath resolution
345
+ domNode.setAttribute(`data-xpath-${key}`, value.__xpath__);
346
+ reactiveAttrs[key] = value;
347
+ continue;
348
+ }
349
+
342
350
  if (key === 'onmount' || key === 'onunmount') {
343
351
  const state = getOrSet(nodeState, domNode, nodeStateFactory);
344
352
  state[key] = value;
@@ -483,6 +491,12 @@ const processChildren = (children, targetNode, clearExisting = true) => {
483
491
  runner = effect(update);
484
492
  trackEffect(startMarker, runner);
485
493
  childElements.push(child);
494
+ } else if (child && typeof child === 'object' && child.__xpath__ && child.__static__) {
495
+ // XPath marker - create text node with marker for later resolution
496
+ const textNode = document.createTextNode('');
497
+ textNode.__xpathExpr = child.__xpath__;
498
+ targetNode.appendChild(textNode);
499
+ childElements.push(child);
486
500
  } else if (['string', 'number', 'boolean', 'symbol'].includes(type) || (child && type === 'object' && child instanceof String)) {
487
501
  // Static text
488
502
  targetNode.appendChild(document.createTextNode(child));
@@ -506,6 +520,11 @@ const processChildren = (children, targetNode, clearExisting = true) => {
506
520
  }
507
521
  }
508
522
 
523
+ // Resolve static XPath expressions after DOM tree is constructed
524
+ if (typeof globalThis.LightviewCDOM?.resolveStaticXPath === 'function') {
525
+ globalThis.LightviewCDOM.resolveStaticXPath(targetNode);
526
+ }
527
+
509
528
  return childElements;
510
529
  };
511
530
 
@@ -0,0 +1,18 @@
1
+ // Test preprocessXPath function
2
+ import { parseCDOMC } from './jprx/parser.js';
3
+
4
+ const testInput = `{
5
+ button: {
6
+ id: "7",
7
+ children: [#../@id]
8
+ }
9
+ }`;
10
+
11
+ console.log('Test input:', testInput);
12
+
13
+ try {
14
+ const result = parseCDOMC(testInput);
15
+ console.log('Parsed successfully:', result);
16
+ } catch (e) {
17
+ console.error('Parse failed:', e.message);
18
+ }
@@ -0,0 +1,63 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>XPath Test</title>
8
+ <script type="module">
9
+ import { parseCDOMC, hydrate } from './src/lightview-cdom.js';
10
+
11
+ // Test 1: Static XPath in attributes
12
+ const cdomcInput = `{
13
+ tag: div,
14
+ attributes: {
15
+ id: parent-div,
16
+ data-theme: dark
17
+ },
18
+ children: [{
19
+ tag: button,
20
+ attributes: {
21
+ data-parent-id: #../@id,
22
+ data-parent-theme: #../@data-theme
23
+ },
24
+ children: ["Button with parent data"]
25
+ }]
26
+ }`;
27
+
28
+ console.log('Parsing cDOMC input...');
29
+ const parsed = parseCDOMC(cdomcInput);
30
+ console.log('Parsed:', parsed);
31
+
32
+ console.log('Hydrating...');
33
+ const hydrated = hydrate(parsed);
34
+ console.log('Hydrated:', hydrated);
35
+
36
+ // Mount to DOM
37
+ document.addEventListener('DOMContentLoaded', () => {
38
+ const container = document.getElementById('app');
39
+ if (hydrated && hydrated.tag) {
40
+ const el = globalThis.Lightview.tags[hydrated.tag](
41
+ hydrated.attributes || {},
42
+ hydrated.children || []
43
+ );
44
+ container.appendChild(el.domEl);
45
+ console.log('Mounted to DOM');
46
+ console.log('Button element:', el.domEl.querySelector('button'));
47
+ }
48
+ });
49
+ </script>
50
+ </head>
51
+
52
+ <body>
53
+ <h1>XPath Test</h1>
54
+ <div id="app"></div>
55
+
56
+ <h2>Test Cases:</h2>
57
+ <ul>
58
+ <li>Button should have <code>data-parent-id="parent-div"</code></li>
59
+ <li>Button should have <code>data-parent-theme="dark"</code></li>
60
+ </ul>
61
+ </body>
62
+
63
+ </html>
package/test_error.txt ADDED
Binary file
Binary file
Binary file
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import Lightview from '../../src/lightview.js';
3
+ import LightviewX from '../../src/lightview-x.js';
4
+ import LightviewCDOM from '../../src/lightview-cdom.js';
5
+ import { resolveExpression, unwrapSignal } from '../../jprx/parser.js';
6
+
7
+ /**
8
+ * Operator Tests
9
+ *
10
+ * Tests all registered operators using property-based context.
11
+ */
12
+ describe('JPRX Operators', () => {
13
+ beforeEach(() => {
14
+ globalThis.window = globalThis;
15
+ globalThis.Lightview = Lightview;
16
+ globalThis.LightviewX = LightviewX;
17
+ globalThis.LightviewCDOM = LightviewCDOM;
18
+ Lightview.registry.clear();
19
+ });
20
+
21
+ describe('Context-based Evaluation', () => {
22
+ it('resolves simple properties from context', () => {
23
+ const context = { a: 10, b: 20 };
24
+ expect(resolveExpression('a', context)).toBe(10);
25
+ expect(resolveExpression('b', context)).toBe(20);
26
+ });
27
+
28
+ it('performs math with context variables', () => {
29
+ const context = { a: 10, b: 5 };
30
+ expect(resolveExpression('a + b', context)).toBe(15);
31
+ expect(resolveExpression('a - b', context)).toBe(5);
32
+ expect(resolveExpression('a * b', context)).toBe(50);
33
+ expect(resolveExpression('a / b', context)).toBe(2);
34
+ });
35
+
36
+ it('handles comparison operators', () => {
37
+ const context = { a: 10, b: 5, c: 10 };
38
+ expect(resolveExpression('a > b', context)).toBe(true);
39
+ expect(resolveExpression('b > a', context)).toBe(false);
40
+ expect(resolveExpression('a < b', context)).toBe(false);
41
+ expect(resolveExpression('b < a', context)).toBe(true);
42
+ expect(resolveExpression('a >= c', context)).toBe(true);
43
+ expect(resolveExpression('a <= c', context)).toBe(true);
44
+ expect(resolveExpression('a == c', context)).toBe(true);
45
+ expect(resolveExpression('a === c', context)).toBe(true);
46
+ expect(resolveExpression('a != b', context)).toBe(true);
47
+ expect(resolveExpression('a == b', context)).toBe(false);
48
+ expect(resolveExpression('a === b', context)).toBe(false);
49
+ });
50
+
51
+ it('handles equality with different types', () => {
52
+ const context = { a: 5, b: '5' };
53
+ expect(resolveExpression('a == b', context)).toBe(true);
54
+ expect(resolveExpression('a === b', context)).toBe(false);
55
+ });
56
+ });
57
+
58
+ describe('Mutation Operators (Context-based)', () => {
59
+ it('increments a property: ++count', () => {
60
+ const context = { count: 5 };
61
+ const result = resolveExpression('++count', context);
62
+ expect(result).toBe(6);
63
+ expect(context.count).toBe(6);
64
+ });
65
+
66
+ it('decrements a property: count--', () => {
67
+ const context = { count: 10 };
68
+ const result = resolveExpression('count--', context);
69
+ expect(result).toBe(9);
70
+ expect(context.count).toBe(9);
71
+ });
72
+
73
+ it('toggles a property: !!flag', () => {
74
+ const context = { flag: false };
75
+ const result = resolveExpression('!!flag', context);
76
+ expect(result).toBe(true);
77
+ expect(context.flag).toBe(true);
78
+ });
79
+ });
80
+
81
+ describe('Assignment Operator', () => {
82
+ it('assigns value to context property: x = 42', () => {
83
+ const context = { x: 0 };
84
+ const result = resolveExpression('x = 42', context);
85
+ expect(result).toBe(42);
86
+ expect(context.x).toBe(42);
87
+ });
88
+
89
+ it('assigns complex values: x = a + b', () => {
90
+ const context = { x: 0, a: 10, b: 20 };
91
+ // Note: complex right-hand side currently requires helper syntax or better Pratt integration
92
+ // But with current parser, it should work if we use precedence correctly
93
+ const result = resolveExpression('x = a + b', context);
94
+ expect(result).toBe(30);
95
+ expect(context.x).toBe(30);
96
+ });
97
+
98
+ it('assigns object literals: user = { name: "Alice" }', () => {
99
+ const context = { user: null };
100
+ resolveExpression('user = { name: "Alice" }', context);
101
+ expect(context.user).toEqual({ name: 'Alice' });
102
+ });
103
+ });
104
+
105
+ describe('Whitespace and Ambiguity', () => {
106
+ it('handles packed assignment: count=0', () => {
107
+ const context = { count: 5 };
108
+ resolveExpression('count=0', context);
109
+ expect(context.count).toBe(0);
110
+ });
111
+
112
+ it('handles packed addition: a+b', () => {
113
+ const context = { a: 1, b: 2 };
114
+ expect(resolveExpression('a+b', context)).toBe(3);
115
+ });
116
+
117
+ it('requires whitespace for division: a / b', () => {
118
+ const context = { a: 10, b: 2, 'a/b': 99 };
119
+ // Correct division
120
+ expect(resolveExpression('a / b', context)).toBe(5);
121
+ // Path resolution (no spaces)
122
+ expect(resolveExpression('a/b', context)).toBe(99);
123
+ });
124
+
125
+ it('requires whitespace for subtraction: a - b', () => {
126
+ const context = { a: 10, b: 2, 'a-b': 42 };
127
+ // Subtraction
128
+ expect(resolveExpression('a - b', context)).toBe(8);
129
+ // Kebab-case path
130
+ expect(resolveExpression('a-b', context)).toBe(42);
131
+ });
132
+ });
133
+
134
+ describe('Registry Integration (Global Paths)', () => {
135
+ it('works with global paths: =/global/x + y', () => {
136
+ LightviewX.state({ x: 100 }, 'global');
137
+ const context = { y: 50 };
138
+ expect(resolveExpression('=/global/x + y', context)).toBe(150);
139
+ });
140
+ });
141
+ });
package/wrangler.toml CHANGED
@@ -13,3 +13,4 @@ compatibility_date = "2024-01-01"
13
13
  # Uncomment below for Workers mode (not Pages mode):
14
14
  [assets]
15
15
  directory = "./dist"
16
+