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.
- package/build-bundles.mjs +105 -0
- package/build.js +236 -46
- package/components/actions/button.js +16 -3
- package/components/actions/swap.js +26 -3
- package/components/daisyui.js +1 -1
- package/components/data-display/alert.js +13 -3
- package/components/data-display/avatar.js +25 -1
- package/components/data-display/badge.js +11 -3
- package/components/data-display/chart.js +22 -5
- package/components/data-display/countdown.js +3 -2
- package/components/data-display/kbd.js +9 -3
- package/components/data-display/loading.js +11 -3
- package/components/data-display/progress.js +11 -3
- package/components/data-display/radial-progress.js +12 -3
- package/components/data-display/tooltip.js +17 -0
- package/components/data-input/checkbox.js +23 -1
- package/components/data-input/input.js +24 -1
- package/components/data-input/radio.js +37 -2
- package/components/data-input/select.js +24 -1
- package/components/data-input/toggle.js +21 -1
- package/components/layout/divider.js +21 -1
- package/components/layout/indicator.js +14 -0
- package/components/navigation/breadcrumbs.js +42 -2
- package/components/navigation/tabs.js +291 -16
- package/docs/api/elements.html +125 -49
- package/docs/api/hypermedia.html +29 -2
- package/docs/api/index.html +6 -2
- package/docs/api/nav.html +18 -4
- package/docs/assets/js/examplify.js +1 -1
- package/docs/cdom-nav.html +55 -0
- package/docs/cdom.html +792 -0
- package/docs/components/alert.html +8 -8
- package/docs/components/avatar.html +24 -54
- package/docs/components/badge.html +69 -14
- package/docs/components/breadcrumbs.html +95 -29
- package/docs/components/button.html +78 -92
- package/docs/components/chart-area.html +3 -3
- package/docs/components/chart-bar.html +4 -181
- package/docs/components/chart-column.html +4 -189
- package/docs/components/chart-line.html +3 -3
- package/docs/components/chart-pie.html +112 -166
- package/docs/components/chart.html +11 -13
- package/docs/components/checkbox.html +48 -28
- package/docs/components/collapse.html +6 -6
- package/docs/components/component-nav.html +1 -1
- package/docs/components/countdown.html +12 -12
- package/docs/components/divider.html +65 -21
- package/docs/components/dropdown.html +1 -1
- package/docs/components/file-input.html +4 -4
- package/docs/components/footer.html +11 -11
- package/docs/components/indicator.html +85 -31
- package/docs/components/input.html +45 -29
- package/docs/components/join.html +4 -4
- package/docs/components/kbd.html +67 -28
- package/docs/components/loading.html +96 -92
- package/docs/components/pagination.html +4 -4
- package/docs/components/progress.html +50 -7
- package/docs/components/radial-progress.html +32 -12
- package/docs/components/radio.html +42 -31
- package/docs/components/select.html +48 -59
- package/docs/components/swap.html +183 -100
- package/docs/components/tabs.html +146 -278
- package/docs/components/toggle.html +44 -25
- package/docs/components/tooltip.html +71 -31
- package/docs/getting-started/index.html +8 -6
- package/docs/index.html +1 -1
- package/docs/syntax-nav.html +10 -0
- package/docs/syntax.html +8 -6
- package/index.html +2 -2
- package/jprx/LICENSE +21 -0
- package/jprx/README.md +130 -0
- package/jprx/helpers/array.js +75 -0
- package/jprx/helpers/compare.js +26 -0
- package/jprx/helpers/conditional.js +34 -0
- package/jprx/helpers/datetime.js +54 -0
- package/jprx/helpers/format.js +20 -0
- package/jprx/helpers/logic.js +24 -0
- package/jprx/helpers/lookup.js +25 -0
- package/jprx/helpers/math.js +34 -0
- package/jprx/helpers/network.js +41 -0
- package/jprx/helpers/state.js +80 -0
- package/jprx/helpers/stats.js +39 -0
- package/jprx/helpers/string.js +49 -0
- package/jprx/index.js +69 -0
- package/jprx/package.json +24 -0
- package/jprx/parser.js +1517 -0
- package/lightview-all.js +3785 -0
- package/lightview-cdom.js +2128 -0
- package/lightview-router.js +179 -208
- package/lightview-x.js +1435 -1608
- package/lightview.js +613 -766
- package/lightview.js.bak +1 -0
- package/package.json +10 -3
- package/src/lightview-all.js +10 -0
- package/src/lightview-cdom.js +457 -0
- package/src/lightview-router.js +210 -0
- package/src/lightview-x.js +1630 -0
- package/src/lightview.js +705 -0
- package/src/reactivity/signal.js +133 -0
- package/src/reactivity/state.js +217 -0
- package/{watch.js → start-dev.js} +2 -1
- package/tests/cdom/fixtures/helpers.cdomc +62 -0
- package/tests/cdom/fixtures/user.cdom +14 -0
- package/tests/cdom/fixtures/user.cdomc +12 -0
- package/tests/cdom/fixtures/user.odom +18 -0
- package/tests/cdom/fixtures/user.vdom +11 -0
- package/tests/cdom/helpers.test.js +121 -0
- package/tests/cdom/loader.test.js +125 -0
- package/tests/cdom/parser.test.js +179 -0
- package/tests/cdom/reactivity.test.js +186 -0
- package/tests/text-tag.test.js +77 -0
- package/vite.config.mjs +52 -0
- package/wrangler.toml +0 -3
- package/components/data-display/skeleton.js +0 -66
- 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
|
+
|