lightview
Version:
A reactive UI library with features of Bau, Juris, and HTMX plus safe LLM UI generation
1,333 lines (1,149 loc) • 72.1 kB
JavaScript
import { signal, effect, getRegistry } from './reactivity/signal.js';
import { state, getState, getOrSet } from './reactivity/state.js';
/**
* LIGHTVIEW-X
* Hypermedia and Extended Reactivity for Lightview.
*/
const STANDARD_SRC_TAGS = ['img', 'script', 'iframe', 'video', 'audio', 'source', 'track', 'embed', 'input'];
const isStandardSrcTag = (tagName) => STANDARD_SRC_TAGS.includes(tagName) || tagName.startsWith('lv-');
const STANDARD_HREF_TAGS = ['a', 'area', 'base', 'link'];
const isValidTagName = (name) => typeof name === 'string' && name.length > 0 && name !== 'children';
/**
* Checks if a URL/string uses a dangerous protocol like javascript: or data: (for navigation).
*/
const isDangerousProtocol = (url) => {
if (!url || typeof url !== 'string') return false;
const normalized = url.trim().toLowerCase();
// Specifically block javascript, vbscript, and data (when used for HTML/navigation)
return normalized.startsWith('javascript:') ||
normalized.startsWith('vbscript:') ||
normalized.startsWith('data:text/html') ||
normalized.startsWith('data:application/javascript');
};
/**
* Validates a URL before fetching content.
* Default implementation allows same domain and its subdomains (ignoring port).
*/
const validateUrl = (url) => {
if (!url) return false;
// If it doesn't look like a full URL (no protocol), assume it's relative and valid
// This avoids issues in sandboxed iframes where location.origin might be 'null'
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) return true;
try {
const base = (typeof document !== 'undefined') ? document.baseURI : globalThis.location.origin;
// If base is 'null' (sandboxed iframe), new URL(url, 'null') will throw if url is absolute
// But if it's absolute, we don't strictly need the base.
const target = new URL(url, base === 'null' ? undefined : base);
const current = globalThis.location;
// Allow same origin (matches protocol, host, and port)
if (target.origin === current.origin && target.origin !== 'null') return true;
// Allow same hostname (matches host, ignores port/protocol)
// This specifically allows different ports on the same host (e.g., localhost:3000 -> localhost:4000)
if (target.hostname && target.hostname === current.hostname) return true;
// Allow subdomains
if (target.hostname && current.hostname && target.hostname.endsWith('.' + current.hostname)) return true;
// Support local file protocol
if (current.protocol === 'file:' && target.protocol === 'file:') return true;
return false;
} catch (e) {
return false;
}
};
/**
* Detects if an object follows the Object DOM syntax: { tag: { attr: val, children: [...] } }
*/
const isObjectDOM = (obj) => {
if (typeof obj !== 'object' || obj === null || Array.isArray(obj) || obj.tag || obj.domEl) return false;
const keys = Object.keys(obj);
return keys.length === 1 && isValidTagName(keys[0]) && typeof obj[keys[0]] === 'object';
};
/**
* Converts Object DOM syntax into standard Lightview VDOM { tag, attributes, children }
*/
const convertObjectDOM = (obj) => {
if (typeof obj !== 'object' || obj === null) return obj;
if (Array.isArray(obj)) return obj.map(convertObjectDOM);
if (obj.tag) return { ...obj, children: obj.children ? convertObjectDOM(obj.children) : [] };
if (obj.domEl || !isObjectDOM(obj)) return obj;
const tagKey = Object.keys(obj)[0];
const content = obj[tagKey];
const LV = typeof window !== 'undefined' ? globalThis.Lightview : (typeof globalThis !== 'undefined' ? globalThis.Lightview : null);
const tag = (LV?.tags?._customTags?.[tagKey]) || tagKey;
const { children, ...attributes } = content;
return { tag, attributes, children: children ? convertObjectDOM(children) : [] };
};
// ============= COMPONENT CONFIGURATION =============
// Global configuration for Lightview components
const DAISYUI_CDN = 'https://cdn.jsdelivr.net/npm/daisyui@4.12.23/dist/full.min.css';
// Component configuration (set by initComponents)
const componentConfig = {
initialized: false,
shadowDefault: true, // Default: components use shadow DOM
daisyStyleSheet: null,
themeStyleSheet: null, // Global theme stylesheet
componentStyleSheets: new Map(),
customStyleSheets: new Map(), // Registry for named custom stylesheets
customStyleSheetPromises: new Map() // Cache for pending stylesheet fetches
};
/**
* Register a named stylesheet for use in components
* @param {string} nameOrIdOrUrl - The name/ID/URL of the stylesheet
* @param {string} [cssText] - Optional raw CSS content. If provided, nameOrIdOrUrl is treated as a name.
* @returns {Promise<void>}
*/
const registerStyleSheet = async (nameOrIdOrUrl, cssText) => {
if (componentConfig.customStyleSheets.has(nameOrIdOrUrl)) return componentConfig.customStyleSheets.get(nameOrIdOrUrl);
if (componentConfig.customStyleSheetPromises.has(nameOrIdOrUrl)) return componentConfig.customStyleSheetPromises.get(nameOrIdOrUrl);
const promise = (async () => {
try {
let finalCss = cssText;
if (finalCss === undefined) {
if (nameOrIdOrUrl.startsWith('#')) {
// ID selector - search synchronously
const el = document.querySelector(nameOrIdOrUrl);
if (el) {
finalCss = el.textContent;
} else {
throw new Error(`Style block '${nameOrIdOrUrl}' not found`);
}
} else {
// Assume URL
const response = await fetch(nameOrIdOrUrl);
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
finalCss = await response.text();
}
}
if (finalCss !== undefined) {
const sheet = new CSSStyleSheet();
sheet.replaceSync(finalCss);
componentConfig.customStyleSheets.set(nameOrIdOrUrl, sheet);
return sheet;
}
} catch (e) {
console.error(`LightviewX: Failed to register stylesheet '${nameOrIdOrUrl}':`, e);
} finally {
componentConfig.customStyleSheetPromises.delete(nameOrIdOrUrl);
}
})();
componentConfig.customStyleSheetPromises.set(nameOrIdOrUrl, promise);
return promise;
};
// Theme Signal
// Helper to safely get local storage
const getSavedTheme = () => {
try {
if (typeof localStorage !== 'undefined') {
return localStorage.getItem('lightview-theme');
}
} catch (e) {
return null;
}
};
// Theme Signal
const themeSignal = signal(
(typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme')) ||
getSavedTheme() ||
'light'
);
/**
* Set the global theme for Lightview components (updates signal only)
* @param {string} themeName - The name of the theme (e.g., 'light', 'dark', 'cyberpunk')
*/
const setTheme = (themeName) => {
if (!themeName) return;
// Determine base theme (light or dark) for the main document
// const darkThemes = ['dark', 'aqua', 'black', 'business', 'coffee', 'dim', 'dracula', 'forest', 'halloween', 'luxury', 'night', 'sunset', 'synthwave'];
// const baseTheme = darkThemes.includes(themeName) ? 'dark' : 'light';
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', themeName);
}
// Update signal
if (themeSignal && themeSignal.value !== themeName) {
themeSignal.value = themeName;
}
// Persist preference
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('lightview-theme', themeName);
}
} catch (e) {
// Ignore storage errors
}
};
/**
* Register a global theme stylesheet for all components
* @param {string} url - URL to the CSS file
* @returns {Promise<void>}
*/
const registerThemeSheet = async (url) => {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch theme CSS: ${response.status}`);
const cssText = await response.text();
const sheet = new CSSStyleSheet();
sheet.replaceSync(cssText);
componentConfig.themeStyleSheet = sheet;
} catch (e) {
console.error(`LightviewX: Failed to register theme stylesheet '${url}':`, e);
}
};
/**
* Initialize Lightview components
* Preloads DaisyUI stylesheet for shadow DOM usage
* @param {Object} options
* @param {boolean} options.shadowDefault - Whether components use shadow DOM by default (default: true)
* @returns {Promise<void>}
*/
const initComponents = async (options = {}) => {
const { shadowDefault = true } = options;
componentConfig.shadowDefault = shadowDefault;
if (shadowDefault) {
// Preload DaisyUI stylesheet for adopted stylesheets
try {
const response = await fetch(DAISYUI_CDN);
if (!response.ok) {
throw new Error(`Failed to fetch DaisyUI CSS: ${response.status}`);
}
const cssText = await response.text();
const sheet = new CSSStyleSheet();
sheet.replaceSync(cssText);
componentConfig.daisyStyleSheet = sheet;
} catch (e) {
console.error('LightviewX: Failed to preload DaisyUI stylesheet:', e);
// Continue without DaisyUI - components will still work, just without DaisyUI styles in shadow
}
}
componentConfig.initialized = true;
};
(async () => await initComponents())();
/**
* Get or create a CSSStyleSheet for a component's CSS file
* @param {string} cssUrl - URL to the component's CSS file
* @returns {Promise<CSSStyleSheet|null>}
*/
const getComponentStyleSheet = async (cssUrl) => {
// Return cached sheet if available
if (componentConfig.componentStyleSheets.has(cssUrl)) {
return componentConfig.componentStyleSheets.get(cssUrl);
}
try {
const response = await fetch(cssUrl);
if (!response.ok) {
throw new Error(`Failed to fetch component CSS: ${response.status}`);
}
const cssText = await response.text();
const sheet = new CSSStyleSheet();
sheet.replaceSync(cssText);
componentConfig.componentStyleSheets.set(cssUrl, sheet);
return sheet;
} catch (e) {
console.error(`LightviewX: Failed to create stylesheet for ${cssUrl}:`, e);
return null;
}
};
/**
* Synchronously get cached component stylesheet (returns null if not yet loaded)
* @param {string} cssUrl
* @returns {CSSStyleSheet|null}
*/
const getComponentStyleSheetSync = (cssUrl) => componentConfig.componentStyleSheets.get(cssUrl) || null;
/**
* Check if a component should use shadow DOM based on props and global default
* @param {boolean|undefined} useShadowProp - The useShadow prop passed to the component
* @returns {boolean}
*/
const shouldUseShadow = (useShadowProp) => {
// Explicit prop value takes precedence
if (useShadowProp !== undefined) {
return useShadowProp;
}
// Fall back to global default
return componentConfig.shadowDefault;
};
/**
* Get the adopted stylesheets for a component
* @param {string} componentCssUrl - URL to the component's CSS file
* @param {string[]} requestedSheets - Array of stylesheet URLs to include
* @returns {(CSSStyleSheet|string)[]} - Mixed array of StyleSheet objects and URL strings (for link fallbacks)
*/
const getAdoptedStyleSheets = (componentCssUrl, requestedSheets = []) => {
const result = [];
// Add global DaisyUI sheet
if (componentConfig.daisyStyleSheet) {
result.push(componentConfig.daisyStyleSheet);
} else {
result.push(DAISYUI_CDN);
}
// Add global Theme sheet (overrides default Daisy variables)
if (componentConfig.themeStyleSheet) {
result.push(componentConfig.themeStyleSheet);
}
// Add component-specific sheet
if (componentCssUrl) {
const componentSheet = componentConfig.componentStyleSheets.get(componentCssUrl);
if (componentSheet) {
result.push(componentSheet);
}
}
// Process requested sheets
if (Array.isArray(requestedSheets)) {
requestedSheets.forEach(url => {
const sheet = componentConfig.customStyleSheets.get(url);
if (sheet) {
// Registered and loaded -> use object
result.push(sheet);
} else {
// Not found -> trigger load, but return string URL for immediate link tag
registerStyleSheet(url); // Fire and forget
result.push(url);
}
});
}
return result;
};
/**
* Preload a component's CSS for shadow DOM usage
* Called by components during their initialization
* @param {string} cssUrl - URL to the component's CSS file
* @returns {Promise<void>}
*/
const preloadComponentCSS = async (cssUrl) => {
if (!componentConfig.componentStyleSheets.has(cssUrl)) {
await getComponentStyleSheet(cssUrl);
}
};
// Registry shared functions are imported from signal.js
// ============= STATE (Deep Reactivity) =============
// Deep reactivity logic has been moved to src/reactivity/state.js
// Template compilation: unified logic for creating reactive functions
const compileTemplate = (code) => {
try {
const isSingle = code.trim().startsWith('${') && code.trim().endsWith('}') && !code.trim().includes('${', 2);
const body = isSingle ? 'return ' + code.trim().slice(2, -1) : 'return `' + code.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`';
return new Function('state', 'signal', body);
} catch (e) {
return () => "";
}
};
const processTemplateChild = (child, LV) => {
if (typeof child === 'string' && child.includes('${')) {
const fn = compileTemplate(child);
return () => fn(LV.state, LV.signal);
}
return child;
};
const transformTextNode = (node, isRaw, LV) => {
const text = node.textContent;
if (isRaw) return text;
if (!text.trim() && !text.includes('${')) return null;
if (text.includes('${')) {
const fn = compileTemplate(text);
return () => fn(LV.state, LV.signal);
}
return text;
};
const transformElementNode = (node, element, domToElements) => {
const tagName = node.tagName.toLowerCase();
const attributes = {};
const skip = tagName === 'script' || tagName === 'style';
const LV = typeof window !== 'undefined' ? globalThis.Lightview : (typeof globalThis !== 'undefined' ? globalThis.Lightview : null);
for (let attr of node.attributes) {
const val = attr.value;
attributes[attr.name] = (!skip && val.includes('${')) ? (() => {
const fn = compileTemplate(val);
return () => fn(LV.state, LV.signal);
})() : val;
}
return element(tagName, attributes, domToElements(Array.from(node.childNodes), element, tagName));
};
/**
* Converts standard DOM nodes into Lightview reactive elements.
* This is used to transform HTML templates (with template literals) into live VDOM.
*/
const domToElements = (domNodes, element, parentTagName = null) => {
const isRaw = parentTagName === 'script' || parentTagName === 'style';
const LV = globalThis.Lightview;
return domNodes.map(node => {
if (node.nodeType === Node.TEXT_NODE) return transformTextNode(node, isRaw, LV);
if (node.nodeType === Node.ELEMENT_NODE) return transformElementNode(node, element, domToElements);
return null;
}).filter(n => n !== null);
};
// WeakMap to track inserted content per element+location for deduplication
const insertedContentMap = new WeakMap();
// Simple hash function for content comparison
const hashContent = (str) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash.toString(36);
};
// Create a marker comment to identify inserted content boundaries
const createMarker = (id, isEnd = false) => {
return document.createComment(`lv-src-${isEnd ? 'end' : 'start'}:${id}`);
};
/**
* Execute scripts in a container element
* Scripts created via DOMParser or innerHTML don't execute automatically,
* so we need to replace them with new script elements to trigger execution
* @param {HTMLElement|DocumentFragment} container - Container to search for scripts
*/
const executeScripts = (container) => {
if (!container) return;
// Find all script tags in the container
const scripts = container.querySelectorAll('script');
scripts.forEach(oldScript => {
// Create a new script element
const newScript = document.createElement('script');
// Copy all attributes from old to new
Array.from(oldScript.attributes).forEach(attr => {
newScript.setAttribute(attr.name, attr.value);
});
// Copy the script content
if (oldScript.src) {
// External script - src attribute already copied
newScript.src = oldScript.src;
} else {
// Inline script - copy text content
newScript.textContent = oldScript.textContent;
}
// Replace the old script with the new one
// This causes the browser to execute it
oldScript.parentNode.replaceChild(newScript, oldScript);
});
};
// Find and remove previously inserted content between markers
const removeInsertedContent = (parentEl, markerId) => {
const startMarker = `lv-src-start:${markerId}`;
const endMarker = `lv-src-end:${markerId}`;
let inRange = false;
const nodesToRemove = [];
const walker = document.createTreeWalker(
parentEl.parentElement || parentEl,
NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
null,
false
);
while (walker.nextNode()) {
const node = walker.currentNode;
if (node.nodeType === Node.COMMENT_NODE) {
if (node.textContent === startMarker) {
inRange = true;
nodesToRemove.push(node);
continue;
}
if (node.textContent === endMarker) {
nodesToRemove.push(node);
break;
}
}
if (inRange) {
nodesToRemove.push(node);
}
}
nodesToRemove.forEach(node => node.remove());
return nodesToRemove.length > 0;
};
const insert = (elements, parent, location, markerId, { element, setupChildren }) => {
const isSibling = location === 'beforebegin' || location === 'afterend';
const isOuter = location === 'outerhtml';
const target = (isSibling || isOuter) ? parent.parentElement : parent;
if (!target) return console.warn(`LightviewX: No parent for ${location}`);
const frag = document.createDocumentFragment();
frag.appendChild(createMarker(markerId, false));
elements.forEach(c => {
if (typeof c === 'string') frag.appendChild(document.createTextNode(c));
else if (c.domEl) frag.appendChild(c.domEl);
else if (c instanceof Node) frag.appendChild(c);
else {
const v = globalThis.Lightview?.hooks.processChild?.(c) || c;
if (v.tag) {
const n = element(v.tag, v.attributes || {}, v.children || []);
if (n?.domEl) frag.appendChild(n.domEl);
}
}
});
frag.appendChild(createMarker(markerId, true));
if (isOuter) target.replaceChild(frag, parent);
else if (location === 'beforebegin') target.insertBefore(frag, parent);
else if (location === 'afterend') target.insertBefore(frag, parent.nextSibling);
else if (location === 'afterbegin') parent.insertBefore(frag, parent.firstChild);
else if (location === 'beforeend') parent.appendChild(frag);
executeScripts(target);
};
const isPath = (s) => typeof s === 'string' && !isDangerousProtocol(s) && /^(https?:|\.|\/|[\w])|(\.(html|json|[vo]dom|cdomc?))$/i.test(s);
/**
* Resolves request information (method, body, headers) from an element's data- attributes.
* Supports data-method and data-body.
*/
const getRequestInfo = (el) => {
const domEl = el.domEl || el;
const method = (domEl.getAttribute('data-method') || 'GET').toUpperCase();
const bodyAttr = domEl.getAttribute('data-body');
let body = null;
let headers = {};
if (bodyAttr) {
if (bodyAttr.startsWith('javascript:')) {
const expr = bodyAttr.slice(11);
const LV = globalThis.Lightview;
try {
// Use the registry to resolve signals/state mentioned in expressions
body = new Function('state', 'signal', `return ${expr}`)(LV.state || {}, LV.signal || {});
} catch (e) {
console.warn(`[LightviewX] Failed to evaluate data-body expression: ${expr}`, e);
}
} else if (bodyAttr.startsWith('json:')) {
try {
body = JSON.parse(bodyAttr.slice(5));
headers['Content-Type'] = 'application/json';
} catch (e) {
console.warn(`[LightviewX] Failed to parse data-body JSON: ${bodyAttr.slice(5)}`, e);
}
} else if (bodyAttr.startsWith('text:')) {
body = bodyAttr.slice(5);
headers['Content-Type'] = 'text/plain';
} else {
// Assume CSS Selector
try {
const target = document.querySelector(bodyAttr);
if (target) {
if (target.tagName === 'FORM') {
body = new FormData(target);
} else if (['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName)) {
const name = target.getAttribute('name') || 'body';
body = { [name]: target.value };
} else {
body = target.innerText;
}
}
} catch (e) {
// Not a valid selector, use raw string
body = bodyAttr;
}
}
}
return { method, body, headers };
};
/**
* Fetches content from a URL, respecting data-method and data-body settings.
*/
const fetchContent = async (src, requestOptions = {}) => {
const { method = 'GET', body = null, headers = {} } = requestOptions;
try {
const LV = globalThis.Lightview;
if (LV?.hooks?.validateUrl && !LV.hooks.validateUrl(src)) {
console.warn(`[LightviewX] Fetch blocked by validateUrl hook: ${src}`);
return null;
}
let url = new URL(src, document.baseURI);
const fetchOptions = { method, headers: { ...headers } };
if (body) {
if (method === 'GET') {
const params = new URLSearchParams(url.search);
if (body instanceof FormData) {
for (const [key, value] of body.entries()) params.append(key, value);
} else if (typeof body === 'object' && body !== null) {
for (const [key, value] of Object.entries(body)) params.append(key, String(value));
} else {
params.append('body', String(body));
}
const queryString = params.toString();
if (queryString) {
url = new URL(`${url.origin}${url.pathname}?${queryString}${url.hash}`, url.origin);
}
} else {
if (body instanceof FormData) {
fetchOptions.body = body;
} else if (typeof body === 'object' && body !== null) {
if (headers['Content-Type'] === 'application/json' || !headers['Content-Type']) {
fetchOptions.body = JSON.stringify(body);
fetchOptions.headers['Content-Type'] = 'application/json';
} else {
fetchOptions.body = String(body);
}
} else {
fetchOptions.body = String(body);
}
}
}
const res = await fetch(url, fetchOptions);
if (!res.ok) return null;
const ext = url.pathname.split('.').pop().toLowerCase();
const isJson = (ext === 'vdom' || ext === 'odom' || ext === 'cdom');
const isHtml = (ext === 'html');
const isCdom = (ext === 'cdom' || ext === 'cdomc');
const content = isJson ? await res.json() : await res.text();
return {
content,
isJson,
isHtml,
isCdom,
ext,
raw: isJson ? JSON.stringify(content) : content
};
} catch (e) {
return null;
}
};
const parseElements = (content, isJson, isHtml, el, element, isCdom = false, ext = '') => {
if (isJson) return Array.isArray(content) ? content : [content];
if (isCdom && ext === 'cdomc') {
const CDOM = globalThis.LightviewCDOM;
const parser = CDOM?.parseCDOMC;
if (parser) {
try {
const obj = parser(content);
// Hydrate the parsed object to convert expression strings to reactive signals
const hydrated = CDOM.hydrate ? CDOM.hydrate(obj) : obj;
return Array.isArray(hydrated) ? hydrated : [hydrated];
} catch (e) {
console.warn('LightviewX: Failed to parse .cdomc:', e);
return [];
}
} else {
console.warn('LightviewX: CDOMC parser not found. Ensure lightview-cdom.js is loaded.');
return [];
}
}
if (isHtml) {
if (el.domEl.getAttribute('escape') === 'true') return [content];
const doc = new DOMParser().parseFromString(content.replace(/<head[^>]*>[\s\S]*?<\/head>/i, ''), 'text/html');
return domToElements([...Array.from(doc.head.childNodes), ...Array.from(doc.body.childNodes)], element);
}
return [content];
};
const elementsFromSelector = (selector, element) => {
try {
const sel = document.querySelectorAll(selector);
if (!sel.length) return null;
return {
elements: domToElements(Array.from(sel), element),
raw: Array.from(sel).map(n => n.outerHTML || n.textContent).join('')
};
} catch (e) {
return null;
}
};
const updateTargetContent = (el, elements, raw, loc, contentHash, options, targetHash = null) => {
const { element, setupChildren, saveScrolls, restoreScrolls } = {
element: globalThis.Lightview?.element,
...globalThis.Lightview?.internals,
...options
};
const markerId = `${loc}-${contentHash.slice(0, 8)}`;
let track = getOrSet(insertedContentMap, el.domEl, () => ({}));
if (track[loc]) removeInsertedContent(el.domEl, `${loc}-${track[loc].slice(0, 8)}`);
track[loc] = contentHash;
// Snapshot scroll positions document-wide before updating
const scrollMap = saveScrolls ? saveScrolls() : null;
const performScroll = (root) => {
if (!targetHash) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const id = targetHash.startsWith('#') ? targetHash.slice(1) : targetHash;
const target = root.getElementById ? root.getElementById(id) : root.querySelector(`#${id}`);
if (target) {
target.style.scrollMarginTop = 'calc(var(--site-nav-height, 0px) + 2rem)';
target.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' });
}
});
});
};
const runRestore = (root) => {
if (restoreScrolls && scrollMap) restoreScrolls(scrollMap, root);
};
if (loc === 'shadow') {
if (!el.domEl.shadowRoot) el.domEl.attachShadow({ mode: 'open' });
setupChildren(elements, el.domEl.shadowRoot);
executeScripts(el.domEl.shadowRoot);
performScroll(el.domEl.shadowRoot);
runRestore(el.domEl.shadowRoot);
} else if (loc === 'innerhtml') {
el.children = elements;
executeScripts(el.domEl);
performScroll(document);
runRestore(el.domEl);
} else {
insert(elements, el.domEl, loc, markerId, { element, setupChildren });
performScroll(document);
runRestore(el.domEl);
}
};
/**
* Handles the 'src' attribute on non-standard tags.
* Loads content from a URL or selector and injects it into the element.
*/
const handleSrcAttribute = async (el, src, tagName, { element, setupChildren }) => {
if (STANDARD_SRC_TAGS.includes(tagName)) return;
let elements = [], raw = '', targetHash = null;
if (isPath(src)) {
if (src.includes('#')) {
[src, targetHash] = src.split('#');
}
const options = getRequestInfo(el);
const result = await fetchContent(src, options);
if (result) {
elements = parseElements(result.content, result.isJson, result.isHtml, el, element, result.isCdom, result.ext);
raw = result.raw;
}
}
if (!elements.length) {
const result = elementsFromSelector(src, element);
if (result) {
elements = result.elements;
raw = result.raw;
}
}
if (!elements.length) return;
const loc = (el.domEl.getAttribute('location') || 'innerhtml').toLowerCase();
const contentHash = hashContent(raw);
const track = getOrSet(insertedContentMap, el.domEl, () => ({}));
if (track[loc] === contentHash) {
// If already loaded but we have a new hash, we should still scroll
if (targetHash) {
const root = loc === 'shadow' ? el.domEl.shadowRoot : document;
if (root) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const id = targetHash.startsWith('#') ? targetHash.slice(1) : targetHash;
const target = root.getElementById ? root.getElementById(id) : root.querySelector?.(`#${id}`);
if (target) {
target.style.scrollMarginTop = 'calc(var(--site-nav-height, 0px) + 2rem)';
target.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' });
}
});
});
}
}
return;
}
updateTargetContent(el, elements, raw, loc, contentHash, { element, setupChildren }, targetHash);
};
// Valid location values for content insertion
const VALID_LOCATIONS = ['beforebegin', 'afterbegin', 'beforeend', 'afterend', 'innerhtml', 'outerhtml', 'shadow'];
// Parse position suffix from target string (e.g., "#box:afterbegin" -> { selector: "#box", location: "afterbegin" })
const parseTargetWithLocation = (targetStr) => {
for (const loc of VALID_LOCATIONS) {
const suffix = ':' + loc;
if (targetStr.toLowerCase().endsWith(suffix)) {
return {
selector: targetStr.slice(0, -suffix.length),
location: loc
};
}
}
return { selector: targetStr, location: null };
};
/**
* Intercepts clicks on elements with 'href' attributes that are not standard links.
* Enables HTMX-like SPA navigation by loading the href content into a target element.
*/
const handleNonStandardHref = async (e, { domToElement, wrapDomElement }) => {
const clickedEl = e.target.closest('[href]');
if (!clickedEl) return;
const tagName = clickedEl.tagName.toLowerCase();
if (STANDARD_HREF_TAGS.includes(tagName)) return;
e.preventDefault();
const href = clickedEl.getAttribute('href');
const LV = globalThis.Lightview;
if (href && (isDangerousProtocol(href) || (LV?.hooks?.validateUrl && !LV.hooks.validateUrl(href)))) {
console.warn(`[LightviewX] Navigation or fetch blocked by security policy: ${href}`);
return;
}
const targetAttr = clickedEl.getAttribute('target');
// Get request configuration (method, body) from the clicked element
const options = getRequestInfo(clickedEl);
// Case 1: No target attribute - existing behavior (load into self)
if (!targetAttr) {
let el = domToElement.get(clickedEl);
if (!el) {
const attrs = {};
for (let attr of clickedEl.attributes) attrs[attr.name] = attr.value;
el = wrapDomElement(clickedEl, tagName, attrs);
}
// Setting src triggers handleSrcAttribute via property proxy
const newAttrs = { ...el.attributes, src: href };
el.attributes = newAttrs;
return;
}
// Case 2: Target starts with _ (browser navigation)
if (targetAttr.startsWith('_')) {
if (options.method !== 'GET') {
console.warn('[LightviewX] Cannot use non-GET method for browser navigation (_blank, _top, etc.)');
}
switch (targetAttr) {
case '_self': globalThis.location.href = href; break;
case '_parent': globalThis.parent.location.href = href; break;
case '_top': globalThis.top.location.href = href; break;
case '_blank':
default: globalThis.open(href, targetAttr); break;
}
return;
}
// Case 3: Target is a CSS selector (with optional :position suffix)
const { selector, location } = parseTargetWithLocation(targetAttr);
try {
const targetElements = document.querySelectorAll(selector);
if (targetElements.length === 0) return;
// Perform the fetch once for all targets
const result = await fetchContent(href, options);
if (!result) return;
const { setupChildren } = LV.internals;
const element = LV.element;
targetElements.forEach(targetEl => {
let el = domToElement.get(targetEl);
if (!el) {
const attrs = {};
for (let attr of targetEl.attributes) attrs[attr.name] = attr.value;
el = wrapDomElement(targetEl, targetEl.tagName.toLowerCase(), attrs);
}
const elements = parseElements(result.content, result.isJson, result.isHtml, el, element, result.isCdom, result.ext);
const loc = (location || targetEl.getAttribute('location') || 'innerhtml').toLowerCase();
const contentHash = hashContent(result.raw);
updateTargetContent(el, elements, result.raw, loc, contentHash, { element, setupChildren });
// Update the src attribute on the target to reflect current content
targetEl.setAttribute('src', href);
});
} catch (err) {
console.warn('Invalid target selector or fetch error:', selector, err);
}
};
// ============= LV-BEFORE (Event Gating) =============
const gateStates = new WeakMap();
const BYPASS_FLAG = '__lv_passed';
const RESUME_FLAG = '__lv_resume';
const SENSIBLE_EVENTS = [
'click', 'dblclick', 'mousedown', 'mouseup', 'contextmenu',
'submit', 'reset', 'change', 'input', 'invalid',
'keydown', 'keyup', 'keypress',
'touchstart', 'touchend'
];
const CAPTURE_EVENTS = ['focus', 'blur'];
const getGateState = (el, key) => {
let elState = gateStates.get(el);
if (!elState) {
elState = new Map();
gateStates.set(el, elState);
}
let state = elState.get(key);
if (!state) {
state = {};
elState.set(key, state);
}
return state;
};
/**
* Gate implementation for throttle.
* Returns true if enough time has passed since the last successful run for this specific element/event/index.
*/
const gateThrottle = function (ms) {
const event = arguments[arguments.length - 1];
if (event?.[RESUME_FLAG]) return true;
const key = `throttle-${event?.type || 'all'}-${ms}`;
const state = getGateState(this, key);
const now = Date.now();
if (now - (state.last || 0) >= ms) {
state.last = now;
return true;
}
return false;
};
/**
* Gate implementation for debounce.
* Returns true only after the specified delay has passed without further calls.
*/
const gateDebounce = function (ms) {
const event = arguments[arguments.length - 1];
const key = `debounce-${event?.type || 'all'}-${ms}`;
const state = getGateState(this, key);
if (state.timer) clearTimeout(state.timer);
if (event?.[RESUME_FLAG] && state.passed) {
state.passed = false;
return true;
}
state.timer = setTimeout(() => {
state.passed = true;
const newEvent = new event.constructor(event.type, event);
newEvent[RESUME_FLAG] = true;
this.dispatchEvent(newEvent);
}, ms);
return false;
};
/**
* Parses the lv-before attribute value into event filters and gate functions.
*/
const parseBeforeAttribute = (attrValue) => {
// Smart tokenizer that respects parentheses and quotes
const tokens = [];
let current = '', depth = 0, inQuote = null;
for (let i = 0; i < attrValue.length; i++) {
const char = attrValue[i];
if (inQuote) {
current += char;
if (char === inQuote && attrValue[i - 1] !== '\\') inQuote = null;
} else if (char === "'" || char === '"') {
inQuote = char;
current += char;
} else if (char === '(') {
depth++;
current += char;
} else if (char === ')') {
depth--;
current += char;
} else if (/\s/.test(char) && depth === 0) {
if (current) tokens.push(current);
current = '';
} else {
current += char;
}
}
if (current) tokens.push(current);
const events = [];
const exclusions = [];
const calls = [];
let i = 0;
while (i < tokens.length) {
const token = tokens[i];
if (!token || token.includes('(')) break; // Start of function calls or empty
if (token.startsWith('!')) exclusions.push(token.slice(1));
else events.push(token);
i++;
}
while (i < tokens.length) {
if (tokens[i]) calls.push(tokens[i]);
i++;
}
return { events, exclusions, calls };
};
/**
* Global interceptor for lv-before gating.
*/
const globalBeforeInterceptor = async (e) => {
if (e[BYPASS_FLAG]) return;
const target = e.target.closest?.('[lv-before]');
if (!target) return;
const { events, exclusions, calls } = parseBeforeAttribute(target.getAttribute('lv-before'));
// Check if event matches the selection
const isExcluded = exclusions.includes(e.type);
const isIncluded = events.includes('*') || events.includes(e.type);
if (isExcluded || !isIncluded) return;
// Pass 1: Stop the event
e.stopImmediatePropagation();
e.preventDefault();
// Run the pipeline
for (const callStr of calls) {
try {
// Parse call (e.g., "throttle(1000)")
const match = callStr.match(/^([\w\.]+)\((.*)\)$/);
if (!match) continue;
const funcName = match[1];
const argsStr = match[2];
// Search for function in: global scope, LightviewX
const LV = globalThis.Lightview;
const LVX = globalThis.LightviewX;
// Enhanced function lookup supporting dotted paths
let fn = funcName.split('.').reduce((obj, key) => obj?.[key], globalThis);
if (!fn && funcName === 'throttle') fn = gateThrottle;
if (!fn && funcName === 'debounce') fn = gateDebounce;
if (!fn && LVX && LVX[funcName]) fn = LVX[funcName];
if (typeof fn !== 'function') {
console.warn(`LightviewX: lv-before function '${funcName}' not found`);
continue;
}
// Eval arguments in context
const evalArgs = new Function('event', 'state', 'signal', `return [${argsStr}]`);
const args = evalArgs.call(target, e, LV?.state || {}, LV?.signal || {});
// Inject event as last argument for built-ins and detection
args.push(e);
let result = fn.apply(target, args);
if (result instanceof Promise) result = await result;
if (result === false || result === null || result === undefined) return; // Abort
} catch (err) {
console.error(`LightviewX: Error executing lv-before gate '${callStr}':`, err);
return; // Abort on error
}
}
// Pass 2: Success! Re-dispatch with bypass flag
const finalEvent = new e.constructor(e.type, e);
finalEvent[BYPASS_FLAG] = true;
target.dispatchEvent(finalEvent);
};
// ============= DOM OBSERVER FOR SRC ATTRIBUTES =============
/**
* Process src attribute on a DOM element that doesn't normally have src
* @param {HTMLElement} node - DOM element to process
* @param {Object} LV - Lightview instance
*/
const processSrcOnNode = (node, LV) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
const tagName = node.tagName.toLowerCase();
if (isStandardSrcTag(tagName)) return;
const src = node.getAttribute('src');
if (!src) return;
// Get or create reactive wrapper
let el = LV.internals.domToElement.get(node);
if (!el) {
const attrs = {};
for (let attr of node.attributes) attrs[attr.name] = attr.value;
el = LV.internals.wrapDomElement(node, tagName, attrs, []);
}
handleSrcAttribute(el, src, tagName, {
element: LV.element,
setupChildren: LV.internals.setupChildren
});
};
// Track nodes to avoid double-processing
const processedNodes = new WeakSet();
/**
* Activate reactive syntax (${...}) in existing DOM nodes
* Uses XPath for performance optimization
* @param {Node} root - Root node to start scanning from
* @param {Object} LV - Lightview instance
*/
const activateReactiveSyntax = (root, LV) => {
if (!root || !LV) return;
const bindEffect = (node, codeStr, isAttr = false, attrName = null) => {
if (processedNodes.has(node) && !isAttr) return;
if (!isAttr) processedNodes.add(node);
const fn = compileTemplate(codeStr);
LV.effect(() => {
try {
const val = fn(LV.state, LV.signal);
if (isAttr) {
if (attrName.startsWith('cdom-')) {
node[attrName] = val;
} else {
(val === null || val === undefined || val === false) ? node.removeAttribute(attrName) : node.setAttribute(attrName, val);
}
} else node.textContent = val !== undefined ? val : '';
} catch (e) { /* Effect execution failed */ }
});
};
// 1. Find Text Nodes containing '${'
const textXPath = ".//text()[contains(., '${')]";
const textResult = document.evaluate(
textXPath,
root,
null,
XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
null
);
for (let i = 0; i < textResult.snapshotLength; i++) {
const node = textResult.snapshotItem(i);
// Verify it's not inside a skip tag (XPath might pick them up if defined loosely)
if (node.parentElement && node.parentElement.closest('SCRIPT, STYLE, CODE, PRE, TEMPLATE, NOSCRIPT')) continue;
bindEffect(node, node.textContent);
}
// 2. Find Elements with Attributes containing '${'
// XPath: select any element (*) that has an attribute (@*) containing '${'
const attrXPath = ".//*[@*[contains(., '${')]]";
const attrResult = document.evaluate(
attrXPath,
root,
null,
XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
null
);
for (let i = 0; i < attrResult.snapshotLength; i++) {
const element = attrResult.snapshotItem(i);
if (['SCRIPT', 'STYLE', 'CODE', 'PRE', 'TEMPLATE', 'NOSCRIPT'].includes(element.tagName)) continue;
// Iterate attributes to find matches (XPath found the element, but not *which* attribute)
Array.from(element.attributes).forEach(attr => {
if (attr.value.includes('${')) {
bindEffect(element, attr.value, true, attr.name);
}
});
}
// 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)
if (root.nodeType === Node.ELEMENT_NODE && !['SCRIPT', 'STYLE', 'CODE', 'PRE', 'TEMPLATE', 'NOSCRIPT'].includes(root.tagName)) {
Array.from(root.attributes).forEach(attr => {
if (attr.value.includes('${')) {
bindEffect(root, attr.value, true, attr.name);
}
});
}
};
const processAddedNode = (node, nodesToProcess, nodesToActivate) => {
if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) {
nodesToActivate.push(node);
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
// Check the added node itself for src
nodesToProcess.push(node);
// Check descendants with src attribute
const selector = '[src]:not(' + STANDARD_SRC_TAGS.join('):not(') + ')';
const descendants = node.querySelectorAll(selector);
for (const desc of descendants) {
if (!desc.tagName.toLowerCase().startsWith('lv-')) {
nodesToProcess.push(desc);
}
}
};
const collectNodesFromMutations = (mutations) => {
const nodesToProcess = [];
const nodesToActivate = [];
for (const mutation of mutations) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => processAddedNode(node, nodesToProcess, nodesToActivate));
} else if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
nodesToProcess.push(mutation.target);
}
}
return { nodesToProcess, nodesToActivate };
};
/**
* Setup MutationObserver to watch for added nodes with src attributes OR reactive syntax
* @param {Object} LV - Lightview instance
*/
const setupSrcObserver = (LV) => {
const observer = new MutationObserver((mutations) => {
const { nodesToProcess, nodesToActivate } = collectNodesFromMutations(mutations);
if (nodesToProcess.length > 0 || nodesToActivate.length > 0) {
requestAnimationFrame(() => {
nodesToActivate.forEach(node => activateReactiveSyntax(node, LV));
nodesToProcess.forEach(node => processSrcOnNode(node, LV));
});
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src']
});
return observer;
};
// Auto-register with Lightview if available
if (typeof window !== 'undefined' && globalThis.Lightview) {
const LV = globalThis.Lightview;
LV.state = state;
LV.getState = getState;
// Extend Lightview with simple named signal getter/setter if needed (already in Core now)
// But for template literals we use processTemplateChild which needs access to registries
// We can just rely on LV.signal.get if it exists, or fall back
// Setup DOM observer for src attributes on added nodes
// Setup DOM observer for src attributes on added nodes
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setupSrcObserver(LV));
} else {
setupSrcObserver(LV);
}
// Also process any existing elements
const initialScan = () => {
requestAnimationFrame(() => {
activateReactiveSyntax(document.body, LV);
const selector = '[src]:not(' + STANDARD_SRC_TAGS.join('):not(') + ')';
const nodes = document.querySelectorAll(selector);
nodes.forEach(node => {
if (node.tagName.toLowerCase().startsWith('lv-')) return;
processSrcOnNode(node, LV);
});
});
};
if (document.body) {
initialScan();
} else {
document.addEventListener