lightview 2.0.9 → 2.2.1

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.
Files changed (115) hide show
  1. package/build-bundles.mjs +105 -0
  2. package/build.js +236 -46
  3. package/components/actions/button.js +16 -3
  4. package/components/actions/swap.js +26 -3
  5. package/components/daisyui.js +1 -1
  6. package/components/data-display/alert.js +13 -3
  7. package/components/data-display/avatar.js +25 -1
  8. package/components/data-display/badge.js +11 -3
  9. package/components/data-display/chart.js +22 -5
  10. package/components/data-display/countdown.js +3 -2
  11. package/components/data-display/kbd.js +9 -3
  12. package/components/data-display/loading.js +11 -3
  13. package/components/data-display/progress.js +11 -3
  14. package/components/data-display/radial-progress.js +12 -3
  15. package/components/data-display/tooltip.js +17 -0
  16. package/components/data-input/checkbox.js +23 -1
  17. package/components/data-input/input.js +24 -1
  18. package/components/data-input/radio.js +37 -2
  19. package/components/data-input/select.js +24 -1
  20. package/components/data-input/toggle.js +21 -1
  21. package/components/layout/divider.js +21 -1
  22. package/components/layout/indicator.js +14 -0
  23. package/components/navigation/breadcrumbs.js +42 -2
  24. package/components/navigation/tabs.js +291 -16
  25. package/docs/api/elements.html +125 -49
  26. package/docs/api/hypermedia.html +29 -2
  27. package/docs/api/index.html +6 -2
  28. package/docs/api/nav.html +18 -4
  29. package/docs/assets/js/examplify.js +1 -1
  30. package/docs/cdom-nav.html +55 -0
  31. package/docs/cdom.html +792 -0
  32. package/docs/components/alert.html +8 -8
  33. package/docs/components/avatar.html +24 -54
  34. package/docs/components/badge.html +69 -14
  35. package/docs/components/breadcrumbs.html +95 -29
  36. package/docs/components/button.html +78 -92
  37. package/docs/components/chart-area.html +3 -3
  38. package/docs/components/chart-bar.html +4 -181
  39. package/docs/components/chart-column.html +4 -189
  40. package/docs/components/chart-line.html +3 -3
  41. package/docs/components/chart-pie.html +112 -166
  42. package/docs/components/chart.html +11 -13
  43. package/docs/components/checkbox.html +48 -28
  44. package/docs/components/collapse.html +6 -6
  45. package/docs/components/component-nav.html +1 -1
  46. package/docs/components/countdown.html +12 -12
  47. package/docs/components/divider.html +65 -21
  48. package/docs/components/dropdown.html +1 -1
  49. package/docs/components/file-input.html +4 -4
  50. package/docs/components/footer.html +11 -11
  51. package/docs/components/indicator.html +85 -31
  52. package/docs/components/input.html +45 -29
  53. package/docs/components/join.html +4 -4
  54. package/docs/components/kbd.html +67 -28
  55. package/docs/components/loading.html +96 -92
  56. package/docs/components/pagination.html +4 -4
  57. package/docs/components/progress.html +50 -7
  58. package/docs/components/radial-progress.html +32 -12
  59. package/docs/components/radio.html +42 -31
  60. package/docs/components/select.html +48 -59
  61. package/docs/components/swap.html +183 -100
  62. package/docs/components/tabs.html +146 -278
  63. package/docs/components/toggle.html +44 -25
  64. package/docs/components/tooltip.html +71 -31
  65. package/docs/getting-started/index.html +8 -6
  66. package/docs/index.html +1 -1
  67. package/docs/syntax-nav.html +10 -0
  68. package/docs/syntax.html +8 -6
  69. package/index.html +2 -2
  70. package/jprx/LICENSE +21 -0
  71. package/jprx/README.md +130 -0
  72. package/jprx/helpers/array.js +75 -0
  73. package/jprx/helpers/compare.js +26 -0
  74. package/jprx/helpers/conditional.js +34 -0
  75. package/jprx/helpers/datetime.js +54 -0
  76. package/jprx/helpers/format.js +20 -0
  77. package/jprx/helpers/logic.js +24 -0
  78. package/jprx/helpers/lookup.js +25 -0
  79. package/jprx/helpers/math.js +34 -0
  80. package/jprx/helpers/network.js +41 -0
  81. package/jprx/helpers/state.js +80 -0
  82. package/jprx/helpers/stats.js +39 -0
  83. package/jprx/helpers/string.js +49 -0
  84. package/jprx/index.js +69 -0
  85. package/jprx/package.json +24 -0
  86. package/jprx/parser.js +1517 -0
  87. package/lightview-all.js +3785 -0
  88. package/lightview-cdom.js +2128 -0
  89. package/lightview-router.js +179 -208
  90. package/lightview-x.js +1435 -1608
  91. package/lightview.js +613 -766
  92. package/lightview.js.bak +1 -0
  93. package/package.json +10 -3
  94. package/src/lightview-all.js +10 -0
  95. package/src/lightview-cdom.js +457 -0
  96. package/src/lightview-router.js +210 -0
  97. package/src/lightview-x.js +1630 -0
  98. package/src/lightview.js +705 -0
  99. package/src/reactivity/signal.js +133 -0
  100. package/src/reactivity/state.js +217 -0
  101. package/{watch.js → start-dev.js} +2 -1
  102. package/tests/cdom/fixtures/helpers.cdomc +62 -0
  103. package/tests/cdom/fixtures/user.cdom +14 -0
  104. package/tests/cdom/fixtures/user.cdomc +12 -0
  105. package/tests/cdom/fixtures/user.odom +18 -0
  106. package/tests/cdom/fixtures/user.vdom +11 -0
  107. package/tests/cdom/helpers.test.js +121 -0
  108. package/tests/cdom/loader.test.js +125 -0
  109. package/tests/cdom/parser.test.js +179 -0
  110. package/tests/cdom/reactivity.test.js +186 -0
  111. package/tests/text-tag.test.js +77 -0
  112. package/vite.config.mjs +52 -0
  113. package/wrangler.toml +0 -3
  114. package/components/data-display/skeleton.js +0 -66
  115. package/docs/components/skeleton.html +0 -447
@@ -0,0 +1,1630 @@
1
+ import { signal, effect, getRegistry } from './reactivity/signal.js';
2
+ import { state, getOrSet } from './reactivity/state.js';
3
+
4
+
5
+ /**
6
+ * LIGHTVIEW-X
7
+ * Hypermedia and Extended Reactivity for Lightview.
8
+ */
9
+
10
+ const STANDARD_SRC_TAGS = ['img', 'script', 'iframe', 'video', 'audio', 'source', 'track', 'embed', 'input'];
11
+ const isStandardSrcTag = (tagName) => STANDARD_SRC_TAGS.includes(tagName) || tagName.startsWith('lv-');
12
+ const STANDARD_HREF_TAGS = ['a', 'area', 'base', 'link'];
13
+
14
+ const isValidTagName = (name) => typeof name === 'string' && name.length > 0 && name !== 'children';
15
+
16
+ /**
17
+ * Checks if a URL/string uses a dangerous protocol like javascript: or data: (for navigation).
18
+ */
19
+ const isDangerousProtocol = (url) => {
20
+ if (!url || typeof url !== 'string') return false;
21
+ const normalized = url.trim().toLowerCase();
22
+ // Specifically block javascript, vbscript, and data (when used for HTML/navigation)
23
+ return normalized.startsWith('javascript:') ||
24
+ normalized.startsWith('vbscript:') ||
25
+ normalized.startsWith('data:text/html') ||
26
+ normalized.startsWith('data:application/javascript');
27
+ };
28
+
29
+ /**
30
+ * Validates a URL before fetching content.
31
+ * Default implementation allows same domain and its subdomains (ignoring port).
32
+ */
33
+ const validateUrl = (url) => {
34
+ if (!url) return false;
35
+ // If it doesn't look like a full URL (no protocol), assume it's relative and valid
36
+ // This avoids issues in sandboxed iframes where location.origin might be 'null'
37
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) return true;
38
+
39
+ try {
40
+ const base = (typeof document !== 'undefined') ? document.baseURI : globalThis.location.origin;
41
+ // If base is 'null' (sandboxed iframe), new URL(url, 'null') will throw if url is absolute
42
+ // But if it's absolute, we don't strictly need the base.
43
+ const target = new URL(url, base === 'null' ? undefined : base);
44
+ const current = globalThis.location;
45
+
46
+ // Allow same origin (matches protocol, host, and port)
47
+ if (target.origin === current.origin && target.origin !== 'null') return true;
48
+
49
+ // Allow same hostname (matches host, ignores port/protocol)
50
+ // This specifically allows different ports on the same host (e.g., localhost:3000 -> localhost:4000)
51
+ if (target.hostname && target.hostname === current.hostname) return true;
52
+
53
+ // Allow subdomains
54
+ if (target.hostname && current.hostname && target.hostname.endsWith('.' + current.hostname)) return true;
55
+
56
+ // Support local file protocol
57
+ if (current.protocol === 'file:' && target.protocol === 'file:') return true;
58
+
59
+ return false;
60
+ } catch (e) {
61
+ return false;
62
+ }
63
+ };
64
+
65
+ /**
66
+ * Detects if an object follows the Object DOM syntax: { tag: { attr: val, children: [...] } }
67
+ */
68
+ const isObjectDOM = (obj) => {
69
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj) || obj.tag || obj.domEl) return false;
70
+ const keys = Object.keys(obj);
71
+ return keys.length === 1 && isValidTagName(keys[0]) && typeof obj[keys[0]] === 'object';
72
+ };
73
+
74
+ /**
75
+ * Converts Object DOM syntax into standard Lightview VDOM { tag, attributes, children }
76
+ */
77
+ const convertObjectDOM = (obj) => {
78
+ if (typeof obj !== 'object' || obj === null) return obj;
79
+ if (Array.isArray(obj)) return obj.map(convertObjectDOM);
80
+ if (obj.tag) return { ...obj, children: obj.children ? convertObjectDOM(obj.children) : [] };
81
+ if (obj.domEl || !isObjectDOM(obj)) return obj;
82
+
83
+ const tagKey = Object.keys(obj)[0];
84
+ const content = obj[tagKey];
85
+ const LV = typeof window !== 'undefined' ? globalThis.Lightview : (typeof globalThis !== 'undefined' ? globalThis.Lightview : null);
86
+ const tag = (LV?.tags?._customTags?.[tagKey]) || tagKey;
87
+ const { children, ...attributes } = content;
88
+
89
+ return { tag, attributes, children: children ? convertObjectDOM(children) : [] };
90
+ };
91
+
92
+ // ============= COMPONENT CONFIGURATION =============
93
+ // Global configuration for Lightview components
94
+
95
+ const DAISYUI_CDN = 'https://cdn.jsdelivr.net/npm/daisyui@4.12.23/dist/full.min.css';
96
+
97
+ // Component configuration (set by initComponents)
98
+ const componentConfig = {
99
+ initialized: false,
100
+ shadowDefault: true, // Default: components use shadow DOM
101
+ daisyStyleSheet: null,
102
+ themeStyleSheet: null, // Global theme stylesheet
103
+ componentStyleSheets: new Map(),
104
+ customStyleSheets: new Map(), // Registry for named custom stylesheets
105
+ customStyleSheetPromises: new Map() // Cache for pending stylesheet fetches
106
+ };
107
+
108
+ /**
109
+ * Register a named stylesheet for use in components
110
+ * @param {string} nameOrIdOrUrl - The name/ID/URL of the stylesheet
111
+ * @param {string} [cssText] - Optional raw CSS content. If provided, nameOrIdOrUrl is treated as a name.
112
+ * @returns {Promise<void>}
113
+ */
114
+ const registerStyleSheet = async (nameOrIdOrUrl, cssText) => {
115
+ if (componentConfig.customStyleSheets.has(nameOrIdOrUrl)) return componentConfig.customStyleSheets.get(nameOrIdOrUrl);
116
+ if (componentConfig.customStyleSheetPromises.has(nameOrIdOrUrl)) return componentConfig.customStyleSheetPromises.get(nameOrIdOrUrl);
117
+
118
+ const promise = (async () => {
119
+ try {
120
+ let finalCss = cssText;
121
+
122
+ if (finalCss === undefined) {
123
+ if (nameOrIdOrUrl.startsWith('#')) {
124
+ // ID selector - search synchronously
125
+ const el = document.querySelector(nameOrIdOrUrl);
126
+ if (el) {
127
+ finalCss = el.textContent;
128
+ } else {
129
+ throw new Error(`Style block '${nameOrIdOrUrl}' not found`);
130
+ }
131
+ } else {
132
+ // Assume URL
133
+ const response = await fetch(nameOrIdOrUrl);
134
+ if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
135
+ finalCss = await response.text();
136
+ }
137
+ }
138
+
139
+ if (finalCss !== undefined) {
140
+ const sheet = new CSSStyleSheet();
141
+ sheet.replaceSync(finalCss);
142
+ componentConfig.customStyleSheets.set(nameOrIdOrUrl, sheet);
143
+ return sheet;
144
+ }
145
+ } catch (e) {
146
+ console.error(`LightviewX: Failed to register stylesheet '${nameOrIdOrUrl}':`, e);
147
+ } finally {
148
+ componentConfig.customStyleSheetPromises.delete(nameOrIdOrUrl);
149
+ }
150
+ })();
151
+
152
+ componentConfig.customStyleSheetPromises.set(nameOrIdOrUrl, promise);
153
+ return promise;
154
+ };
155
+
156
+ // Theme Signal
157
+ // Helper to safely get local storage
158
+ const getSavedTheme = () => {
159
+ try {
160
+ if (typeof localStorage !== 'undefined') {
161
+ return localStorage.getItem('lightview-theme');
162
+ }
163
+ } catch (e) {
164
+ return null;
165
+ }
166
+ };
167
+
168
+ // Theme Signal
169
+ const themeSignal = signal(
170
+ (typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme')) ||
171
+ getSavedTheme() ||
172
+ 'light'
173
+ );
174
+
175
+ /**
176
+ * Set the global theme for Lightview components (updates signal only)
177
+ * @param {string} themeName - The name of the theme (e.g., 'light', 'dark', 'cyberpunk')
178
+ */
179
+ const setTheme = (themeName) => {
180
+ if (!themeName) return;
181
+
182
+ // Determine base theme (light or dark) for the main document
183
+ // const darkThemes = ['dark', 'aqua', 'black', 'business', 'coffee', 'dim', 'dracula', 'forest', 'halloween', 'luxury', 'night', 'sunset', 'synthwave'];
184
+ // const baseTheme = darkThemes.includes(themeName) ? 'dark' : 'light';
185
+ if (typeof document !== 'undefined') {
186
+ document.documentElement.setAttribute('data-theme', themeName);
187
+ }
188
+
189
+ // Update signal
190
+ if (themeSignal && themeSignal.value !== themeName) {
191
+ themeSignal.value = themeName;
192
+ }
193
+
194
+ // Persist preference
195
+ try {
196
+ if (typeof localStorage !== 'undefined') {
197
+ localStorage.setItem('lightview-theme', themeName);
198
+ }
199
+ } catch (e) {
200
+ // Ignore storage errors
201
+ }
202
+ };
203
+
204
+ /**
205
+ * Register a global theme stylesheet for all components
206
+ * @param {string} url - URL to the CSS file
207
+ * @returns {Promise<void>}
208
+ */
209
+ const registerThemeSheet = async (url) => {
210
+ try {
211
+ const response = await fetch(url);
212
+ if (!response.ok) throw new Error(`Failed to fetch theme CSS: ${response.status}`);
213
+ const cssText = await response.text();
214
+ const sheet = new CSSStyleSheet();
215
+ sheet.replaceSync(cssText);
216
+ componentConfig.themeStyleSheet = sheet;
217
+ } catch (e) {
218
+ console.error(`LightviewX: Failed to register theme stylesheet '${url}':`, e);
219
+ }
220
+ };
221
+
222
+ /**
223
+ * Initialize Lightview components
224
+ * Preloads DaisyUI stylesheet for shadow DOM usage
225
+ * @param {Object} options
226
+ * @param {boolean} options.shadowDefault - Whether components use shadow DOM by default (default: true)
227
+ * @returns {Promise<void>}
228
+ */
229
+ const initComponents = async (options = {}) => {
230
+ const { shadowDefault = true } = options;
231
+
232
+ componentConfig.shadowDefault = shadowDefault;
233
+
234
+ if (shadowDefault) {
235
+ // Preload DaisyUI stylesheet for adopted stylesheets
236
+ try {
237
+ const response = await fetch(DAISYUI_CDN);
238
+ if (!response.ok) {
239
+ throw new Error(`Failed to fetch DaisyUI CSS: ${response.status}`);
240
+ }
241
+ const cssText = await response.text();
242
+ const sheet = new CSSStyleSheet();
243
+ sheet.replaceSync(cssText);
244
+ componentConfig.daisyStyleSheet = sheet;
245
+ } catch (e) {
246
+ console.error('LightviewX: Failed to preload DaisyUI stylesheet:', e);
247
+ // Continue without DaisyUI - components will still work, just without DaisyUI styles in shadow
248
+ }
249
+ }
250
+
251
+ componentConfig.initialized = true;
252
+ };
253
+ (async () => await initComponents())();
254
+
255
+ /**
256
+ * Get or create a CSSStyleSheet for a component's CSS file
257
+ * @param {string} cssUrl - URL to the component's CSS file
258
+ * @returns {Promise<CSSStyleSheet|null>}
259
+ */
260
+ const getComponentStyleSheet = async (cssUrl) => {
261
+ // Return cached sheet if available
262
+ if (componentConfig.componentStyleSheets.has(cssUrl)) {
263
+ return componentConfig.componentStyleSheets.get(cssUrl);
264
+ }
265
+
266
+ try {
267
+ const response = await fetch(cssUrl);
268
+ if (!response.ok) {
269
+ throw new Error(`Failed to fetch component CSS: ${response.status}`);
270
+ }
271
+ const cssText = await response.text();
272
+
273
+ const sheet = new CSSStyleSheet();
274
+ sheet.replaceSync(cssText);
275
+ componentConfig.componentStyleSheets.set(cssUrl, sheet);
276
+ return sheet;
277
+ } catch (e) {
278
+ console.error(`LightviewX: Failed to create stylesheet for ${cssUrl}:`, e);
279
+ return null;
280
+ }
281
+ };
282
+
283
+ /**
284
+ * Synchronously get cached component stylesheet (returns null if not yet loaded)
285
+ * @param {string} cssUrl
286
+ * @returns {CSSStyleSheet|null}
287
+ */
288
+ const getComponentStyleSheetSync = (cssUrl) => componentConfig.componentStyleSheets.get(cssUrl) || null;
289
+
290
+ /**
291
+ * Check if a component should use shadow DOM based on props and global default
292
+ * @param {boolean|undefined} useShadowProp - The useShadow prop passed to the component
293
+ * @returns {boolean}
294
+ */
295
+ const shouldUseShadow = (useShadowProp) => {
296
+ // Explicit prop value takes precedence
297
+ if (useShadowProp !== undefined) {
298
+ return useShadowProp;
299
+ }
300
+ // Fall back to global default
301
+ return componentConfig.shadowDefault;
302
+ };
303
+
304
+ /**
305
+ * Get the adopted stylesheets for a component
306
+ * @param {string} componentCssUrl - URL to the component's CSS file
307
+ * @param {string[]} requestedSheets - Array of stylesheet URLs to include
308
+ * @returns {(CSSStyleSheet|string)[]} - Mixed array of StyleSheet objects and URL strings (for link fallbacks)
309
+ */
310
+ const getAdoptedStyleSheets = (componentCssUrl, requestedSheets = []) => {
311
+ const result = [];
312
+
313
+ // Add global DaisyUI sheet
314
+ if (componentConfig.daisyStyleSheet) {
315
+ result.push(componentConfig.daisyStyleSheet);
316
+ } else {
317
+ result.push(DAISYUI_CDN);
318
+ }
319
+
320
+ // Add global Theme sheet (overrides default Daisy variables)
321
+ if (componentConfig.themeStyleSheet) {
322
+ result.push(componentConfig.themeStyleSheet);
323
+ }
324
+
325
+ // Add component-specific sheet
326
+ if (componentCssUrl) {
327
+ const componentSheet = componentConfig.componentStyleSheets.get(componentCssUrl);
328
+ if (componentSheet) {
329
+ result.push(componentSheet);
330
+ }
331
+ }
332
+
333
+ // Process requested sheets
334
+ if (Array.isArray(requestedSheets)) {
335
+ requestedSheets.forEach(url => {
336
+ const sheet = componentConfig.customStyleSheets.get(url);
337
+ if (sheet) {
338
+ // Registered and loaded -> use object
339
+ result.push(sheet);
340
+ } else {
341
+ // Not found -> trigger load, but return string URL for immediate link tag
342
+ registerStyleSheet(url); // Fire and forget
343
+ result.push(url);
344
+ }
345
+ });
346
+ }
347
+
348
+ return result;
349
+ };
350
+
351
+ /**
352
+ * Preload a component's CSS for shadow DOM usage
353
+ * Called by components during their initialization
354
+ * @param {string} cssUrl - URL to the component's CSS file
355
+ * @returns {Promise<void>}
356
+ */
357
+ const preloadComponentCSS = async (cssUrl) => {
358
+ if (!componentConfig.componentStyleSheets.has(cssUrl)) {
359
+ await getComponentStyleSheet(cssUrl);
360
+ }
361
+ };
362
+
363
+ // Registry shared functions are imported from signal.js
364
+
365
+ // ============= STATE (Deep Reactivity) =============
366
+ // Deep reactivity logic has been moved to src/reactivity/state.js
367
+
368
+ // Template compilation: unified logic for creating reactive functions
369
+ const compileTemplate = (code) => {
370
+ try {
371
+ const isSingle = code.trim().startsWith('${') && code.trim().endsWith('}') && !code.trim().includes('${', 2);
372
+ const body = isSingle ? 'return ' + code.trim().slice(2, -1) : 'return `' + code.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`';
373
+ return new Function('state', 'signal', body);
374
+ } catch (e) {
375
+ return () => "";
376
+ }
377
+ };
378
+
379
+ const processTemplateChild = (child, LV) => {
380
+ if (typeof child === 'string' && child.includes('${')) {
381
+ const fn = compileTemplate(child);
382
+ return () => fn(LV.state, LV.signal);
383
+ }
384
+ return child;
385
+ };
386
+
387
+ const transformTextNode = (node, isRaw, LV) => {
388
+ const text = node.textContent;
389
+ if (isRaw) return text;
390
+ if (!text.trim() && !text.includes('${')) return null;
391
+ if (text.includes('${')) {
392
+ const fn = compileTemplate(text);
393
+ return () => fn(LV.state, LV.signal);
394
+ }
395
+ return text;
396
+ };
397
+
398
+ const transformElementNode = (node, element, domToElements) => {
399
+ const tagName = node.tagName.toLowerCase();
400
+ const attributes = {};
401
+ const skip = tagName === 'script' || tagName === 'style';
402
+ const LV = typeof window !== 'undefined' ? globalThis.Lightview : (typeof globalThis !== 'undefined' ? globalThis.Lightview : null);
403
+
404
+ for (let attr of node.attributes) {
405
+ const val = attr.value;
406
+ attributes[attr.name] = (!skip && val.includes('${')) ? (() => {
407
+ const fn = compileTemplate(val);
408
+ return () => fn(LV.state, LV.signal);
409
+ })() : val;
410
+ }
411
+ return element(tagName, attributes, domToElements(Array.from(node.childNodes), element, tagName));
412
+ };
413
+
414
+ /**
415
+ * Converts standard DOM nodes into Lightview reactive elements.
416
+ * This is used to transform HTML templates (with template literals) into live VDOM.
417
+ */
418
+ const domToElements = (domNodes, element, parentTagName = null) => {
419
+ const isRaw = parentTagName === 'script' || parentTagName === 'style';
420
+ const LV = globalThis.Lightview;
421
+
422
+ return domNodes.map(node => {
423
+ if (node.nodeType === Node.TEXT_NODE) return transformTextNode(node, isRaw, LV);
424
+ if (node.nodeType === Node.ELEMENT_NODE) return transformElementNode(node, element, domToElements);
425
+ return null;
426
+ }).filter(n => n !== null);
427
+ };
428
+
429
+ // WeakMap to track inserted content per element+location for deduplication
430
+ const insertedContentMap = new WeakMap();
431
+
432
+ // Simple hash function for content comparison
433
+ const hashContent = (str) => {
434
+ let hash = 0;
435
+ for (let i = 0; i < str.length; i++) {
436
+ const char = str.charCodeAt(i);
437
+ hash = ((hash << 5) - hash) + char;
438
+ hash = hash & hash; // Convert to 32bit integer
439
+ }
440
+ return hash.toString(36);
441
+ };
442
+
443
+ // Create a marker comment to identify inserted content boundaries
444
+ const createMarker = (id, isEnd = false) => {
445
+ return document.createComment(`lv-src-${isEnd ? 'end' : 'start'}:${id}`);
446
+ };
447
+
448
+
449
+ /**
450
+ * Execute scripts in a container element
451
+ * Scripts created via DOMParser or innerHTML don't execute automatically,
452
+ * so we need to replace them with new script elements to trigger execution
453
+ * @param {HTMLElement|DocumentFragment} container - Container to search for scripts
454
+ */
455
+ const executeScripts = (container) => {
456
+ if (!container) return;
457
+
458
+ // Find all script tags in the container
459
+ const scripts = container.querySelectorAll('script');
460
+
461
+ scripts.forEach(oldScript => {
462
+ // Create a new script element
463
+ const newScript = document.createElement('script');
464
+
465
+ // Copy all attributes from old to new
466
+ Array.from(oldScript.attributes).forEach(attr => {
467
+ newScript.setAttribute(attr.name, attr.value);
468
+ });
469
+
470
+ // Copy the script content
471
+ if (oldScript.src) {
472
+ // External script - src attribute already copied
473
+ newScript.src = oldScript.src;
474
+ } else {
475
+ // Inline script - copy text content
476
+ newScript.textContent = oldScript.textContent;
477
+ }
478
+
479
+ // Replace the old script with the new one
480
+ // This causes the browser to execute it
481
+ oldScript.parentNode.replaceChild(newScript, oldScript);
482
+ });
483
+ };
484
+
485
+ // Find and remove previously inserted content between markers
486
+ const removeInsertedContent = (parentEl, markerId) => {
487
+ const startMarker = `lv-src-start:${markerId}`;
488
+ const endMarker = `lv-src-end:${markerId}`;
489
+
490
+ let inRange = false;
491
+ const nodesToRemove = [];
492
+
493
+ const walker = document.createTreeWalker(
494
+ parentEl.parentElement || parentEl,
495
+ NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
496
+ null,
497
+ false
498
+ );
499
+
500
+ while (walker.nextNode()) {
501
+ const node = walker.currentNode;
502
+ if (node.nodeType === Node.COMMENT_NODE) {
503
+ if (node.textContent === startMarker) {
504
+ inRange = true;
505
+ nodesToRemove.push(node);
506
+ continue;
507
+ }
508
+ if (node.textContent === endMarker) {
509
+ nodesToRemove.push(node);
510
+ break;
511
+ }
512
+ }
513
+ if (inRange) {
514
+ nodesToRemove.push(node);
515
+ }
516
+ }
517
+
518
+ nodesToRemove.forEach(node => node.remove());
519
+ return nodesToRemove.length > 0;
520
+ };
521
+
522
+ const insert = (elements, parent, location, markerId, { element, setupChildren }) => {
523
+ const isSibling = location === 'beforebegin' || location === 'afterend';
524
+ const isOuter = location === 'outerhtml';
525
+ const target = (isSibling || isOuter) ? parent.parentElement : parent;
526
+ if (!target) return console.warn(`LightviewX: No parent for ${location}`);
527
+
528
+ const frag = document.createDocumentFragment();
529
+ frag.appendChild(createMarker(markerId, false));
530
+ elements.forEach(c => {
531
+ if (typeof c === 'string') frag.appendChild(document.createTextNode(c));
532
+ else if (c.domEl) frag.appendChild(c.domEl);
533
+ else if (c instanceof Node) frag.appendChild(c);
534
+ else {
535
+ const v = globalThis.Lightview?.hooks.processChild?.(c) || c;
536
+ if (v.tag) {
537
+ const n = element(v.tag, v.attributes || {}, v.children || []);
538
+ if (n?.domEl) frag.appendChild(n.domEl);
539
+ }
540
+ }
541
+ });
542
+ frag.appendChild(createMarker(markerId, true));
543
+
544
+ if (isOuter) target.replaceChild(frag, parent);
545
+ else if (location === 'beforebegin') target.insertBefore(frag, parent);
546
+ else if (location === 'afterend') target.insertBefore(frag, parent.nextSibling);
547
+ else if (location === 'afterbegin') parent.insertBefore(frag, parent.firstChild);
548
+ else if (location === 'beforeend') parent.appendChild(frag);
549
+
550
+ executeScripts(target);
551
+ };
552
+
553
+ const isPath = (s) => typeof s === 'string' && !isDangerousProtocol(s) && /^(https?:|\.|\/|[\w])|(\.(html|json|[vo]dom|cdomc?))$/i.test(s);
554
+
555
+ const fetchContent = async (src) => {
556
+ try {
557
+ const LV = globalThis.Lightview;
558
+ if (LV?.hooks?.validateUrl && !LV.hooks.validateUrl(src)) {
559
+ console.warn(`[LightviewX] Fetch blocked by validateUrl hook: ${src}`);
560
+ return null;
561
+ }
562
+ const url = new URL(src, document.baseURI);
563
+ const res = await fetch(url);
564
+ if (!res.ok) return null;
565
+ const ext = url.pathname.split('.').pop().toLowerCase();
566
+ const isJson = (ext === 'vdom' || ext === 'odom' || ext === 'cdom');
567
+ const isHtml = (ext === 'html');
568
+ const isCdom = (ext === 'cdom' || ext === 'cdomc');
569
+ const content = isJson ? await res.json() : await res.text();
570
+ return {
571
+ content,
572
+ isJson,
573
+ isHtml,
574
+ isCdom,
575
+ ext,
576
+ raw: isJson ? JSON.stringify(content) : content
577
+ };
578
+ } catch (e) {
579
+ return null;
580
+ }
581
+ };
582
+
583
+
584
+
585
+
586
+
587
+ const parseElements = (content, isJson, isHtml, el, element, isCdom = false, ext = '') => {
588
+ if (isJson) return Array.isArray(content) ? content : [content];
589
+ if (isCdom && ext === 'cdomc') {
590
+ const parser = globalThis.LightviewCDOM?.parseCDOMC;
591
+ if (parser) {
592
+ try {
593
+ const obj = parser(content);
594
+ return Array.isArray(obj) ? obj : [obj];
595
+ } catch (e) {
596
+ console.warn('LightviewX: Failed to parse .cdomc:', e);
597
+ return [];
598
+ }
599
+ } else {
600
+ console.warn('LightviewX: CDOMC parser not found. Ensure lightview-cdom.js is loaded.');
601
+ return [];
602
+ }
603
+ }
604
+ if (isHtml) {
605
+ if (el.domEl.getAttribute('escape') === 'true') return [content];
606
+ const doc = new DOMParser().parseFromString(content.replace(/<head[^>]*>[\s\S]*?<\/head>/i, ''), 'text/html');
607
+ return domToElements([...Array.from(doc.head.childNodes), ...Array.from(doc.body.childNodes)], element);
608
+ }
609
+ return [content];
610
+ };
611
+
612
+ const elementsFromSelector = (selector, element) => {
613
+ try {
614
+ const sel = document.querySelectorAll(selector);
615
+ if (!sel.length) return null;
616
+ return {
617
+ elements: domToElements(Array.from(sel), element),
618
+ raw: Array.from(sel).map(n => n.outerHTML || n.textContent).join('')
619
+ };
620
+ } catch (e) {
621
+ return null;
622
+ }
623
+ };
624
+
625
+ const updateTargetContent = (el, elements, raw, loc, contentHash, { element, setupChildren }, targetHash = null) => {
626
+ const markerId = `${loc}-${contentHash.slice(0, 8)}`;
627
+ let track = getOrSet(insertedContentMap, el.domEl, () => ({}));
628
+ if (track[loc]) removeInsertedContent(el.domEl, `${loc}-${track[loc].slice(0, 8)}`);
629
+ track[loc] = contentHash;
630
+
631
+ const performScroll = (root) => {
632
+ if (!targetHash) return;
633
+ requestAnimationFrame(() => {
634
+ requestAnimationFrame(() => {
635
+ const id = targetHash.startsWith('#') ? targetHash.slice(1) : targetHash;
636
+ const target = root.getElementById ? root.getElementById(id) : root.querySelector(`#${id}`);
637
+ if (target) {
638
+ target.style.scrollMarginTop = 'calc(var(--site-nav-height, 0px) + 2rem)';
639
+ target.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' });
640
+ }
641
+ });
642
+ });
643
+ };
644
+
645
+ if (loc === 'shadow') {
646
+ if (!el.domEl.shadowRoot) el.domEl.attachShadow({ mode: 'open' });
647
+ setupChildren(elements, el.domEl.shadowRoot);
648
+ executeScripts(el.domEl.shadowRoot);
649
+ performScroll(el.domEl.shadowRoot);
650
+ } else if (loc === 'innerhtml') {
651
+ el.children = elements;
652
+ executeScripts(el.domEl);
653
+ performScroll(document);
654
+ } else {
655
+ insert(elements, el.domEl, loc, markerId, { element, setupChildren });
656
+ performScroll(document);
657
+ }
658
+ };
659
+
660
+ /**
661
+ * Handles the 'src' attribute on non-standard tags.
662
+ * Loads content from a URL or selector and injects it into the element.
663
+ */
664
+ const handleSrcAttribute = async (el, src, tagName, { element, setupChildren }) => {
665
+ if (STANDARD_SRC_TAGS.includes(tagName)) return;
666
+
667
+ let elements = [], raw = '', targetHash = null;
668
+ if (isPath(src)) {
669
+ if (src.includes('#')) {
670
+ [src, targetHash] = src.split('#');
671
+ }
672
+ const result = await fetchContent(src);
673
+ if (result) {
674
+ elements = parseElements(result.content, result.isJson, result.isHtml, el, element, result.isCdom, result.ext);
675
+ raw = result.raw;
676
+ }
677
+ }
678
+
679
+ if (!elements.length) {
680
+ const result = elementsFromSelector(src, element);
681
+ if (result) {
682
+ elements = result.elements;
683
+ raw = result.raw;
684
+ }
685
+ }
686
+
687
+ if (!elements.length) return;
688
+
689
+ const loc = (el.domEl.getAttribute('location') || 'innerhtml').toLowerCase();
690
+ const contentHash = hashContent(raw);
691
+ const track = getOrSet(insertedContentMap, el.domEl, () => ({}));
692
+
693
+ if (track[loc] === contentHash) {
694
+ // If already loaded but we have a new hash, we should still scroll
695
+ if (targetHash) {
696
+ const root = loc === 'shadow' ? el.domEl.shadowRoot : document;
697
+ if (root) {
698
+ requestAnimationFrame(() => {
699
+ requestAnimationFrame(() => {
700
+ const id = targetHash.startsWith('#') ? targetHash.slice(1) : targetHash;
701
+ const target = root.getElementById ? root.getElementById(id) : root.querySelector?.(`#${id}`);
702
+ if (target) {
703
+ target.style.scrollMarginTop = 'calc(var(--site-nav-height, 0px) + 2rem)';
704
+ target.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' });
705
+ }
706
+ });
707
+ });
708
+ }
709
+ }
710
+ return;
711
+ }
712
+ updateTargetContent(el, elements, raw, loc, contentHash, { element, setupChildren }, targetHash);
713
+ };
714
+
715
+ // Valid location values for content insertion
716
+ const VALID_LOCATIONS = ['beforebegin', 'afterbegin', 'beforeend', 'afterend', 'innerhtml', 'outerhtml', 'shadow'];
717
+
718
+ // Parse position suffix from target string (e.g., "#box:afterbegin" -> { selector: "#box", location: "afterbegin" })
719
+ const parseTargetWithLocation = (targetStr) => {
720
+ for (const loc of VALID_LOCATIONS) {
721
+ const suffix = ':' + loc;
722
+ if (targetStr.toLowerCase().endsWith(suffix)) {
723
+ return {
724
+ selector: targetStr.slice(0, -suffix.length),
725
+ location: loc
726
+ };
727
+ }
728
+ }
729
+ return { selector: targetStr, location: null };
730
+ };
731
+
732
+ /**
733
+ * Intercepts clicks on elements with 'href' attributes that are not standard links.
734
+ * Enables HTMX-like SPA navigation by loading the href content into a target element.
735
+ */
736
+ const handleNonStandardHref = (e, { domToElement, wrapDomElement }) => {
737
+ const clickedEl = e.target.closest('[href]');
738
+ if (!clickedEl) return;
739
+
740
+ const tagName = clickedEl.tagName.toLowerCase();
741
+ if (STANDARD_HREF_TAGS.includes(tagName)) return;
742
+
743
+ e.preventDefault();
744
+
745
+ const href = clickedEl.getAttribute('href');
746
+ const LV = globalThis.Lightview;
747
+ if (href && (isDangerousProtocol(href) || (LV?.hooks?.validateUrl && !LV.hooks.validateUrl(href)))) {
748
+ console.warn(`[LightviewX] Navigation or fetch blocked by security policy: ${href}`);
749
+ return;
750
+ }
751
+ const targetAttr = clickedEl.getAttribute('target');
752
+
753
+ // Case 1: No target attribute - existing behavior (load into self)
754
+ if (!targetAttr) {
755
+ let el = domToElement.get(clickedEl);
756
+ if (!el) {
757
+ const attrs = {};
758
+ for (let attr of clickedEl.attributes) attrs[attr.name] = attr.value;
759
+ el = wrapDomElement(clickedEl, tagName, attrs);
760
+ }
761
+ const newAttrs = { ...el.attributes, src: href };
762
+ el.attributes = newAttrs;
763
+ return;
764
+ }
765
+
766
+ // Case 2: Target starts with _ (browser navigation)
767
+ if (targetAttr.startsWith('_')) {
768
+ switch (targetAttr) {
769
+ case '_self':
770
+ globalThis.location.href = href;
771
+ break;
772
+ case '_parent':
773
+ globalThis.parent.location.href = href;
774
+ break;
775
+ case '_top':
776
+ globalThis.top.location.href = href;
777
+ break;
778
+ case '_blank':
779
+ default:
780
+ // _blank or any custom _name opens a new window/tab
781
+ globalThis.open(href, targetAttr);
782
+ break;
783
+ }
784
+ return;
785
+ }
786
+
787
+ // Case 3: Target is a CSS selector (with optional :position suffix)
788
+ const { selector, location } = parseTargetWithLocation(targetAttr);
789
+
790
+ try {
791
+ const targetElements = document.querySelectorAll(selector);
792
+ targetElements.forEach(targetEl => {
793
+ let el = domToElement.get(targetEl);
794
+ if (!el) {
795
+ const attrs = {};
796
+ for (let attr of targetEl.attributes) attrs[attr.name] = attr.value;
797
+ el = wrapDomElement(targetEl, targetEl.tagName.toLowerCase(), attrs);
798
+ }
799
+
800
+ // Build new attributes
801
+ const newAttrs = { ...el.attributes, src: href };
802
+ if (location) {
803
+ newAttrs.location = location;
804
+ }
805
+ el.attributes = newAttrs;
806
+ });
807
+ } catch (err) {
808
+ console.warn('Invalid target selector:', selector, err);
809
+ }
810
+ };
811
+
812
+
813
+
814
+ // ============= LV-BEFORE (Event Gating) =============
815
+ const gateStates = new WeakMap();
816
+ const BYPASS_FLAG = '__lv_passed';
817
+ const RESUME_FLAG = '__lv_resume';
818
+
819
+ const SENSIBLE_EVENTS = [
820
+ 'click', 'dblclick', 'mousedown', 'mouseup', 'contextmenu',
821
+ 'submit', 'reset', 'change', 'input', 'invalid',
822
+ 'keydown', 'keyup', 'keypress',
823
+ 'touchstart', 'touchend'
824
+ ];
825
+ const CAPTURE_EVENTS = ['focus', 'blur'];
826
+
827
+ const getGateState = (el, key) => {
828
+ let elState = gateStates.get(el);
829
+ if (!elState) {
830
+ elState = new Map();
831
+ gateStates.set(el, elState);
832
+ }
833
+ let state = elState.get(key);
834
+ if (!state) {
835
+ state = {};
836
+ elState.set(key, state);
837
+ }
838
+ return state;
839
+ };
840
+
841
+ /**
842
+ * Gate implementation for throttle.
843
+ * Returns true if enough time has passed since the last successful run for this specific element/event/index.
844
+ */
845
+ const gateThrottle = function (ms) {
846
+ const event = arguments[arguments.length - 1];
847
+ if (event?.[RESUME_FLAG]) return true;
848
+ const key = `throttle-${event?.type || 'all'}-${ms}`;
849
+ const state = getGateState(this, key);
850
+ const now = Date.now();
851
+ if (now - (state.last || 0) >= ms) {
852
+ state.last = now;
853
+ return true;
854
+ }
855
+ return false;
856
+ };
857
+
858
+ /**
859
+ * Gate implementation for debounce.
860
+ * Returns true only after the specified delay has passed without further calls.
861
+ */
862
+ const gateDebounce = function (ms) {
863
+ const event = arguments[arguments.length - 1];
864
+ const key = `debounce-${event?.type || 'all'}-${ms}`;
865
+ const state = getGateState(this, key);
866
+
867
+ if (state.timer) clearTimeout(state.timer);
868
+
869
+ if (event?.[RESUME_FLAG] && state.passed) {
870
+ state.passed = false;
871
+ return true;
872
+ }
873
+
874
+ state.timer = setTimeout(() => {
875
+ state.passed = true;
876
+ const newEvent = new event.constructor(event.type, event);
877
+ newEvent[RESUME_FLAG] = true;
878
+ this.dispatchEvent(newEvent);
879
+ }, ms);
880
+
881
+ return false;
882
+ };
883
+
884
+ /**
885
+ * Parses the lv-before attribute value into event filters and gate functions.
886
+ */
887
+ const parseBeforeAttribute = (attrValue) => {
888
+ // Smart tokenizer that respects parentheses and quotes
889
+ const tokens = [];
890
+ let current = '', depth = 0, inQuote = null;
891
+ for (let i = 0; i < attrValue.length; i++) {
892
+ const char = attrValue[i];
893
+ if (inQuote) {
894
+ current += char;
895
+ if (char === inQuote && attrValue[i - 1] !== '\\') inQuote = null;
896
+ } else if (char === "'" || char === '"') {
897
+ inQuote = char;
898
+ current += char;
899
+ } else if (char === '(') {
900
+ depth++;
901
+ current += char;
902
+ } else if (char === ')') {
903
+ depth--;
904
+ current += char;
905
+ } else if (/\s/.test(char) && depth === 0) {
906
+ if (current) tokens.push(current);
907
+ current = '';
908
+ } else {
909
+ current += char;
910
+ }
911
+ }
912
+ if (current) tokens.push(current);
913
+
914
+ const events = [];
915
+ const exclusions = [];
916
+ const calls = [];
917
+
918
+ let i = 0;
919
+ while (i < tokens.length) {
920
+ const token = tokens[i];
921
+ if (!token || token.includes('(')) break; // Start of function calls or empty
922
+ if (token.startsWith('!')) exclusions.push(token.slice(1));
923
+ else events.push(token);
924
+ i++;
925
+ }
926
+
927
+ while (i < tokens.length) {
928
+ if (tokens[i]) calls.push(tokens[i]);
929
+ i++;
930
+ }
931
+
932
+ return { events, exclusions, calls };
933
+ };
934
+
935
+ /**
936
+ * Global interceptor for lv-before gating.
937
+ */
938
+ const globalBeforeInterceptor = async (e) => {
939
+ if (e[BYPASS_FLAG]) return;
940
+
941
+ const target = e.target.closest?.('[lv-before]');
942
+ if (!target) return;
943
+
944
+ const { events, exclusions, calls } = parseBeforeAttribute(target.getAttribute('lv-before'));
945
+
946
+ // Check if event matches the selection
947
+ const isExcluded = exclusions.includes(e.type);
948
+ const isIncluded = events.includes('*') || events.includes(e.type);
949
+ if (isExcluded || !isIncluded) return;
950
+
951
+ // Pass 1: Stop the event
952
+ e.stopImmediatePropagation();
953
+ e.preventDefault();
954
+
955
+ // Run the pipeline
956
+ for (const callStr of calls) {
957
+ try {
958
+ // Parse call (e.g., "throttle(1000)")
959
+ const match = callStr.match(/^([\w\.]+)\((.*)\)$/);
960
+ if (!match) continue;
961
+
962
+ const funcName = match[1];
963
+ const argsStr = match[2];
964
+
965
+ // Search for function in: global scope, LightviewX
966
+ const LV = globalThis.Lightview;
967
+ const LVX = globalThis.LightviewX;
968
+
969
+ // Enhanced function lookup supporting dotted paths
970
+ let fn = funcName.split('.').reduce((obj, key) => obj?.[key], globalThis);
971
+
972
+ if (!fn && funcName === 'throttle') fn = gateThrottle;
973
+ if (!fn && funcName === 'debounce') fn = gateDebounce;
974
+ if (!fn && LVX && LVX[funcName]) fn = LVX[funcName];
975
+
976
+ if (typeof fn !== 'function') {
977
+ console.warn(`LightviewX: lv-before function '${funcName}' not found`);
978
+ continue;
979
+ }
980
+
981
+ // Eval arguments in context
982
+ const evalArgs = new Function('event', 'state', 'signal', `return [${argsStr}]`);
983
+ const args = evalArgs.call(target, e, LV?.state || {}, LV?.signal || {});
984
+
985
+ // Inject event as last argument for built-ins and detection
986
+ args.push(e);
987
+
988
+ let result = fn.apply(target, args);
989
+ if (result instanceof Promise) result = await result;
990
+ if (result === false || result === null || result === undefined) return; // Abort
991
+ } catch (err) {
992
+ console.error(`LightviewX: Error executing lv-before gate '${callStr}':`, err);
993
+ return; // Abort on error
994
+ }
995
+ }
996
+
997
+ // Pass 2: Success! Re-dispatch with bypass flag
998
+ const finalEvent = new e.constructor(e.type, e);
999
+ finalEvent[BYPASS_FLAG] = true;
1000
+ target.dispatchEvent(finalEvent);
1001
+ };
1002
+
1003
+
1004
+
1005
+ // ============= DOM OBSERVER FOR SRC ATTRIBUTES =============
1006
+
1007
+ /**
1008
+ * Process src attribute on a DOM element that doesn't normally have src
1009
+ * @param {HTMLElement} node - DOM element to process
1010
+ * @param {Object} LV - Lightview instance
1011
+ */
1012
+ const processSrcOnNode = (node, LV) => {
1013
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
1014
+
1015
+ const tagName = node.tagName.toLowerCase();
1016
+ if (isStandardSrcTag(tagName)) return;
1017
+
1018
+ const src = node.getAttribute('src');
1019
+ if (!src) return;
1020
+
1021
+ // Get or create reactive wrapper
1022
+ let el = LV.internals.domToElement.get(node);
1023
+ if (!el) {
1024
+ const attrs = {};
1025
+ for (let attr of node.attributes) attrs[attr.name] = attr.value;
1026
+ el = LV.internals.wrapDomElement(node, tagName, attrs, []);
1027
+ }
1028
+
1029
+ handleSrcAttribute(el, src, tagName, {
1030
+ element: LV.element,
1031
+ setupChildren: LV.internals.setupChildren
1032
+ });
1033
+ };
1034
+
1035
+ // Track nodes to avoid double-processing
1036
+ const processedNodes = new WeakSet();
1037
+
1038
+ /**
1039
+ * Activate reactive syntax (${...}) in existing DOM nodes
1040
+ * Uses XPath for performance optimization
1041
+ * @param {Node} root - Root node to start scanning from
1042
+ * @param {Object} LV - Lightview instance
1043
+ */
1044
+ const activateReactiveSyntax = (root, LV) => {
1045
+ if (!root || !LV) return;
1046
+
1047
+ const bindEffect = (node, codeStr, isAttr = false, attrName = null) => {
1048
+ if (processedNodes.has(node) && !isAttr) return;
1049
+ if (!isAttr) processedNodes.add(node);
1050
+
1051
+ const fn = compileTemplate(codeStr);
1052
+ LV.effect(() => {
1053
+ try {
1054
+ const val = fn(LV.state, LV.signal);
1055
+ if (isAttr) {
1056
+ if (attrName.startsWith('cdom-')) {
1057
+ node[attrName] = val;
1058
+ } else {
1059
+ (val === null || val === undefined || val === false) ? node.removeAttribute(attrName) : node.setAttribute(attrName, val);
1060
+ }
1061
+ } else node.textContent = val !== undefined ? val : '';
1062
+ } catch (e) { /* Effect execution failed */ }
1063
+ });
1064
+ };
1065
+
1066
+ // 1. Find Text Nodes containing '${'
1067
+ const textXPath = ".//text()[contains(., '${')]";
1068
+ const textResult = document.evaluate(
1069
+ textXPath,
1070
+ root,
1071
+ null,
1072
+ XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
1073
+ null
1074
+ );
1075
+
1076
+ for (let i = 0; i < textResult.snapshotLength; i++) {
1077
+ const node = textResult.snapshotItem(i);
1078
+ // Verify it's not inside a skip tag (XPath might pick them up if defined loosely)
1079
+ if (node.parentElement && node.parentElement.closest('SCRIPT, STYLE, CODE, PRE, TEMPLATE, NOSCRIPT')) continue;
1080
+ bindEffect(node, node.textContent);
1081
+ }
1082
+
1083
+ // 2. Find Elements with Attributes containing '${'
1084
+ // XPath: select any element (*) that has an attribute (@*) containing '${'
1085
+ const attrXPath = ".//*[@*[contains(., '${')]]";
1086
+ const attrResult = document.evaluate(
1087
+ attrXPath,
1088
+ root,
1089
+ null,
1090
+ XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
1091
+ null
1092
+ );
1093
+
1094
+ for (let i = 0; i < attrResult.snapshotLength; i++) {
1095
+ const element = attrResult.snapshotItem(i);
1096
+ if (['SCRIPT', 'STYLE', 'CODE', 'PRE', 'TEMPLATE', 'NOSCRIPT'].includes(element.tagName)) continue;
1097
+
1098
+ // Iterate attributes to find matches (XPath found the element, but not *which* attribute)
1099
+ Array.from(element.attributes).forEach(attr => {
1100
+ if (attr.value.includes('${')) {
1101
+ bindEffect(element, attr.value, true, attr.name);
1102
+ }
1103
+ });
1104
+ }
1105
+
1106
+ // Also check the root itself (XPath .// does not always include the context node for attributes depending on implementation details, safer to check manually if root is element)
1107
+ if (root.nodeType === Node.ELEMENT_NODE && !['SCRIPT', 'STYLE', 'CODE', 'PRE', 'TEMPLATE', 'NOSCRIPT'].includes(root.tagName)) {
1108
+ Array.from(root.attributes).forEach(attr => {
1109
+ if (attr.value.includes('${')) {
1110
+ bindEffect(root, attr.value, true, attr.name);
1111
+ }
1112
+ });
1113
+ }
1114
+ };
1115
+
1116
+ const processAddedNode = (node, nodesToProcess, nodesToActivate) => {
1117
+ if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) {
1118
+ nodesToActivate.push(node);
1119
+ }
1120
+
1121
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
1122
+
1123
+ // Check the added node itself for src
1124
+ nodesToProcess.push(node);
1125
+
1126
+ // Check descendants with src attribute
1127
+ const selector = '[src]:not(' + STANDARD_SRC_TAGS.join('):not(') + ')';
1128
+ const descendants = node.querySelectorAll(selector);
1129
+ for (const desc of descendants) {
1130
+ if (!desc.tagName.toLowerCase().startsWith('lv-')) {
1131
+ nodesToProcess.push(desc);
1132
+ }
1133
+ }
1134
+ };
1135
+
1136
+ const collectNodesFromMutations = (mutations) => {
1137
+ const nodesToProcess = [];
1138
+ const nodesToActivate = [];
1139
+
1140
+ for (const mutation of mutations) {
1141
+ if (mutation.type === 'childList') {
1142
+ mutation.addedNodes.forEach(node => processAddedNode(node, nodesToProcess, nodesToActivate));
1143
+ } else if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
1144
+ nodesToProcess.push(mutation.target);
1145
+ }
1146
+ }
1147
+ return { nodesToProcess, nodesToActivate };
1148
+ };
1149
+
1150
+ /**
1151
+ * Setup MutationObserver to watch for added nodes with src attributes OR reactive syntax
1152
+ * @param {Object} LV - Lightview instance
1153
+ */
1154
+ const setupSrcObserver = (LV) => {
1155
+ const observer = new MutationObserver((mutations) => {
1156
+ const { nodesToProcess, nodesToActivate } = collectNodesFromMutations(mutations);
1157
+
1158
+ if (nodesToProcess.length > 0 || nodesToActivate.length > 0) {
1159
+ requestAnimationFrame(() => {
1160
+ nodesToActivate.forEach(node => activateReactiveSyntax(node, LV));
1161
+ nodesToProcess.forEach(node => processSrcOnNode(node, LV));
1162
+ });
1163
+ }
1164
+ });
1165
+
1166
+ observer.observe(document.body, {
1167
+ childList: true,
1168
+ subtree: true,
1169
+ attributes: true,
1170
+ attributeFilter: ['src']
1171
+ });
1172
+
1173
+ return observer;
1174
+ };
1175
+
1176
+ // Auto-register with Lightview if available
1177
+ if (typeof window !== 'undefined' && globalThis.Lightview) {
1178
+ const LV = globalThis.Lightview;
1179
+
1180
+ // Extend Lightview with simple named signal getter/setter if needed (already in Core now)
1181
+ // But for template literals we use processTemplateChild which needs access to registries
1182
+ // We can just rely on LV.signal.get if it exists, or fall back
1183
+
1184
+ // Setup DOM observer for src attributes on added nodes
1185
+
1186
+ // Setup DOM observer for src attributes on added nodes
1187
+ if (document.readyState === 'loading') {
1188
+ document.addEventListener('DOMContentLoaded', () => setupSrcObserver(LV));
1189
+ } else {
1190
+ setupSrcObserver(LV);
1191
+ }
1192
+
1193
+ // Also process any existing elements
1194
+ const initialScan = () => {
1195
+ requestAnimationFrame(() => {
1196
+ activateReactiveSyntax(document.body, LV);
1197
+
1198
+ const selector = '[src]:not(' + STANDARD_SRC_TAGS.join('):not(') + ')';
1199
+ const nodes = document.querySelectorAll(selector);
1200
+ nodes.forEach(node => {
1201
+ if (node.tagName.toLowerCase().startsWith('lv-')) return;
1202
+ processSrcOnNode(node, LV);
1203
+ });
1204
+ });
1205
+ };
1206
+
1207
+ if (document.body) {
1208
+ initialScan();
1209
+ } else {
1210
+ document.addEventListener('DOMContentLoaded', initialScan);
1211
+ }
1212
+
1213
+ // Register href click handler
1214
+ LV.hooks.onNonStandardHref = (e) => {
1215
+ handleNonStandardHref(e, {
1216
+ domToElement: LV.internals.domToElement,
1217
+ wrapDomElement: LV.internals.wrapDomElement
1218
+ });
1219
+ };
1220
+
1221
+ // Register lv-before listeners
1222
+ SENSIBLE_EVENTS.forEach(ev => window.addEventListener(ev, globalBeforeInterceptor, true));
1223
+ CAPTURE_EVENTS.forEach(ev => window.addEventListener(ev, globalBeforeInterceptor, true));
1224
+
1225
+ // Unified processChild hook for LightviewX
1226
+ // Handles: Object DOM, HDOM Expressions, Template Literals
1227
+ LV.hooks.processChild = (child) => {
1228
+ if (!child) return child;
1229
+
1230
+ // 1. Convert Object DOM syntax if applicable
1231
+ if (typeof child === 'object' && !Array.isArray(child) && !child.tag && !child.domEl) {
1232
+ child = convertObjectDOM(child);
1233
+ }
1234
+
1235
+ // 2. Handle CDOM expressions ($/..., $helper(...), $path)
1236
+ // Checks if string starts with '$' and follows with non-digit to avoid matching currency like '$100'
1237
+ if (typeof child === 'string' && child.startsWith('$') && isNaN(parseInt(child[1]))) {
1238
+ const CDOM = globalThis.LightviewCDOM;
1239
+ if (CDOM) return CDOM.parseExpression(child);
1240
+ }
1241
+
1242
+ // 3. Handle object strings that look like ODOM/VDOM but are results of CDOM or static JSON
1243
+ // Prioritize this for curly-brace string content
1244
+ if (typeof child === 'string' && (child.trim().startsWith('{') || child.trim().startsWith('['))) {
1245
+ try {
1246
+ // simple heuristic to check if it's safe JSON-like or needs evaluation
1247
+ const parsed = new Function('return (' + child + ')')();
1248
+
1249
+ // If parsed is a plain object or array, we might want to convert it recursively
1250
+ if (typeof parsed === 'object' && parsed !== null) {
1251
+ if (Array.isArray(parsed)) {
1252
+ return parsed; // Will be processed as array by core
1253
+ }
1254
+ if (parsed.tag || parsed.domEl) {
1255
+ return parsed; // VDOM object
1256
+ }
1257
+ // ODOM object?
1258
+ return convertObjectDOM(parsed);
1259
+ }
1260
+ } catch (e) { /* Not an object string */ }
1261
+ }
1262
+
1263
+ // 4. Process template literals (${...})
1264
+ return processTemplateChild(child, {
1265
+ state: state,
1266
+ signal: LV.signal
1267
+ });
1268
+ };
1269
+ }
1270
+
1271
+
1272
+
1273
+ /**
1274
+ * Create a Custom Element class wrapper for a Lightview component
1275
+ * @param {Function} Component - The Lightview component function
1276
+ * @param {Object} options
1277
+ * @param {string} options.cssUrl - Optional URL for component CSS
1278
+ * @param {string[]} options.styles - Optional extra style URLs
1279
+ * @returns {Class} - The Custom Element class
1280
+ */
1281
+ const createCustomElement = (Component, options = {}) => {
1282
+ return class extends HTMLElement {
1283
+ constructor() {
1284
+ super();
1285
+ this.attachShadow({ mode: 'open' });
1286
+ }
1287
+
1288
+ async connectedCallback() {
1289
+ const { cssUrl, styles } = options;
1290
+
1291
+ // Create theme wrapper
1292
+ this.themeWrapper = document.createElement('div');
1293
+ this.themeWrapper.style.display = 'contents';
1294
+ // Sync theme from document
1295
+ const syncTheme = () => {
1296
+ const theme = document.documentElement.getAttribute('data-theme') || 'light';
1297
+ this.themeWrapper.setAttribute('data-theme', theme);
1298
+ };
1299
+ syncTheme();
1300
+
1301
+ // Observe theme changes
1302
+ this.themeObserver = new MutationObserver(syncTheme);
1303
+ this.themeObserver.observe(document.documentElement, {
1304
+ attributes: true,
1305
+ attributeFilter: ['data-theme']
1306
+ });
1307
+
1308
+ // Attach wrapper
1309
+ this.shadowRoot.appendChild(this.themeWrapper);
1310
+
1311
+ // Get stylesheets
1312
+ const adoptedStyleSheets = getAdoptedStyleSheets(cssUrl, styles);
1313
+
1314
+ // Handle adoptedStyleSheets
1315
+ try {
1316
+ const sheets = adoptedStyleSheets.filter(s => s instanceof CSSStyleSheet);
1317
+ this.shadowRoot.adoptedStyleSheets = sheets;
1318
+ } catch (e) {
1319
+ // Fallback handled by individual links below if needed
1320
+ }
1321
+
1322
+ // Handle link tags for strings (fallback or external non-CORS sheets)
1323
+ // Also fallback for DaisyUI if not loaded as adoptedStyleSheet
1324
+ if (!componentConfig.daisyStyleSheet) {
1325
+ const link = document.createElement('link');
1326
+ link.rel = 'stylesheet';
1327
+ link.href = DAISYUI_CDN;
1328
+ this.shadowRoot.appendChild(link);
1329
+ }
1330
+
1331
+ adoptedStyleSheets.forEach(s => {
1332
+ if (typeof s === 'string') {
1333
+ const link = document.createElement('link');
1334
+ link.rel = 'stylesheet';
1335
+ link.href = s;
1336
+ this.shadowRoot.appendChild(link);
1337
+ }
1338
+ });
1339
+
1340
+ // Define render function
1341
+ this.render = () => {
1342
+ // Collect props from attributes
1343
+ const props = {};
1344
+ for (const attr of this.attributes) {
1345
+ // Convert kebab-case to camelCase
1346
+ const name = attr.name.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
1347
+
1348
+ // Convert boolean attributes
1349
+ if (attr.value === '') {
1350
+ props[name] = true;
1351
+ } else {
1352
+ props[name] = attr.value;
1353
+ }
1354
+ }
1355
+
1356
+ // Force useShadow: false to avoid double shadow
1357
+ props.useShadow = false;
1358
+
1359
+ // Render component with a slot for children
1360
+ const slot = globalThis.Lightview.tags.slot();
1361
+ const result = Component(props, slot);
1362
+
1363
+ // Use Lightview's internal setupChildren to render the result
1364
+ // This handles vDOM, DOM nodes, strings, and reactive content
1365
+ globalThis.Lightview.internals.setupChildren([result], this.themeWrapper);
1366
+ };
1367
+
1368
+ if (typeof MutationObserver !== 'undefined' && typeof HTMLElement !== 'undefined') {
1369
+ // Observe attribute changes on self to trigger re-render
1370
+ this.attrObserver = new MutationObserver((mutations) => {
1371
+ // Only re-render if actual attributes changed
1372
+ this.render();
1373
+ });
1374
+ this.attrObserver.observe(this, {
1375
+ attributes: true
1376
+ });
1377
+ }
1378
+
1379
+ // Initial render
1380
+ this.render();
1381
+ }
1382
+
1383
+ disconnectedCallback() {
1384
+ if (this.themeObserver) {
1385
+ this.themeObserver.disconnect();
1386
+ }
1387
+ if (this.attrObserver) {
1388
+ this.attrObserver.disconnect();
1389
+ }
1390
+ }
1391
+ };
1392
+ };
1393
+
1394
+ /**
1395
+ * Custom Element Wrapper Factory
1396
+ * Auto-generates custom element classes that wrap functional components
1397
+ * @param {Function} Component - The functional component to wrap
1398
+ * @param {Object} config - Configuration object
1399
+ * @param {Object} config.attributeMap - Maps attribute names to their types (String, Boolean, Number)
1400
+ * @param {Object} config.childElements - Maps child element tag names to their component info
1401
+ * @returns {Class} - Custom element class
1402
+ */
1403
+ const customElementWrapper = (Component, config = {}) => {
1404
+ const {
1405
+ attributeMap = {},
1406
+ childElements = {}
1407
+ } = config;
1408
+
1409
+ return class extends HTMLElement {
1410
+ constructor() {
1411
+ super();
1412
+ this.attachShadow({ mode: 'open' });
1413
+ }
1414
+
1415
+ connectedCallback() {
1416
+ let adopted = false;
1417
+ // Attempt to use pre-parsed adopted stylesheets for performance
1418
+ if (componentConfig.daisyStyleSheet) {
1419
+ try {
1420
+ const sheets = [componentConfig.daisyStyleSheet];
1421
+ if (componentConfig.themeStyleSheet) {
1422
+ sheets.push(componentConfig.themeStyleSheet);
1423
+ }
1424
+ this.shadowRoot.adoptedStyleSheets = sheets;
1425
+ adopted = true;
1426
+ } catch (e) {
1427
+ // Browser might not support adoptedStyleSheets
1428
+ }
1429
+ }
1430
+
1431
+ // Fallback to link tag if adoption failed or sheet wasn't loaded yet
1432
+ if (!adopted) {
1433
+ const link = document.createElement('link');
1434
+ link.rel = 'stylesheet';
1435
+ link.href = DAISYUI_CDN;
1436
+ this.shadowRoot.appendChild(link);
1437
+ }
1438
+
1439
+ // Sync theme from document
1440
+ const themeWrapper = document.createElement('div');
1441
+ themeWrapper.setAttribute('data-theme', document.documentElement.getAttribute('data-theme') || 'light');
1442
+ themeWrapper.style.display = 'contents';
1443
+ this.shadowRoot.appendChild(themeWrapper);
1444
+ this.themeWrapper = themeWrapper;
1445
+
1446
+ // Observe theme changes
1447
+ this.themeObserver = new MutationObserver(() => {
1448
+ const theme = document.documentElement.getAttribute('data-theme') || 'light';
1449
+ this.themeWrapper.setAttribute('data-theme', theme);
1450
+ });
1451
+ this.themeObserver.observe(document.documentElement, {
1452
+ attributes: true,
1453
+ attributeFilter: ['data-theme']
1454
+ });
1455
+
1456
+ this.render();
1457
+
1458
+ // Observe attributes
1459
+ const attrs = Object.keys(attributeMap);
1460
+ if (attrs.length > 0) {
1461
+ this.attrObserver = new MutationObserver(() => this.render());
1462
+ this.attrObserver.observe(this, {
1463
+ attributes: true,
1464
+ attributeFilter: attrs
1465
+ });
1466
+ }
1467
+
1468
+ // Observe children if specified
1469
+ if (Object.keys(childElements).length > 0) {
1470
+ this.childObserver = new MutationObserver(() => this.render());
1471
+ this.childObserver.observe(this, {
1472
+ childList: true,
1473
+ subtree: true,
1474
+ attributes: true
1475
+ });
1476
+ }
1477
+ }
1478
+
1479
+ disconnectedCallback() {
1480
+ if (this.themeObserver) this.themeObserver.disconnect();
1481
+ if (this.attrObserver) this.attrObserver.disconnect();
1482
+ if (this.childObserver) this.childObserver.disconnect();
1483
+ }
1484
+
1485
+ parseChildrenToVDOM() {
1486
+ return Array.from(this.children).map(child => {
1487
+ const tagName = child.tagName.toLowerCase();
1488
+ const componentInfo = childElements[tagName];
1489
+
1490
+ if (!componentInfo) return null;
1491
+
1492
+ const { component, attributeMap = {} } = componentInfo;
1493
+ const attributes = {};
1494
+
1495
+ // Parse all attributes
1496
+ for (const attr of child.attributes) {
1497
+ const name = attr.name.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
1498
+ const type = attributeMap[name];
1499
+ const value = attr.value;
1500
+
1501
+ if (type === Boolean) {
1502
+ attributes[name] = value === 'true' || value === '';
1503
+ } else if (type === Number) {
1504
+ attributes[name] = Number(value);
1505
+ } else if (type === Array || type === Object) {
1506
+ try {
1507
+ attributes[name] = JSON.parse(value);
1508
+ } catch (e) {
1509
+ console.warn(`[Lightview] Failed to parse child attribute ${name} as JSON:`, value);
1510
+ attributes[name] = value;
1511
+ }
1512
+ } else {
1513
+ attributes[name] = value;
1514
+ }
1515
+ }
1516
+
1517
+ // Copy event handlers
1518
+ if (child.onclick) attributes.onclick = child.onclick.bind(child);
1519
+
1520
+ return {
1521
+ tag: component,
1522
+ attributes,
1523
+ children: Array.from(child.childNodes)
1524
+ };
1525
+ }).filter(Boolean);
1526
+ }
1527
+
1528
+ render() {
1529
+ // Build props from attributes
1530
+ const props = { useShadow: false }; // Wrapper already created shadow DOM
1531
+
1532
+ // Collect all attributes
1533
+ for (const attr of this.attributes) {
1534
+ const name = attr.name.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
1535
+ const type = attributeMap[name];
1536
+ const value = attr.value;
1537
+
1538
+ if (type === Boolean) {
1539
+ props[name] = value === 'true' || value === '';
1540
+ } else if (type === Number) {
1541
+ props[name] = Number(value);
1542
+ } else if (type === Array || type === Object) {
1543
+ try {
1544
+ props[name] = JSON.parse(value);
1545
+ } catch (e) {
1546
+ console.warn(`[Lightview] Failed to parse ${name} as JSON:`, value);
1547
+ props[name] = value;
1548
+ }
1549
+ } else {
1550
+ props[name] = value;
1551
+ }
1552
+ }
1553
+
1554
+ const vdomChildren = this.parseChildrenToVDOM();
1555
+ // If no child elements are mapped, use a slot to project light DOM
1556
+ const children = Object.keys(childElements).length > 0 ? vdomChildren : [{ tag: globalThis.Lightview.tags.slot }];
1557
+ const result = Component(props, ...children);
1558
+
1559
+ // Use Lightview's internal rendering
1560
+ if (globalThis.Lightview?.internals?.setupChildren && this.themeWrapper) {
1561
+ this.themeWrapper.innerHTML = '';
1562
+ globalThis.Lightview.internals.setupChildren([result], this.themeWrapper);
1563
+ }
1564
+ }
1565
+
1566
+ static get observedAttributes() {
1567
+ return Object.keys(attributeMap);
1568
+ }
1569
+
1570
+ attributeChangedCallback() {
1571
+ this.render();
1572
+ }
1573
+ };
1574
+ };
1575
+
1576
+ // Export for module usage
1577
+ const LightviewX = {
1578
+ state,
1579
+ themeSignal,
1580
+ setTheme,
1581
+ registerStyleSheet,
1582
+ registerThemeSheet,
1583
+ // Gate modifiers
1584
+ throttle: gateThrottle,
1585
+ debounce: gateDebounce,
1586
+ // Component initialization
1587
+ initComponents,
1588
+ componentConfig,
1589
+ shouldUseShadow,
1590
+ getAdoptedStyleSheets,
1591
+ preloadComponentCSS,
1592
+ createCustomElement,
1593
+ customElementWrapper,
1594
+ internals: {
1595
+ handleSrcAttribute,
1596
+ parseElements
1597
+ }
1598
+ };
1599
+
1600
+ if (typeof module !== 'undefined' && module.exports) {
1601
+ module.exports = LightviewX;
1602
+ }
1603
+ if (typeof window !== 'undefined') {
1604
+ globalThis.LightviewX = LightviewX;
1605
+ }
1606
+
1607
+ // Initialize component hook to use Object DOM
1608
+ if (typeof window !== 'undefined') {
1609
+ // Auto-load theme
1610
+ try {
1611
+ const savedTheme = getSavedTheme();
1612
+ if (savedTheme) {
1613
+ setTheme(savedTheme);
1614
+ }
1615
+ } catch (e) { /* ignore */ }
1616
+
1617
+ if (typeof window !== 'undefined' && globalThis.Lightview) {
1618
+ if (!globalThis.Lightview.hooks.validateUrl) {
1619
+ globalThis.Lightview.hooks.validateUrl = validateUrl;
1620
+ }
1621
+ }
1622
+ }
1623
+
1624
+ // Server-side initialization if globally available
1625
+ if (typeof globalThis !== 'undefined' && globalThis.Lightview) {
1626
+ if (!globalThis.Lightview.hooks.validateUrl) {
1627
+ globalThis.Lightview.hooks.validateUrl = validateUrl;
1628
+ }
1629
+ }
1630
+