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.
@@ -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
- const chain = [];
67
- let cur = node;
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 === '__parent__') return undefined; // Should be handled by resolvePath
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(target, prop, value, receiver) {
101
-
102
-
103
- // Search chain for existing property to update
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
- * Handles cdom-state directive.
173
+ * Hook for Lightview core to process $bind markers.
174
174
  */
175
- export const handleCDOMState = (node) => {
176
- const attr = node['cdom-state'] || (node.getAttribute && node.getAttribute('cdom-state'));
177
- if (!attr || localStates.has(node)) return;
178
-
179
- try {
180
- const data = typeof attr === 'object' ? attr : JSON.parse(attr);
181
- // Use imported state factory
182
- const s = state(data);
183
- localStates.set(node, s);
184
- // Also attach to the object for non-DOM traversal (e.g. during hydration)
185
- if (node && typeof node === 'object') {
186
- node.__state__ = s;
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
- const context = getContext(node);
213
- let target = resolvePathAsContext(path, context);
190
+ const res = globalThis.Lightview.get(path.replace(/^=/, ''), { scope: domNode });
214
191
 
215
- // Smart initialization: if state is undefined, initialize from DOM
216
- if (target && target.isBindingTarget && target.value === undefined) {
217
- const val = node[prop];
218
- if (val !== undefined && val !== '') {
219
- set(context, { [target.key]: val });
220
- // Re-resolve to get the now-reactive target if it was newly created
221
- target = resolvePathAsContext(path, context);
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
- // State -> DOM
226
- effect(() => {
227
- const val = unwrapSignal(target);
228
- if (node[prop] !== val) {
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
- // DOM -> State
234
- node.addEventListener(event, () => {
235
- const val = node[prop];
236
- if (target && target.isBindingTarget) {
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
- * Scans a subtree and activates CDOM directives.
213
+ * Legacy activation no longer needed.
248
214
  */
249
- export const activate = (root = document.body) => {
250
- const walk = (node) => {
251
- if (node.nodeType === 1) {
252
- if (node.hasAttribute('cdom-state')) handleCDOMState(node);
253
- if (node.hasAttribute('cdom-bind')) handleCDOMBind(node);
254
- }
255
- let child = node.firstChild;
256
- while (child) {
257
- walk(child);
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 ($...) into Signals/Computeds.
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 Expressions (Strings starting with $)
273
- if (typeof node === 'string' && node.startsWith('$')) {
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
- // 3. Handle String Objects (Quoted literals from parser)
283
- if (node instanceof String) {
284
- return node.toString();
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
- // 4. Handle Objects (Nodes)
288
- if (typeof node === 'object' && node !== null) {
289
- // Set back-reference for relative path resolution (../)
290
- if (parent && !('__parent__' in node)) {
291
- Object.defineProperty(node, '__parent__', {
292
- value: parent,
293
- enumerable: false,
294
- writable: true,
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
- // NEW: Normalize cDOM shorthand { tag: content } -> { tag: 'tag', ...content }
300
- // But skip if this looks like pure data (no potential tag keys)
301
- if (!node.tag) {
302
- let potentialTag = null;
303
- for (const key in node) {
304
- // Skip reserved keys and directives
305
- if (key === 'children' || key === 'attributes' || key === 'tag' ||
306
- key.startsWith('cdom-') || key.startsWith('on') || key === '__parent__') {
307
- continue;
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
- // If we find a key that looks like a tag name, use it
327
- potentialTag = key;
328
- break;
329
- }
281
+ } else node.children = [content];
282
+ delete node[potentialTag];
283
+ }
284
+ }
330
285
 
331
- if (potentialTag) {
332
- const content = node[potentialTag];
333
- if (content !== undefined && content !== null) {
334
- node.tag = potentialTag;
335
- // Move the content into the node
336
- if (Array.isArray(content)) {
337
- node.children = content;
338
- } else if (typeof content === 'object') {
339
- // Separate children, directives from attributes
340
- node.attributes = node.attributes || {};
341
- for (const k in content) {
342
- if (k === 'children') {
343
- node.children = content[k];
344
- } else if (k.startsWith('cdom-')) {
345
- // cDOM directives go on the node directly
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
- // Treat primitive value as text child
354
- node.children = [content];
302
+ value[attrKey] = parseExpression(attrVal, node);
355
303
  }
356
- // Remove the shorthand key to avoid processing it again
357
- delete node[potentialTag];
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
- // IMPORTANT: Handle cdom-state FIRST before any other processing
363
- // This ensures state is available when children expressions are evaluated
364
- if (node['cdom-state']) {
365
- handleCDOMState(node);
366
- }
367
-
368
- // Process each property
369
- for (const key in node) {
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
- // Recursively hydrate other values
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'
@@ -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] : '';