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/.gemini/XPATH_IMPLEMENTATION.md +190 -0
- package/_headers +5 -0
- package/_routes.json +7 -0
- package/build-bundles.mjs +10 -0
- package/docs/articles/calculator-no-javascript-hackernoon.md +283 -0
- package/docs/articles/calculator-no-javascript.md +290 -0
- package/docs/articles/part1-reference.md +236 -0
- package/docs/calculator.cdomc +77 -0
- package/docs/calculator.css +316 -0
- package/docs/calculator.html +22 -0
- package/docs/cdom-nav.html +1 -1
- package/docs/cdom.html +2 -2
- package/functions/_middleware.js +20 -3
- package/jprx/README.md +1 -1
- package/jprx/helpers/calc.js +82 -0
- package/jprx/helpers/dom.js +69 -0
- package/jprx/helpers/logic.js +9 -3
- package/jprx/helpers/lookup.js +39 -0
- package/jprx/helpers/math.js +4 -0
- package/jprx/index.js +4 -0
- package/jprx/package.json +2 -2
- package/jprx/parser.js +394 -83
- package/lightview-all.js +2256 -168
- package/lightview-cdom.js +2176 -92
- package/lightview-x.js +4 -3
- package/lightview.js +14 -0
- package/package.json +3 -2
- package/src/lightview-cdom.js +153 -8
- package/src/lightview-x.js +5 -2
- package/src/lightview.js +19 -0
- package/test-xpath-preprocess.js +18 -0
- package/test-xpath.html +63 -0
- package/test_error.txt +0 -0
- package/test_output.txt +0 -0
- package/test_output_full.txt +0 -0
- package/tests/cdom/operators.test.js +141 -0
- package/wrangler.toml +1 -0
- package/cDOMIntro.md +0 -279
- package/start-dev.js +0 -93
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|
package/src/lightview-cdom.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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;
|
package/src/lightview-x.js
CHANGED
|
@@ -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
|
|
590
|
+
const CDOM = globalThis.LightviewCDOM;
|
|
591
|
+
const parser = CDOM?.parseCDOMC;
|
|
591
592
|
if (parser) {
|
|
592
593
|
try {
|
|
593
594
|
const obj = parser(content);
|
|
594
|
-
|
|
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
|
+
}
|
package/test-xpath.html
ADDED
|
@@ -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
|
package/test_output.txt
ADDED
|
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