lightview 2.3.5 → 2.3.7

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.
@@ -0,0 +1,186 @@
1
+ # XPath Integration for cDOM - Implementation Summary
2
+
3
+ ## Overview
4
+ XPath support has been added to cDOM, allowing developers to navigate the DOM structure during element definition. This enables referencing parent/ancestor attributes without duplication.
5
+
6
+ ## Features Implemented
7
+
8
+ ### 1. Static XPath (`#` prefix)
9
+ - **Syntax:** `#xpath-expression`
10
+ - **Evaluation:** Once during DOM construction (two-pass approach)
11
+ - **Reactivity:** None - becomes a static string value
12
+ - **Escape Sequence:** `'#` produces literal `#` string
13
+
14
+ children: ["#@aria-label"]
15
+ }
16
+ ```
17
+
18
+ **Note on Text Nodes:** Even though the XPath refers to a child of the element, it is evaluated relative to the element itself (`self::*`). This improves consistency between attributes and children, and avoids "Operation is not supported" errors in browsers when using a Text node as an XPath context.
19
+
20
+ ### 2. Reactive XPath (`=xpath()` helper)
21
+ - **Syntax:** `=xpath('expression')`
22
+ - **Evaluation:** As JPRX helper, returns computed signal
23
+ - **Reactivity:** Re-evaluates when observed DOM changes (TODO: full MutationObserver)
24
+ - **Composable:** Can mix with JPRX expressions
25
+
26
+ **Example:**
27
+ ```javascript
28
+ {
29
+ attributes: {
30
+ title: "=xpath('../@id')",
31
+ class: "=concat(xpath('../@disabled'), ' ', $/theme)"
32
+ }
33
+ }
34
+ ```
35
+
36
+ ## Allowed XPath Axes (Backward-Only)
37
+ ✅ `self::` or `.` - current node
38
+ ✅ `parent::` or `..` - parent node
39
+ ✅ `ancestor::` - all ancestors
40
+ ✅ `ancestor-or-self::`
41
+ ✅ `preceding-sibling::` - earlier siblings
42
+ ✅ `preceding::` - all preceding nodes
43
+
44
+ ❌ `child::` - forward reference (not allowed)
45
+ ❌ `descendant::` - forward reference (not allowed)
46
+ ❌ `following::` - forward reference (not allowed)
47
+ ❌ `following-sibling::` - forward reference (not allowed)
48
+
49
+ ## Implementation Details
50
+
51
+ ### Files Modified
52
+
53
+ 1. **`jprx/parser.js`**
54
+ - Added `preprocessXPath()` function to quote unquoted `#xpath` expressions in cDOMC
55
+ - Handles escape sequences (`'#`)
56
+ - Skips quoted strings and comments
57
+
58
+ 2. **`src/lightview-cdom.js`**
59
+ - Updated `hydrate()` to detect and mark `#xpath` expressions
60
+ - Added `validateXPath()` to ensure no forward-looking axes
61
+ - Added `resolveStaticXPath()` for two-pass XPath resolution
62
+ - Registered `registerDOMHelpers` for reactive xpath() helper
63
+ - Exported `resolveStaticXPath` for global access
64
+
65
+ 3. **`src/lightview.js`**
66
+ - Updated `makeReactiveAttributes()` to handle `__xpath__` markers from hydration
67
+ - Updated `processChildren()` to handle XPath markers in children arrays
68
+ - Added call to `resolveStaticXPath()` after DOM tree construction
69
+
70
+ 4. **`jprx/helpers/dom.js`** (NEW)
71
+ - Created reactive `xpath()` helper function
72
+ - Returns computed signal for XPath evaluation
73
+ - Validates against forward-looking axes
74
+ - TODO: Add Mutation Observer for full reactivity
75
+
76
+ 5. **`jprx/index.js`**
77
+ - Exported `registerDOMHelpers`
78
+
79
+ ### Two-Pass Resolution Flow
80
+
81
+ **Pass 1: Build DOM Tree**
82
+ 1. `parseCDOMC()` preprocesses input, quoting unquoted `#xpath` expressions
83
+ 2. `hydrate()` detects `#xpath` strings, marks with `{ __xpath__: 'expr', __static__: true }`
84
+ 3. `element()` creates DOM nodes
85
+ 4. `makeReactiveAttributes()` sets `data-xpath-{attrname}` markers on nodes
86
+ 5. `processChildren()` creates text nodes with `__xpathExpr` property
87
+ 6. Tree is fully constructed via `appendChild()`
88
+
89
+ **Pass 2: Resolve XPath**
90
+ 7. `resolveStaticXPath()` called after tree construction
91
+ 8. Walks tree with `TreeWalker`
92
+ 9. Finds nodes with `data-xpath-*` attributes or `__xpathExpr` properties
93
+ 10. Validates XPath expressions (no forward-looking)
94
+ 11. Calls browser's `document.evaluate()` with node as context
95
+ 12. Sets resolved values and cleans up markers
96
+
97
+ ## Usage Examples
98
+
99
+ ### cDOMC (Unquoted)
100
+ ```javascript
101
+ {
102
+ tag: div,
103
+ attributes: {
104
+ id: parent-div,
105
+ data-theme: dark
106
+ },
107
+ children: [{
108
+ tag: button,
109
+ attributes: {
110
+ data-parent-id: #../@id,
111
+ class: "=concat(xpath('../@data-theme'), '-button')"
112
+ },
113
+ children: [#@aria-label]
114
+ }]
115
+ }
116
+ ```
117
+
118
+ ### JSON
119
+ ```javascript
120
+ {
121
+ "tag": "div",
122
+ "attributes": {
123
+ "id": "parent-div",
124
+ "data-theme": "dark"
125
+ },
126
+ "children": [{
127
+ "tag": "button",
128
+ "attributes": {
129
+ "data-parent-id": "#../@id",
130
+ "class": "=concat(xpath('../@data-theme'), '-button')"
131
+ },
132
+ "children": ["#@aria-label"]
133
+ }]
134
+ }
135
+ ```
136
+
137
+ ## Testing
138
+
139
+ Test file created: `test-xpath.html`
140
+
141
+ Open in browser to verify:
142
+ - Static XPath in attributes
143
+ - Parent attribute access
144
+ - Escape sequences
145
+
146
+ ## Key Features
147
+
148
+ 1. **Unquoted XPath in cDOMC**: Fully supported through structural integration with the parser character loop.
149
+ - ✅ Working: `children: [#@id]`
150
+ - ✅ Working: `children: [#div[@id='1']]` (Complex paths with brackets now work!)
151
+
152
+ 2. **Reactive xpath() helper**: Currently evaluates once via computed signal. Full reactivity with MutationObserver is TODO.
153
+
154
+ 3. **XPath complexity**: Works with paths like `../@id`, `../../@attr`, and predicate-based paths.
155
+
156
+ 4. **Consistency**: Both attributes and children evaluate XPath relative to the Element node. This avoids browser issues with Text node context and simplifies the developer's mental model.
157
+
158
+ ## Implementation: Structural Parser Integration
159
+
160
+ Instead of a string preprocessor hack, XPath support is now integrated directly into the `parseCDOMC` word parser.
161
+
162
+ - When `parseWord` encounters `#`, it enters a permissive "expression mode" (similar to `=` JPRX).
163
+ - In this mode, it tracks nesting depth for `[]`, `{}`, and `()`.
164
+ - It handles internal quotes within the XPath safely.
165
+ - It only terminates the unquoted word when nesting depth is `0` and it encounters a structural cDOMC delimiter (e.g., `]`, `}`, or `,`).
166
+
167
+ ## Bug Fixes Applied
168
+
169
+ 1. **Structural Delimiters**: Fixed the issue where unquoted XPath would consume the closing bracket of its container.
170
+ 2. **Complex Paths**: Enabled support for square brackets and other structural characters within the XPath itself.
171
+ 3. **Text node context**: Fixed XPath evaluation to use the text node itself as context, not its parent element.
172
+
173
+ ## Next Steps
174
+
175
+ 1. Add MutationObserver to reactive xpath() helper for full reactivity
176
+ 2. Add comprehensive unit tests for both static and reactive XPath
177
+ 3. Document XPath support in cDOM documentation
178
+ 4. Add more examples showing different XPath axes
179
+ 5. Performance optimization for large DOMs
180
+
181
+ ## Lint Issues
182
+
183
+ Note: The following lint warnings exist but don't affect functionality:
184
+ - **File length**: `/mnt/c/Users/Owner/AntigravityProjects/lightview/jprx/parser.js` has 1552 lines (limit 500)
185
+ - This is a large parser file; could be split/refactored in the future
186
+ - **Escape characters**: Some unnecessary escapes in regex (cosmetic issue)
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "lightview",
3
+ "version": "2.3.7",
4
+ "description": "A lightweight reactive UI library with features of Bau, Juris, and HTMX",
5
+ "main": "lightview.js",
6
+ "workspaces": [
7
+ "jprx"
8
+ ],
9
+ "directories": {
10
+ "doc": "docs"
11
+ },
12
+ "scripts": {
13
+ "dev": "wrangler pages dev . --port 3000",
14
+ "preview": "npm run build && wrangler pages dev ./dist --port 8788",
15
+ "build": "node build-bundles.mjs && node build.js",
16
+ "watch": "node build-bundles.mjs && node build.js --watch --env=dev",
17
+ "deploy": "npm run build && wrangler pages deploy dist",
18
+ "test": "echo \"Error: no test specified\" && exit 1"
19
+ },
20
+ "keywords": [],
21
+ "author": "Simon Y. Blackwell, AnyWhichWay LLC",
22
+ "license": "MIT",
23
+ "type": "commonjs",
24
+ "devDependencies": {
25
+ "@grucloud/bau": "^0.106.0",
26
+ "acorn": "^8.15.0",
27
+ "acorn-walk": "^8.3.4",
28
+ "htmx.org": "^2.0.8",
29
+ "jsdom": "^27.4.0",
30
+ "juris": "^0.9.0",
31
+ "react": "^19.2.3",
32
+ "terser": "^5.24.0",
33
+ "vite": "^5.0.0",
34
+ "vitest": "^4.0.16",
35
+ "wrangler": "^4.54.0"
36
+ },
37
+ "dependencies": {
38
+ "expr-eval": "^2.0.2",
39
+ "jprx": "^1.3.1",
40
+ "linkedom": "^0.18.12",
41
+ "marked": "^17.0.1"
42
+ }
43
+ }
package/_headers CHANGED
@@ -2,3 +2,8 @@
2
2
  Access-Control-Allow-Origin: *
3
3
  Access-Control-Allow-Methods: GET, OPTIONS
4
4
  Access-Control-Allow-Headers: *
5
+
6
+ /*.cdom
7
+ Content-Type: application/cdom
8
+ /*.cdomc
9
+ Content-Type: application/cdomc
package/_routes.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 1,
3
+ "include": [
4
+ "/*"
5
+ ],
6
+ "exclude": []
7
+ }
package/build-bundles.mjs CHANGED
@@ -102,4 +102,14 @@ if (isWatch) {
102
102
  }, 300); // 300ms debounce
103
103
  }
104
104
  });
105
+
106
+ // Also watch jprx directory for parser changes
107
+ watch(resolve(__dirname, 'jprx'), { recursive: true }, (event, filename) => {
108
+ if (filename && !filename.includes('~')) {
109
+ clearTimeout(debounceTimer);
110
+ debounceTimer = setTimeout(() => {
111
+ runBuilds();
112
+ }, 300);
113
+ }
114
+ });
105
115
  }
@@ -0,0 +1,185 @@
1
+ (function() {
2
+ "use strict";
3
+ (() => {
4
+ const base = (shellPath) => {
5
+ if (typeof window === "undefined" || document.getElementById("content")) return;
6
+ const url = new URL(shellPath, globalThis.location.href);
7
+ url.searchParams.set("load", globalThis.location.pathname);
8
+ globalThis.location.href = url.toString();
9
+ };
10
+ const router = (options = {}) => {
11
+ const { base: base2 = "", contentEl, notFound, debug, onResponse, onStart } = options;
12
+ const chains = [];
13
+ const normalizePath = (p) => {
14
+ if (!p) return "/";
15
+ let hash = "";
16
+ if (p.includes("#")) {
17
+ [p, hash] = p.split("#");
18
+ hash = "#" + hash;
19
+ }
20
+ try {
21
+ if (p.startsWith("http") || p.startsWith("//")) p = new URL(p, globalThis.location.origin).pathname;
22
+ } catch (e) {
23
+ }
24
+ if (base2 && p.startsWith(base2)) p = p.slice(base2.length);
25
+ return (p.replace(/\/+$/, "").replace(/^([^/])/, "/$1") || "/") + hash;
26
+ };
27
+ const createMatcher = (pattern) => {
28
+ if (typeof pattern === "function") return pattern;
29
+ return (ctx) => {
30
+ const { path } = ctx;
31
+ const pathOnly = path.split("#")[0];
32
+ if (pattern instanceof RegExp) {
33
+ const m2 = pathOnly.match(pattern);
34
+ return m2 ? { ...ctx, match: m2 } : null;
35
+ }
36
+ if (pattern === "*" || pattern === pathOnly) return { ...ctx, wildcard: pathOnly };
37
+ const keys = [];
38
+ const regexStr = "^" + pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, "(.*)").replace(/:([^/]+)/g, (_, k) => (keys.push(k), "([^/]+)")) + "$";
39
+ const m = pathOnly.match(new RegExp(regexStr));
40
+ if (m) {
41
+ const params = {};
42
+ keys.forEach((k, i) => params[k] = m[i + 1]);
43
+ return { ...ctx, params, wildcard: m[1] };
44
+ }
45
+ return null;
46
+ };
47
+ };
48
+ const createReplacer = (pat) => (ctx) => {
49
+ const [path, hash] = ctx.path.split("#");
50
+ return {
51
+ ...ctx,
52
+ path: pat.replace(/\*|:([^/]+)/g, (m, k) => {
53
+ var _a;
54
+ return (k ? (_a = ctx.params) == null ? void 0 : _a[k] : ctx.wildcard) || m;
55
+ }) + (hash ? "#" + hash : "")
56
+ };
57
+ };
58
+ const fetchHandler = async (ctx) => {
59
+ try {
60
+ const pathOnly = ctx.path.split("#")[0];
61
+ const res = await fetch(pathOnly);
62
+ if (res.ok) return res;
63
+ } catch (e) {
64
+ if (debug) console.error("[Router] Fetch error:", e);
65
+ }
66
+ return null;
67
+ };
68
+ const use = (...args) => {
69
+ const chain = args.map((arg, i) => i === 0 && typeof arg !== "function" ? createMatcher(arg) : typeof arg === "string" ? createReplacer(arg) : arg);
70
+ if (contentEl && !chain.some((f) => f.name === "fetchHandler" || args.some((a) => typeof a === "function"))) chain.push(fetchHandler);
71
+ chains.push(chain);
72
+ return routerInstance;
73
+ };
74
+ const route = async (raw) => {
75
+ let ctx = { path: normalizePath(raw), contentEl };
76
+ if (debug) console.log(`[Router] Routing: ${ctx.path}`);
77
+ for (const chain of chains) {
78
+ let res = ctx, failed = false;
79
+ for (const fn of chain) {
80
+ try {
81
+ res = await fn(res);
82
+ if (res instanceof Response) return res;
83
+ if (!res) {
84
+ failed = true;
85
+ break;
86
+ }
87
+ } catch (e) {
88
+ console.error("[Router] Chain error:", e);
89
+ failed = true;
90
+ break;
91
+ }
92
+ }
93
+ if (!failed) ctx = typeof res === "string" ? { ...ctx, path: res } : { ...ctx, ...res };
94
+ }
95
+ return notFound ? notFound(ctx) : null;
96
+ };
97
+ const handleRequest = async (path) => {
98
+ var _a, _b;
99
+ if (onStart) onStart(path);
100
+ const internals = (_a = globalThis.Lightview) == null ? void 0 : _a.internals;
101
+ const scrollMap = (_b = internals == null ? void 0 : internals.saveScrolls) == null ? void 0 : _b.call(internals);
102
+ const res = await route(path);
103
+ if (!res) return console.warn(`[Router] No route: ${path}`);
104
+ if (res.ok && contentEl) {
105
+ contentEl.innerHTML = await res.text();
106
+ contentEl.querySelectorAll("script").forEach((s) => {
107
+ const n = document.createElement("script");
108
+ [...s.attributes].forEach((a) => n.setAttribute(a.name, a.value));
109
+ n.textContent = s.textContent;
110
+ s.replaceWith(n);
111
+ });
112
+ if ((internals == null ? void 0 : internals.restoreScrolls) && scrollMap) {
113
+ internals.restoreScrolls(scrollMap);
114
+ }
115
+ const urlParts = path.split("#");
116
+ const hash = urlParts.length > 1 ? "#" + urlParts[1] : "";
117
+ if (hash) {
118
+ requestAnimationFrame(() => {
119
+ requestAnimationFrame(() => {
120
+ const id = hash.slice(1);
121
+ const target = document.getElementById(id);
122
+ if (target) {
123
+ target.style.scrollMarginTop = "calc(var(--site-nav-height, 0px) + 2rem)";
124
+ target.scrollIntoView({ behavior: "smooth", block: "start", inline: "start" });
125
+ }
126
+ });
127
+ });
128
+ }
129
+ }
130
+ if (onResponse) await onResponse(res, path);
131
+ return res;
132
+ };
133
+ const navigate = (path) => {
134
+ const p = normalizePath(path);
135
+ return handleRequest(base2 + p).then((r) => {
136
+ let dest = (r == null ? void 0 : r.url) ? new URL(r.url, globalThis.location.origin).pathname : base2 + p;
137
+ if (p.includes("#") && !dest.includes("#")) {
138
+ dest += "#" + p.split("#")[1];
139
+ }
140
+ globalThis.history.pushState({ path: dest }, "", dest);
141
+ }).catch((e) => console.error("[Router] Nav error:", e));
142
+ };
143
+ const start = async () => {
144
+ const load = new URLSearchParams(globalThis.location.search).get("load");
145
+ globalThis.onpopstate = (e) => {
146
+ var _a;
147
+ return handleRequest(((_a = e.state) == null ? void 0 : _a.path) || normalizePath(globalThis.location.pathname + globalThis.location.hash));
148
+ };
149
+ document.onclick = (e) => {
150
+ const path = e.composedPath();
151
+ const a = path.find((el) => {
152
+ var _a;
153
+ return el.tagName === "A" && ((_a = el.hasAttribute) == null ? void 0 : _a.call(el, "href"));
154
+ });
155
+ if (!a || a.target === "_blank" || /^(http|#|mailto|tel)/.test(a.getAttribute("href"))) return;
156
+ const url = new URL(a.href, document.baseURI);
157
+ if (url.origin === globalThis.location.origin) {
158
+ e.preventDefault();
159
+ const fullPath = url.pathname + url.search + url.hash;
160
+ navigate(normalizePath(fullPath));
161
+ }
162
+ };
163
+ const init = load || normalizePath(globalThis.location.pathname + globalThis.location.hash);
164
+ globalThis.history.replaceState({ path: init }, "", base2 + init);
165
+ return handleRequest(init).then(() => routerInstance);
166
+ };
167
+ const routerInstance = { use, navigate, start };
168
+ return routerInstance;
169
+ };
170
+ const LightviewRouter = { base, router };
171
+ if (typeof module !== "undefined" && module.exports) module.exports = LightviewRouter;
172
+ else if (typeof window !== "undefined") {
173
+ globalThis.LightviewRouter = LightviewRouter;
174
+ try {
175
+ const script = document.currentScript;
176
+ if (script && script.src.includes("?")) {
177
+ const params = new URL(script.src).searchParams;
178
+ const b = params.get("base");
179
+ if (b) LightviewRouter.base(b);
180
+ }
181
+ } catch (e) {
182
+ }
183
+ }
184
+ })();
185
+ })();