lightview 2.2.2 → 2.3.4
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/cDOMIntro.md +279 -0
- package/docs/about.html +15 -12
- package/docs/api/computed.html +1 -1
- package/docs/api/effects.html +1 -1
- package/docs/api/elements.html +56 -25
- package/docs/api/enhance.html +1 -1
- package/docs/api/hypermedia.html +1 -1
- package/docs/api/index.html +1 -1
- package/docs/api/nav.html +28 -3
- package/docs/api/signals.html +1 -1
- package/docs/api/state.html +283 -85
- package/docs/assets/js/examplify.js +2 -1
- package/docs/cdom-nav.html +3 -2
- package/docs/cdom.html +383 -114
- package/jprx/README.md +112 -71
- package/jprx/helpers/state.js +21 -0
- package/jprx/package.json +1 -1
- package/jprx/parser.js +136 -86
- package/jprx/specs/expressions.json +71 -0
- package/jprx/specs/helpers.json +150 -0
- package/lightview-all.js +618 -431
- package/lightview-cdom.js +311 -605
- package/lightview-router.js +6 -0
- package/lightview-x.js +226 -54
- package/lightview.js +351 -42
- package/package.json +2 -1
- package/src/lightview-cdom.js +211 -315
- package/src/lightview-router.js +10 -0
- package/src/lightview-x.js +121 -1
- package/src/lightview.js +88 -16
- package/src/reactivity/signal.js +73 -29
- package/src/reactivity/state.js +84 -21
- package/tests/cdom/fixtures/helpers.cdomc +24 -24
- package/tests/cdom/helpers.test.js +28 -28
- package/tests/cdom/parser.test.js +39 -114
- package/tests/cdom/reactivity.test.js +32 -29
- package/tests/jprx/spec.test.js +99 -0
- package/tests/cdom/loader.test.js +0 -125
package/src/lightview-cdom.js
CHANGED
|
@@ -33,6 +33,96 @@ registerLookupHelpers(registerHelper);
|
|
|
33
33
|
registerStatsHelpers(registerHelper);
|
|
34
34
|
registerStateHelpers((name, fn) => registerHelper(name, fn, { pathAware: true }));
|
|
35
35
|
registerNetworkHelpers(registerHelper);
|
|
36
|
+
registerHelper('move', (selector, location = 'beforeend') => {
|
|
37
|
+
return {
|
|
38
|
+
isLazy: true,
|
|
39
|
+
resolve: (eventOrNode) => {
|
|
40
|
+
const isEvent = eventOrNode && typeof eventOrNode === 'object' && 'target' in eventOrNode;
|
|
41
|
+
const node = isEvent ? (eventOrNode.currentTarget || eventOrNode.target) : eventOrNode;
|
|
42
|
+
if (!(node instanceof Node) || !selector) return;
|
|
43
|
+
|
|
44
|
+
const target = document.querySelector(selector);
|
|
45
|
+
if (!target) {
|
|
46
|
+
console.warn(`[Lightview-CDOM] move target not found: ${selector}`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Identity logic: if node has ID, check for existing sibling or descendant in target
|
|
51
|
+
if (node.id) {
|
|
52
|
+
// We escape the ID for querySelector
|
|
53
|
+
const escapedId = CSS.escape(node.id);
|
|
54
|
+
// Check if the target itself is the node (unlikely but safe)
|
|
55
|
+
if (target.id === node.id && target !== node) {
|
|
56
|
+
target.replaceWith(node);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Check for existing element in target
|
|
60
|
+
const existing = target.querySelector(`#${escapedId}`);
|
|
61
|
+
if (existing && existing !== node) {
|
|
62
|
+
existing.replaceWith(node);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Use Lightview's standard placement logic
|
|
68
|
+
globalThis.Lightview.$(target).content(node, location);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}, { pathAware: true });
|
|
72
|
+
|
|
73
|
+
registerHelper('mount', async (url, options = {}) => {
|
|
74
|
+
const { target = 'body', location = 'beforeend' } = options;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const fetchOptions = { ...options };
|
|
78
|
+
delete fetchOptions.target;
|
|
79
|
+
delete fetchOptions.location;
|
|
80
|
+
|
|
81
|
+
const headers = { ...fetchOptions.headers };
|
|
82
|
+
let body = fetchOptions.body;
|
|
83
|
+
|
|
84
|
+
if (body !== undefined) {
|
|
85
|
+
if (body !== null && typeof body === 'object') {
|
|
86
|
+
body = JSON.stringify(body);
|
|
87
|
+
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
|
88
|
+
} else {
|
|
89
|
+
body = String(body);
|
|
90
|
+
if (!headers['Content-Type']) headers['Content-Type'] = 'text/plain';
|
|
91
|
+
}
|
|
92
|
+
fetchOptions.body = body;
|
|
93
|
+
fetchOptions.headers = headers;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const response = await globalThis.fetch(url, fetchOptions);
|
|
97
|
+
const contentType = response.headers.get('Content-Type') || '';
|
|
98
|
+
const text = await response.text();
|
|
99
|
+
|
|
100
|
+
let content = text;
|
|
101
|
+
const isCDOM = contentType.includes('application/cdom') ||
|
|
102
|
+
contentType.includes('application/jprx') ||
|
|
103
|
+
contentType.includes('application/vdom') ||
|
|
104
|
+
contentType.includes('application/odom') ||
|
|
105
|
+
url.endsWith('.cdom') || url.endsWith('.jprx') ||
|
|
106
|
+
url.endsWith('.vdom') || url.endsWith('.odom');
|
|
107
|
+
|
|
108
|
+
if (isCDOM || (contentType.includes('application/json') && text.trim().startsWith('{'))) {
|
|
109
|
+
try {
|
|
110
|
+
content = hydrate(parseJPRX(text));
|
|
111
|
+
} catch (e) {
|
|
112
|
+
// Fail gracefully to text
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const targetEl = document.querySelector(target);
|
|
117
|
+
if (targetEl) {
|
|
118
|
+
globalThis.Lightview.$(targetEl).content(content, location);
|
|
119
|
+
} else {
|
|
120
|
+
console.warn(`[Lightview-CDOM] $mount target not found: ${target}`);
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(`[Lightview-CDOM] $mount failed for ${url}:`, err);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
36
126
|
|
|
37
127
|
// Register Standard Operators
|
|
38
128
|
// Mutation operators (prefix and postfix)
|
|
@@ -60,372 +150,178 @@ const localStates = new WeakMap();
|
|
|
60
150
|
|
|
61
151
|
/**
|
|
62
152
|
* Builds a reactive context object for a node by chaining all ancestor states.
|
|
63
|
-
* Global state -> cdom-state1 -> cdom-state2 -> ... -> current
|
|
64
153
|
*/
|
|
65
154
|
export const getContext = (node, event = null) => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const ShadowRoot = globalThis.ShadowRoot;
|
|
69
|
-
|
|
70
|
-
// Collate all ancestor states
|
|
71
|
-
while (cur) {
|
|
72
|
-
const local = localStates.get(cur) || (cur && typeof cur === 'object' ? cur.__state__ : null);
|
|
73
|
-
if (local) chain.unshift(local);
|
|
74
|
-
cur = cur.parentElement || (cur && typeof cur === 'object' ? cur.__parent__ : null) || (ShadowRoot && cur.parentNode instanceof ShadowRoot ? cur.parentNode.host : null);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Access global registry for fallback
|
|
78
|
-
const globalRegistry = getRegistry();
|
|
79
|
-
|
|
80
|
-
const handler = {
|
|
81
|
-
get(target, prop, receiver) {
|
|
155
|
+
return new Proxy({}, {
|
|
156
|
+
get(_, prop) {
|
|
82
157
|
if (prop === '$event' || prop === 'event') return event;
|
|
83
|
-
if (prop === '
|
|
84
|
-
|
|
85
|
-
// Search chain from bottom to top (most local first)
|
|
86
|
-
for (let i = chain.length - 1; i >= 0; i--) {
|
|
87
|
-
const s = chain[i];
|
|
88
|
-
if (prop in s) return s[prop];
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Fall back to global state accessed via registry
|
|
92
|
-
if (globalRegistry && globalRegistry.has(prop)) return unwrapSignal(globalRegistry.get(prop));
|
|
93
|
-
|
|
94
|
-
// Or maybe global state object if user manually set Lightview.state
|
|
95
|
-
const globalState = globalThis.Lightview?.state;
|
|
96
|
-
if (globalState && prop in globalState) return unwrapSignal(globalState[prop]);
|
|
97
|
-
|
|
98
|
-
return undefined;
|
|
158
|
+
if (prop === '$this' || prop === 'this' || prop === '__node__') return node;
|
|
159
|
+
return unwrapSignal(globalThis.Lightview.getState(prop, { scope: node }));
|
|
99
160
|
},
|
|
100
|
-
set(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
for (let i = chain.length - 1; i >= 0; i--) {
|
|
105
|
-
const s = chain[i];
|
|
106
|
-
if (prop in s) {
|
|
107
|
-
|
|
108
|
-
s[prop] = value;
|
|
109
|
-
return true;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// If not found, set on the most local state if it exists
|
|
114
|
-
if (chain.length > 0) {
|
|
115
|
-
|
|
116
|
-
chain[chain.length - 1][prop] = value;
|
|
117
|
-
return true;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Fall back to global state
|
|
121
|
-
const globalState = globalThis.Lightview?.state;
|
|
122
|
-
if (globalState && prop in globalState) {
|
|
123
|
-
|
|
124
|
-
globalState[prop] = value;
|
|
161
|
+
set(_, prop, value) {
|
|
162
|
+
const res = globalThis.Lightview.getState(prop, { scope: node });
|
|
163
|
+
if (res && (typeof res === 'object' || typeof res === 'function') && 'value' in res) {
|
|
164
|
+
res.value = value;
|
|
125
165
|
return true;
|
|
126
166
|
}
|
|
127
|
-
|
|
128
|
-
// Fall back to global registry
|
|
129
|
-
if (globalRegistry && globalRegistry.has(prop)) {
|
|
130
|
-
const s = globalRegistry.get(prop);
|
|
131
|
-
|
|
132
|
-
// Signals are functions with a .value property
|
|
133
|
-
if (s && (typeof s === 'object' || typeof s === 'function') && 'value' in s) {
|
|
134
|
-
s.value = value;
|
|
135
|
-
return true;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
167
|
return false;
|
|
141
|
-
},
|
|
142
|
-
has(target, prop) {
|
|
143
|
-
const exists = (prop === '$event' || prop === 'event' || !!chain.find(s => prop in s));
|
|
144
|
-
const inGlobal = (globalThis.Lightview?.state && prop in globalThis.Lightview.state) || (globalRegistry && globalRegistry.has(prop));
|
|
145
|
-
|
|
146
|
-
const isStructural = prop === 'constructor' || prop === 'toJSON' || prop === 'value' || prop === 'isLazy';
|
|
147
|
-
if (!isStructural) {
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
return exists || inGlobal;
|
|
151
|
-
},
|
|
152
|
-
ownKeys(target) {
|
|
153
|
-
const keys = new Set();
|
|
154
|
-
if (event) { keys.add('$event'); keys.add('event'); }
|
|
155
|
-
for (const s of chain) {
|
|
156
|
-
for (const key in s) keys.add(key);
|
|
157
|
-
}
|
|
158
|
-
const globalState = globalThis.Lightview?.state;
|
|
159
|
-
if (globalState) {
|
|
160
|
-
for (const key in globalState) keys.add(key);
|
|
161
|
-
}
|
|
162
|
-
return Array.from(keys);
|
|
163
|
-
},
|
|
164
|
-
getOwnPropertyDescriptor(target, prop) {
|
|
165
|
-
return { enumerable: true, configurable: true };
|
|
166
168
|
}
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
return new Proxy({}, handler);
|
|
169
|
+
});
|
|
170
170
|
};
|
|
171
171
|
|
|
172
172
|
/**
|
|
173
|
-
*
|
|
173
|
+
* Hook for Lightview core to process $bind markers.
|
|
174
174
|
*/
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
175
|
+
globalThis.Lightview.hooks.processAttribute = (domNode, key, value) => {
|
|
176
|
+
if (value?.__JPRX_BIND__) {
|
|
177
|
+
const { path, options } = value;
|
|
178
|
+
const type = domNode.type || '';
|
|
179
|
+
const tagName = domNode.tagName.toLowerCase();
|
|
180
|
+
let prop = 'value';
|
|
181
|
+
let event = 'input';
|
|
182
|
+
|
|
183
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
184
|
+
prop = 'checked';
|
|
185
|
+
event = 'change';
|
|
186
|
+
} else if (tagName === 'select') {
|
|
187
|
+
event = 'change';
|
|
187
188
|
}
|
|
188
|
-
} catch (e) {
|
|
189
|
-
globalThis.console?.error('LightviewCDOM: Failed to parse cdom-state', e);
|
|
190
|
-
}
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Handles cdom-bind directive.
|
|
195
|
-
*/
|
|
196
|
-
export const handleCDOMBind = (node) => {
|
|
197
|
-
const path = node['cdom-bind'] || node.getAttribute('cdom-bind');
|
|
198
|
-
if (!path) return;
|
|
199
|
-
|
|
200
|
-
const type = node.type || '';
|
|
201
|
-
const tagName = node.tagName.toLowerCase();
|
|
202
|
-
let prop = 'value';
|
|
203
|
-
let event = 'input';
|
|
204
|
-
|
|
205
|
-
if (type === 'checkbox' || type === 'radio') {
|
|
206
|
-
prop = 'checked';
|
|
207
|
-
event = 'change';
|
|
208
|
-
} else if (tagName === 'select') {
|
|
209
|
-
event = 'change';
|
|
210
|
-
}
|
|
211
189
|
|
|
212
|
-
|
|
213
|
-
let target = resolvePathAsContext(path, context);
|
|
190
|
+
const res = globalThis.Lightview.get(path.replace(/^=/, ''), { scope: domNode });
|
|
214
191
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
192
|
+
// State -> DOM
|
|
193
|
+
const runner = globalThis.Lightview.effect(() => {
|
|
194
|
+
const val = unwrapSignal(res);
|
|
195
|
+
if (domNode[prop] !== val) {
|
|
196
|
+
domNode[prop] = val === undefined ? '' : val;
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
globalThis.Lightview.internals.trackEffect(domNode, runner);
|
|
224
200
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
node[prop] = val === undefined ? '' : val;
|
|
230
|
-
}
|
|
231
|
-
});
|
|
201
|
+
// DOM -> State
|
|
202
|
+
domNode.addEventListener(event, () => {
|
|
203
|
+
if (res && 'value' in res) res.value = domNode[prop];
|
|
204
|
+
});
|
|
232
205
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
target.value = val;
|
|
238
|
-
} else {
|
|
239
|
-
// Fallback for non-BindingTarget targets
|
|
240
|
-
set(context, { [path]: val });
|
|
241
|
-
}
|
|
242
|
-
});
|
|
206
|
+
// Use initial value if available
|
|
207
|
+
return unwrapSignal(res) ?? domNode[prop];
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
243
210
|
};
|
|
244
211
|
|
|
245
|
-
|
|
246
212
|
/**
|
|
247
|
-
*
|
|
213
|
+
* Legacy activation no longer needed.
|
|
248
214
|
*/
|
|
249
|
-
export const activate = (root = document.body) => {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
child = child.nextSibling;
|
|
259
|
-
}
|
|
260
|
-
};
|
|
261
|
-
walk(root);
|
|
215
|
+
export const activate = (root = document.body) => { };
|
|
216
|
+
|
|
217
|
+
const makeEventHandler = (expr) => (eventOrNode) => {
|
|
218
|
+
const isEvent = eventOrNode && typeof eventOrNode === 'object' && 'target' in eventOrNode;
|
|
219
|
+
const target = isEvent ? (eventOrNode.currentTarget || eventOrNode.target) : eventOrNode;
|
|
220
|
+
const context = getContext(target, isEvent ? eventOrNode : null);
|
|
221
|
+
const result = resolveExpression(expr, context);
|
|
222
|
+
if (result && typeof result === 'object' && result.isLazy) return result.resolve(eventOrNode);
|
|
223
|
+
return result;
|
|
262
224
|
};
|
|
263
225
|
|
|
264
226
|
/**
|
|
265
227
|
* Hydrates a static CDOM object into a reactive CDOM graph.
|
|
266
|
-
* Traverses the object, converting expression strings (
|
|
228
|
+
* Traverses the object, converting expression strings (=...) into Signals/Computeds.
|
|
267
229
|
* Establishes a __parent__ link for relative path resolution.
|
|
268
230
|
*/
|
|
269
231
|
export const hydrate = (node, parent = null) => {
|
|
270
232
|
if (!node) return node;
|
|
271
233
|
|
|
272
|
-
// 1. Handle
|
|
273
|
-
|
|
234
|
+
// 1. Handle Escape and Expressions
|
|
235
|
+
// Escape sequence: '= at start produces a literal string starting with =
|
|
236
|
+
if (typeof node === 'string' && node.startsWith("'=")) {
|
|
237
|
+
return node.slice(1); // Strip the ' and return as literal
|
|
238
|
+
}
|
|
239
|
+
if (typeof node === 'string' && node.startsWith('=')) {
|
|
274
240
|
return parseExpression(node, parent);
|
|
275
241
|
}
|
|
276
242
|
|
|
243
|
+
if (typeof node !== 'object') return node;
|
|
244
|
+
|
|
277
245
|
// 2. Handle Arrays
|
|
278
246
|
if (Array.isArray(node)) {
|
|
279
247
|
return node.map(item => hydrate(item, parent));
|
|
280
248
|
}
|
|
281
249
|
|
|
282
|
-
//
|
|
283
|
-
if (node instanceof String)
|
|
284
|
-
|
|
250
|
+
// 2. Handle String Objects (rare but possible)
|
|
251
|
+
if (node instanceof String) return node.toString();
|
|
252
|
+
|
|
253
|
+
// 3. Handle Nodes
|
|
254
|
+
// Parent link
|
|
255
|
+
if (parent && !('__parent__' in node)) {
|
|
256
|
+
Object.defineProperty(node, '__parent__', { value: parent, enumerable: false, writable: true });
|
|
257
|
+
globalThis.Lightview?.internals?.parents?.set(node, parent);
|
|
285
258
|
}
|
|
286
259
|
|
|
287
|
-
//
|
|
288
|
-
if (
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
configurable: true
|
|
296
|
-
});
|
|
260
|
+
// oDOM Normalization - convert shorthand { div: "text" } to { tag: "div", children: ["text"] }
|
|
261
|
+
if (!node.tag) {
|
|
262
|
+
let potentialTag = null;
|
|
263
|
+
const reserved = ['children', 'attributes', 'tag', '__parent__'];
|
|
264
|
+
for (const key in node) {
|
|
265
|
+
if (reserved.includes(key) || key.startsWith('on')) continue;
|
|
266
|
+
potentialTag = key;
|
|
267
|
+
break;
|
|
297
268
|
}
|
|
298
269
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
// Skip common HTML attribute names (not tag names)
|
|
310
|
-
const attrNames = [
|
|
311
|
-
// Form/input attributes
|
|
312
|
-
'type', 'name', 'value', 'placeholder', 'step', 'min', 'max', 'pattern',
|
|
313
|
-
'disabled', 'checked', 'selected', 'readonly', 'required', 'multiple',
|
|
314
|
-
'rows', 'cols', 'size', 'maxlength', 'minlength', 'autocomplete',
|
|
315
|
-
// Common element attributes
|
|
316
|
-
'id', 'class', 'className', 'style', 'title', 'tabindex', 'role',
|
|
317
|
-
'href', 'src', 'alt', 'width', 'height', 'target', 'rel',
|
|
318
|
-
// Data attributes
|
|
319
|
-
'data', 'label', 'text', 'description', 'content',
|
|
320
|
-
// Common data property names
|
|
321
|
-
'price', 'qty', 'items', 'count', 'total', 'amount', 'url'
|
|
322
|
-
];
|
|
323
|
-
if (attrNames.includes(key)) {
|
|
324
|
-
continue;
|
|
270
|
+
if (potentialTag) {
|
|
271
|
+
const content = node[potentialTag];
|
|
272
|
+
node.tag = potentialTag;
|
|
273
|
+
if (Array.isArray(content)) {
|
|
274
|
+
node.children = content;
|
|
275
|
+
} else if (typeof content === 'object') {
|
|
276
|
+
node.attributes = node.attributes || {};
|
|
277
|
+
for (const k in content) {
|
|
278
|
+
if (k === 'children') node.children = content[k];
|
|
279
|
+
else node.attributes[k] = content[k];
|
|
325
280
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
281
|
+
} else node.children = [content];
|
|
282
|
+
delete node[potentialTag];
|
|
283
|
+
}
|
|
284
|
+
}
|
|
330
285
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
node[k] = content[k];
|
|
347
|
-
} else {
|
|
348
|
-
// Everything else (including event handlers) is an attribute
|
|
349
|
-
node.attributes[k] = content[k];
|
|
350
|
-
}
|
|
351
|
-
}
|
|
286
|
+
// Recursive Processing
|
|
287
|
+
for (const key in node) {
|
|
288
|
+
if (key === 'tag' || key === '__parent__') continue;
|
|
289
|
+
const value = node[key];
|
|
290
|
+
|
|
291
|
+
// Special case: attributes object
|
|
292
|
+
if (key === 'attributes' && typeof value === 'object' && value !== null) {
|
|
293
|
+
for (const attrKey in value) {
|
|
294
|
+
const attrVal = value[attrKey];
|
|
295
|
+
// Escape sequence: '= at start produces a literal string starting with =
|
|
296
|
+
if (typeof attrVal === 'string' && attrVal.startsWith("'=")) {
|
|
297
|
+
value[attrKey] = attrVal.slice(1);
|
|
298
|
+
} else if (typeof attrVal === 'string' && attrVal.startsWith('=')) {
|
|
299
|
+
if (attrKey.startsWith('on')) {
|
|
300
|
+
value[attrKey] = makeEventHandler(attrVal);
|
|
352
301
|
} else {
|
|
353
|
-
|
|
354
|
-
node.children = [content];
|
|
302
|
+
value[attrKey] = parseExpression(attrVal, node);
|
|
355
303
|
}
|
|
356
|
-
|
|
357
|
-
|
|
304
|
+
} else if (typeof attrVal === 'object' && attrVal !== null) {
|
|
305
|
+
value[attrKey] = hydrate(attrVal, node);
|
|
358
306
|
}
|
|
359
307
|
}
|
|
308
|
+
continue;
|
|
360
309
|
}
|
|
361
310
|
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const value = node[key];
|
|
371
|
-
|
|
372
|
-
// Skip cdom-state as we already processed it
|
|
373
|
-
if (key === 'cdom-state') {
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Handle $ expressions - convert to reactive computed values or event handlers
|
|
378
|
-
if (typeof value === 'string' && value.startsWith('$')) {
|
|
379
|
-
if (key.startsWith('on')) {
|
|
380
|
-
// Event handlers: create a function that resolves the expression with event context
|
|
381
|
-
node[key] = (event) => {
|
|
382
|
-
const element = event.currentTarget;
|
|
383
|
-
const context = getContext(element, event);
|
|
384
|
-
const result = resolveExpression(value, context);
|
|
385
|
-
|
|
386
|
-
// If it's a lazy value (contains $event or _), resolve it
|
|
387
|
-
if (result && typeof result === 'object' && result.isLazy && typeof result.resolve === 'function') {
|
|
388
|
-
return result.resolve(event);
|
|
389
|
-
}
|
|
390
|
-
return result;
|
|
391
|
-
};
|
|
392
|
-
} else if (key === 'children') {
|
|
393
|
-
// Children must always be an array. If it's a reactive expression (like $map),
|
|
394
|
-
// wrap the computed signal in an array so Lightview can process it.
|
|
395
|
-
node[key] = [parseExpression(value, node)];
|
|
396
|
-
} else {
|
|
397
|
-
// Other properties: create a computed expression that evaluates reactively
|
|
398
|
-
node[key] = parseExpression(value, node);
|
|
399
|
-
}
|
|
400
|
-
} else if (key === 'attributes' && typeof value === 'object' && value !== null) {
|
|
401
|
-
// Process attributes object - convert $expressions there too
|
|
402
|
-
for (const attrKey in value) {
|
|
403
|
-
const attrValue = value[attrKey];
|
|
404
|
-
if (typeof attrValue === 'string' && attrValue.startsWith('$')) {
|
|
405
|
-
if (attrKey.startsWith('on')) {
|
|
406
|
-
// Event handlers in attributes
|
|
407
|
-
value[attrKey] = (event) => {
|
|
408
|
-
const element = event.currentTarget;
|
|
409
|
-
const context = getContext(element, event);
|
|
410
|
-
const result = resolveExpression(attrValue, context);
|
|
411
|
-
if (result && typeof result === 'object' && result.isLazy && typeof result.resolve === 'function') {
|
|
412
|
-
return result.resolve(event);
|
|
413
|
-
}
|
|
414
|
-
return result;
|
|
415
|
-
};
|
|
416
|
-
} else {
|
|
417
|
-
// Other reactive attributes
|
|
418
|
-
value[attrKey] = parseExpression(attrValue, node);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
node[key] = value;
|
|
311
|
+
// Escape sequence: '= at start produces a literal string starting with =
|
|
312
|
+
if (typeof value === 'string' && value.startsWith("'=")) {
|
|
313
|
+
node[key] = value.slice(1);
|
|
314
|
+
} else if (typeof value === 'string' && value.startsWith('=')) {
|
|
315
|
+
if (key === 'onmount' || key === 'onunmount' || key.startsWith('on')) {
|
|
316
|
+
node[key] = makeEventHandler(value);
|
|
317
|
+
} else if (key === 'children') {
|
|
318
|
+
node[key] = [parseExpression(value, node)];
|
|
423
319
|
} else {
|
|
424
|
-
|
|
425
|
-
node[key] = hydrate(value, node);
|
|
320
|
+
node[key] = parseExpression(value, node);
|
|
426
321
|
}
|
|
322
|
+
} else {
|
|
323
|
+
node[key] = hydrate(value, node);
|
|
427
324
|
}
|
|
428
|
-
return node;
|
|
429
325
|
}
|
|
430
326
|
|
|
431
327
|
return node;
|
|
@@ -442,8 +338,8 @@ const LightviewCDOM = {
|
|
|
442
338
|
parseJPRX,
|
|
443
339
|
unwrapSignal,
|
|
444
340
|
getContext,
|
|
445
|
-
handleCDOMState,
|
|
446
|
-
handleCDOMBind,
|
|
341
|
+
handleCDOMState: () => { },
|
|
342
|
+
handleCDOMBind: () => { },
|
|
447
343
|
activate,
|
|
448
344
|
hydrate,
|
|
449
345
|
version: '1.0.0'
|
package/src/lightview-router.js
CHANGED
|
@@ -115,6 +115,11 @@
|
|
|
115
115
|
|
|
116
116
|
const handleRequest = async (path) => {
|
|
117
117
|
if (onStart) onStart(path);
|
|
118
|
+
|
|
119
|
+
// Snapshot scroll positions document-wide before content swap
|
|
120
|
+
const internals = globalThis.Lightview?.internals;
|
|
121
|
+
const scrollMap = internals?.saveScrolls?.();
|
|
122
|
+
|
|
118
123
|
const res = await route(path);
|
|
119
124
|
if (!res) return console.warn(`[Router] No route: ${path}`);
|
|
120
125
|
|
|
@@ -127,6 +132,11 @@
|
|
|
127
132
|
s.replaceWith(n);
|
|
128
133
|
});
|
|
129
134
|
|
|
135
|
+
// Restore scroll positions after content has been updated
|
|
136
|
+
if (internals?.restoreScrolls && scrollMap) {
|
|
137
|
+
internals.restoreScrolls(scrollMap);
|
|
138
|
+
}
|
|
139
|
+
|
|
130
140
|
// Handle hash scrolling if present in the target path
|
|
131
141
|
const urlParts = path.split('#');
|
|
132
142
|
const hash = urlParts.length > 1 ? '#' + urlParts[1] : '';
|