lightview
Version:
A reactive UI library with features of Bau, Juris, and HTMX plus safe LLM UI generation
828 lines (721 loc) • 30.2 kB
JavaScript
import { signal, effect, computed, getRegistry, internals } from './reactivity/signal.js';
const core = {
get currentEffect() {
return (globalThis.__LIGHTVIEW_INTERNALS__ ||= {}).currentEffect;
}
};
import { getOrSet } from './reactivity/state.js';
const nodeState = new WeakMap();
const nodeStateFactory = () => ({ effects: [], onmount: null, onunmount: null });
const registry = getRegistry();
/**
* Persistent scroll memory - tracks scroll positions continuously via event listeners.
* Much more reliable than point-in-time snapshots.
*/
const scrollMemory = new Map();
const initScrollMemory = () => {
if (typeof document === 'undefined') return;
// Use event delegation on document for scroll events
document.addEventListener('scroll', (e) => {
const el = e.target;
if (el === document || el === document.documentElement) return;
const key = el.id || (el.getAttribute && el.getAttribute('data-preserve-scroll'));
if (key) {
scrollMemory.set(key, { top: el.scrollTop, left: el.scrollLeft });
}
}, true); // Capture phase to catch all scroll events
};
// Initialize when DOM is ready
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initScrollMemory);
} else {
initScrollMemory();
}
}
/**
* Returns the current scroll memory (a snapshot of all tracked positions).
*/
const saveScrolls = () => new Map(scrollMemory);
/**
* Restores scroll positions from a saved map.
*/
const restoreScrolls = (map, root = document) => {
if (!map || map.size === 0) return;
requestAnimationFrame(() => {
map.forEach((pos, key) => {
const node = document.getElementById(key) ||
document.querySelector(`[data-preserve-scroll="${key}"]`);
if (node) {
node.scrollTop = pos.top;
node.scrollLeft = pos.left;
}
});
});
};
/**
* Assocates an effect with a DOM node for automatic cleanup when the node is removed.
*/
const trackEffect = (node, effectFn) => {
const state = getOrSet(nodeState, node, nodeStateFactory);
if (!state.effects) state.effects = [];
state.effects.push(effectFn);
};
// ============= SHADOW DOM SUPPORT =============
// Marker symbol to identify shadowDOM directives
const SHADOW_DOM_MARKER = Symbol('lightview.shadowDOM');
/**
* Create a shadowDOM directive marker
* @param {Object} attributes - { mode: 'open'|'closed', styles?: string[], adoptedStyleSheets?: CSSStyleSheet[] }
* @param {Array} children - Children to render inside the shadow root
* @returns {Object} - Marker object for setupChildren to process
*/
const createShadowDOMMarker = (attributes, children) => ({
[SHADOW_DOM_MARKER]: true,
mode: attributes.mode || 'open',
styles: attributes.styles || [],
adoptedStyleSheets: attributes.adoptedStyleSheets || [],
children
});
/**
* Check if an object is a shadowDOM marker
*/
const isShadowDOMMarker = (obj) => obj && typeof obj === 'object' && obj[SHADOW_DOM_MARKER] === true;
/**
* Process a shadowDOM marker by attaching shadow root and rendering children
* @param {Object} marker - The shadowDOM marker
* @param {HTMLElement} parentNode - The DOM node to attach shadow to
*/
const processShadowDOM = (marker, parentNode) => {
// Don't attach if already has shadow root
if (parentNode.shadowRoot) {
console.warn('Lightview: Element already has a shadowRoot, skipping shadowDOM directive');
return;
}
// Attach shadow root
const shadowRoot = parentNode.attachShadow({ mode: marker.mode });
// Split adoptedStyleSheets into sheets and urls
const sheets = [];
const linkUrls = [...(marker.styles || [])];
if (marker.adoptedStyleSheets && marker.adoptedStyleSheets.length > 0) {
marker.adoptedStyleSheets.forEach(item => {
if (item instanceof CSSStyleSheet) {
sheets.push(item);
} else if (typeof item === 'string') {
linkUrls.push(item);
}
});
}
// Handle adoptedStyleSheets (modern, efficient approach)
if (sheets.length > 0) {
try {
shadowRoot.adoptedStyleSheets = sheets;
} catch (e) {
console.warn('Lightview: adoptedStyleSheets not supported');
}
}
// Inject stylesheet links
for (const styleUrl of linkUrls) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = styleUrl;
shadowRoot.appendChild(link);
}
// Setup children inside shadow root
if (marker.children && marker.children.length > 0) {
setupChildrenInTarget(marker.children, shadowRoot);
}
};
// ============= REACTIVE UI =============
let inSVG = false;
const domToElement = new WeakMap();
/**
* Wraps a native DOM element in a Lightview reactive proxy.
*/
const wrapDomElement = (domNode, tag, attributes = {}, children = []) => {
const el = {
tag,
attributes,
children,
isProxy: true,
get domEl() { return domNode; }
};
const proxy = makeReactive(el);
domToElement.set(domNode, proxy);
return proxy;
};
/**
* Recursively checks if any item in a nested array matches a predicate.
* Replaces expensive .flat(Infinity).some() calls.
*/
const someRecursive = (item, predicate) => {
if (Array.isArray(item)) return item.some(i => someRecursive(i, predicate));
return predicate(item);
};
/**
* The core virtual-DOM-to-real-DOM factory.
* Handles tag functions (components), shadow DOM directives, and SVG namespaces.
*/
const element = (tag, attributes = {}, children = []) => {
if (customTags[tag]) tag = customTags[tag];
// If tag is a function (component), call it and process the result
if (typeof tag === 'function') {
const result = tag({ ...attributes }, children);
return processComponentResult(result);
}
// Special handling for shadowDOM pseudo-element
if (tag === 'shadowDOM') {
return createShadowDOMMarker(attributes, children);
}
// Special handling for text tag - creates a single text node with space-separated children
if (tag === 'text' && !inSVG) {
const domNode = document.createTextNode('');
const el = {
tag,
attributes,
children,
get domEl() { return domNode; }
};
const update = () => {
const bits = [];
const walk = (c) => {
if (Array.isArray(c)) {
for (let i = 0; i < c.length; i++) walk(c[i]);
return;
}
const val = typeof c === 'function' ? c() : c;
if (val && typeof val === 'object' && val.domEl) bits.push(val.domEl.textContent);
else bits.push((val === null || val === undefined) ? '' : String(val));
};
walk(el.children);
domNode.textContent = bits.join(' ');
};
const proxy = new Proxy(el, {
set(target, prop, value) {
target[prop] = value;
if (prop === 'children') update();
return true;
}
});
const hasReactive = someRecursive(children, c => typeof c === 'function');
if (hasReactive) {
const runner = effect(update);
trackEffect(domNode, runner);
}
update();
return proxy;
}
const isSVG = tag.toLowerCase() === 'svg';
const wasInSVG = inSVG;
if (isSVG) inSVG = true;
const domNode = inSVG
? document.createElementNS('http://www.w3.org/2000/svg', tag)
: document.createElement(tag);
// Optimization: Skip Proxy allocation for static nodes
const hasReactiveAttr = Object.values(attributes).some(v => typeof v === 'function');
const hasReactiveChild = someRecursive(children, c => typeof c === 'function' || (c && c.isProxy));
if (hasReactiveAttr || hasReactiveChild) {
const proxy = wrapDomElement(domNode, tag, attributes, children);
proxy.attributes = attributes;
proxy.children = children;
if (isSVG) inSVG = wasInSVG;
return proxy;
}
// Static path: Direct application to the DOM node
makeReactiveAttributes(attributes, domNode);
setupChildren(children, domNode);
if (isSVG) inSVG = wasInSVG;
return {
tag,
attributes,
children,
domEl: domNode
};
};
// Process component function return value (HTML string, DOM node, vDOM, or Object DOM)
const processComponentResult = (result) => {
if (!result) return null;
if (Lightview.hooks.processChild) {
result = Lightview.hooks.processChild(result) ?? result;
}
// Already a Lightview element
if (result.domEl) return result;
const type = typeof result;
// DOM node - wrap it
if (type === 'object' && result && result.nodeType === 1) {
return wrapDomElement(result, result.tagName.toLowerCase(), {}, []);
}
// String object - wrap in span and treat as plain text (avoids parsing/evaluation)
if (type === 'object' && result instanceof String) {
const span = document.createElement('span');
span.textContent = result.toString();
return wrapDomElement(span, 'span', {}, []);
}
// HTML string - parse and wrap
if (type === 'string') {
const template = document.createElement('template');
template.innerHTML = result.trim();
const content = template.content;
// If single element, return it; otherwise wrap in a fragment-like span
if (content.childNodes.length === 1 && content.firstChild && content.firstChild.nodeType === 1) {
const el = content.firstChild;
return wrapDomElement(el, el.tagName.toLowerCase(), {}, []);
} else {
const wrapper = document.createElement('span');
wrapper.style.display = 'contents';
wrapper.appendChild(content);
return wrapDomElement(wrapper, 'span', {}, []);
}
}
// vDOM object with tag property
if (typeof result === 'object' && result.tag) {
return element(result.tag, result.attributes || {}, result.children || []);
}
return null;
};
/**
* Internal proxy to intercept 'attributes' and 'children' updates on an element.
*/
const makeReactive = (el) => {
const domNode = el.domEl;
return new Proxy(el, {
set(target, prop, value) {
if (prop === 'attributes') {
target[prop] = makeReactiveAttributes(value, domNode);
} else if (prop === 'children') {
target[prop] = setupChildren(value, domNode);
} else {
target[prop] = value;
}
return true;
}
});
};
// Properties that should be set directly on the DOM node object rather than as attributes
const NODE_PROPERTIES = new Set(['value', 'checked', 'selected', 'selectedIndex', 'className', 'innerHTML', 'innerText']);
// Set attribute with proper handling of boolean attributes and undefined/null values
const setAttributeValue = (domNode, key, value) => {
const isBool = typeof domNode[key] === 'boolean';
// Sanitize href/src attributes to prevent javascript: and other dangerous protocols
if ((key === 'href' || key === 'src') && typeof value === 'string' && /^(javascript|vbscript|data:text\/html|data:application\/javascript)/i.test(value)) {
console.warn(`[Lightview] Blocked dangerous protocol in ${key}: ${value}`);
value = 'javascript:void(0)'; // Safer fallback than # which might trigger scroll or router
}
if (NODE_PROPERTIES.has(key) || isBool || key.startsWith('cdom-')) {
domNode[key] = isBool ? (value !== null && value !== undefined && value !== false && value !== 'false') : value;
} else if (value === null || value === undefined) {
domNode.removeAttribute(key);
} else {
domNode.setAttribute(key, value);
}
};
/**
* Processes attributes, handling event listeners, reactive bindings, and special 'onmount' hooks.
*/
const makeReactiveAttributes = (attributes = {}, domNode) => {
const reactiveAttrs = {};
for (const key in attributes) {
const value = attributes[key];
const type = typeof value;
// Handle XPath markers from hydration
if (value && type === 'object' && value.__xpath__ && value.__static__) {
// Mark attribute for later XPath resolution
domNode.setAttribute(`data-xpath-${key}`, value.__xpath__);
reactiveAttrs[key] = value;
continue;
}
if (key === 'onmount' || key === 'onunmount') {
const state = getOrSet(nodeState, domNode, nodeStateFactory);
state[key] = value;
if (key === 'onmount' && domNode.isConnected) {
value(domNode);
}
} else if (key.startsWith('on')) {
// Event handler
if (type === 'function') {
// Function handler - use addEventListener
domNode.addEventListener(key.slice(2).toLowerCase(), value);
} else if (type === 'string') {
// String handler (from parsed HTML) - use setAttribute
// Browser will compile the string into a handler function
domNode.setAttribute(key, value);
}
reactiveAttrs[key] = value;
} else if (typeof value === 'object' && value !== null && Lightview.hooks.processAttribute) {
const processed = Lightview.hooks.processAttribute(domNode, key, value);
if (processed !== undefined) {
reactiveAttrs[key] = processed;
} else if (key === 'style') {
// Style object support (merged from below)
for (const styleKey in entries) {
const styleValue = entries[styleKey];
if (typeof styleValue === 'function') {
const runner = effect(() => { domNode.style[styleKey] = styleValue(); });
trackEffect(domNode, runner);
} else {
domNode.style[styleKey] = styleValue;
}
}
reactiveAttrs[key] = value;
} else {
setAttributeValue(domNode, key, value);
reactiveAttrs[key] = value;
}
} else if (type === 'function') {
// Reactive binding
const runner = effect(() => {
const result = value();
if (key === 'style' && typeof result === 'object') {
Object.assign(domNode.style, result);
} else {
setAttributeValue(domNode, key, result);
}
});
trackEffect(domNode, runner);
reactiveAttrs[key] = value;
} else {
// Static attribute - handle undefined/null/boolean properly
setAttributeValue(domNode, key, value);
reactiveAttrs[key] = value;
}
}
return reactiveAttrs;
};
/**
* Core child processing logic - shared between setupChildren and setupChildrenInTarget
* @param {Array} children - Children to process
* @param {HTMLElement|ShadowRoot} targetNode - Where to append children
* @param {boolean} clearExisting - Whether to clear existing content
* @returns {Array} - Processed child elements
*/
/**
* Core child processing logic. Recursively handles strings, arrays,
* reactive functions, vDOM objects, and Shadow DOM markers.
*/
const processChildren = (children, targetNode, clearExisting = true) => {
if (clearExisting && targetNode.innerHTML !== undefined) {
targetNode.innerHTML = ''; // Clear existing
}
const childElements = [];
// Check if we're processing children of script or style elements
// These need raw text content preserved, not reactive transformations
const isSpecialElement = targetNode.tagName &&
(targetNode.tagName.toLowerCase() === 'script' || targetNode.tagName.toLowerCase() === 'style');
const walk = (child) => {
if (Array.isArray(child)) {
for (let i = 0; i < child.length; i++) walk(child[i]);
return;
}
if (child === null || child === undefined) return;
// Allow extensions to transform children (e.g., template literals)
// BUT skip for script/style elements which need raw content
if (Lightview.hooks.processChild && !isSpecialElement) {
child = Lightview.hooks.processChild(child) ?? child;
}
const type = typeof child;
if (child && type === 'object' && child.tag) {
// 1. Child element (already wrapped or plain object) - tag can be string or function (structural)
const childEl = child.domEl ? child : element(child.tag, child.attributes || {}, child.children || []);
targetNode.appendChild(childEl.domEl);
childElements.push(childEl);
} else if (['string', 'number', 'boolean', 'symbol'].includes(type) || (child && type === 'object' && child instanceof String)) {
// 2. Static text (common leaf)
targetNode.appendChild(document.createTextNode(child));
childElements.push(child);
} else if (type === 'function') {
// 3. Reactive function
const startMarker = document.createComment('lv:s');
const endMarker = document.createComment('lv:e');
targetNode.appendChild(startMarker);
targetNode.appendChild(endMarker);
let runner;
const update = () => {
// 1. Cleanup: Remove everything between markers
while (startMarker.nextSibling && startMarker.nextSibling !== endMarker) {
startMarker.nextSibling.remove();
}
// 2. Execution: Get new value and process it
const val = child();
if (val === undefined || val === null) return;
// Stop the runner if the markers are no longer in the DOM
if (runner && !startMarker.isConnected) {
runner.stop();
return;
}
if (typeof val === 'object' && val instanceof String) {
// insert as text node
const textNode = document.createTextNode(val);
endMarker.parentNode.insertBefore(textNode, endMarker);
} else {
// 3. Render: Process children into a fragment and insert before endMarker
const fragment = document.createDocumentFragment();
const childrenToProcess = Array.isArray(val) ? val : [val];
processChildren(childrenToProcess, fragment, false);
endMarker.parentNode.insertBefore(fragment, endMarker);
}
};
runner = effect(update);
trackEffect(startMarker, runner);
childElements.push(child);
} else if (child instanceof Node) {
// 4. Raw DOM node
const node = child.domEl || child;
if (node.nodeType === 1) { // ELEMENT_NODE
const wrapped = wrapDomElement(node, node.tagName.toLowerCase());
targetNode.appendChild(node);
childElements.push(wrapped);
} else {
targetNode.appendChild(node);
childElements.push(child);
}
} else if (isShadowDOMMarker(child)) {
// 5. Shadow DOM marker
if (targetNode instanceof ShadowRoot) {
console.warn('Lightview: Cannot nest shadowDOM inside another shadowDOM');
return;
}
processShadowDOM(child, targetNode);
} else if (child && typeof child === 'object' && child.__xpath__ && child.__static__) {
// 6. XPath marker
const textNode = document.createTextNode('');
textNode.__xpathExpr = child.__xpath__;
targetNode.appendChild(textNode);
childElements.push(child);
}
};
walk(children);
return childElements;
};
/**
* Setup children in a target node (for shadow roots and other targets)
* Does not clear existing content
*/
const setupChildrenInTarget = (children, targetNode) => {
return processChildren(children, targetNode, false);
};
/**
* Setup children on a DOM node, clearing existing content
*/
const setupChildren = (children, domNode) => {
return processChildren(children, domNode, true);
};
// ============= EXPORTS =============
/**
* Enhances an existing DOM element with Lightview reactivity.
*/
const enhance = (selectorOrNode, options = {}) => {
const domNode = typeof selectorOrNode === 'string'
? document.querySelector(selectorOrNode)
: selectorOrNode;
// If it's already a Lightview element, use its domEl
const node = domNode.domEl || domNode;
if (!node || node.nodeType !== 1) return null;
const tagName = node.tagName.toLowerCase();
let el = domToElement.get(node);
if (!el) {
el = wrapDomElement(node, tagName);
}
const { innerText, innerHTML, ...attrs } = options;
if (innerText !== undefined) {
if (typeof innerText === 'function') {
effect(() => { node.innerText = innerText(); });
} else {
node.innerText = innerText;
}
}
if (innerHTML !== undefined) {
if (typeof innerHTML === 'function') {
effect(() => { node.innerHTML = innerHTML(); });
} else {
node.innerHTML = innerHTML;
}
}
if (Object.keys(attrs).length > 0) {
// Merge with existing attributes or simply set them triggers the proxy
el.attributes = attrs;
}
return el;
};
/**
* Query selector helper that adds a .content() method for easy DOM manipulation.
*/
const $ = (cssSelectorOrElement, startingDomEl = document.body) => {
const el = typeof cssSelectorOrElement === 'string' ? startingDomEl.querySelector(cssSelectorOrElement) : cssSelectorOrElement;
if (!el) return null;
Object.defineProperty(el, 'content', {
value(child, location = 'inner') {
location = location.toLowerCase();
const tags = Lightview.tags;
// Check if target element is script or style
const isSpecialElement = el.tagName &&
(el.tagName.toLowerCase() === 'script' || el.tagName.toLowerCase() === 'style');
const array = (Array.isArray(child) ? child : [child]).map(item => {
// Allow extensions to transform children (e.g., Object DOM syntax)
// BUT skip for script/style elements which need raw content
if (Lightview.hooks.processChild && !isSpecialElement) {
item = Lightview.hooks.processChild(item) ?? item;
}
if (item.tag && !item.domEl) {
return element(item.tag, item.attributes || {}, item.children || []).domEl;
} else {
return item.domEl || item;
}
});
const target = location === 'shadow' ? (el.shadowRoot || el.attachShadow({ mode: 'open' })) : el;
if (location === 'inner' || location === 'shadow') {
target.replaceChildren(...array);
} else if (location === 'outer') {
target.replaceWith(...array);
} else if (location === 'afterbegin') {
target.prepend(...array);
} else if (location === 'beforeend') {
target.append(...array);
} else {
array.forEach(item => el.insertAdjacentElement(location, item));
}
return el;
},
configurable: true,
writable: true
});
return el;
};
const customTags = {}
/**
* Proxy for accessing or registering tags/components.
* e.g., Lightview.tags.div(...) or Lightview.tags.MyComponent = ...
*/
const tags = new Proxy({}, {
get(_, tag) {
if (tag === "_customTags") return { ...customTags };
const wrapper = (...args) => {
let attributes = {};
let children = args;
const arg0 = args[0];
if (args.length > 0 && arg0 && typeof arg0 === 'object' && !arg0.tag && !arg0.domEl && !Array.isArray(arg0)) {
attributes = arg0;
children = args.slice(1);
}
return element(customTags[tag] || tag, attributes, children);
};
// Lift static methods/properties from the component onto the wrapper
// This allows patterns like Card.Figure to work when Card is retrieved from tags
if (customTags[tag]) {
Object.assign(wrapper, customTags[tag]);
}
return wrapper;
},
set(_, tag, value) {
customTags[tag] = value;
return true;
}
});
const Lightview = {
registerSchema: (name, definition) => internals.schemas.set(name, definition),
signal,
get: signal.get,
computed,
effect,
registry,
element, // do not document this
enhance,
tags,
$,
// Extension hooks
hooks: {
onNonStandardHref: null,
processChild: null,
processAttribute: null,
validateUrl: null,
validate: (value, schema) => internals.hooks.validate(value, schema)
},
// Internals exposed for extensions
internals: {
core,
domToElement,
wrapDomElement,
setupChildren,
trackEffect,
saveScrolls,
restoreScrolls,
localRegistries: internals.localRegistries,
futureSignals: internals.futureSignals,
schemas: internals.schemas,
parents: internals.parents,
hooks: internals.hooks
}
};
// Export for use
if (typeof module !== 'undefined' && module.exports) {
module.exports = Lightview;
}
if (typeof window !== 'undefined') {
globalThis.Lightview = Lightview;
// Global click handler delegates to hook if registered
globalThis.addEventListener('click', (e) => {
// Support fragment navigation piercing Shadow DOM
// Use composedPath() to find the actual clicked element, even inside shadow roots
const path = e.composedPath();
const link = path.find(el => el.tagName === 'A' && el.getAttribute?.('href')?.startsWith('#'));
if (link && !e.defaultPrevented) {
const href = link.getAttribute('href');
if (href.length > 1) {
const id = href.slice(1);
const root = link.getRootNode();
const target = (root.getElementById ? root.getElementById(id) : null) ||
(root.querySelector ? root.querySelector(`#${id}`) : null);
if (target) {
e.preventDefault();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
target.style.scrollMarginTop = 'calc(var(--site-nav-height, 0px) + 2rem)';
target.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' });
});
});
}
}
}
if (Lightview.hooks.onNonStandardHref) {
Lightview.hooks.onNonStandardHref(e);
}
});
// Automatic Cleanup & Lifecycle Hooks
if (typeof MutationObserver !== 'undefined') {
const walkNodes = (node, fn) => {
fn(node);
node.childNodes?.forEach(n => walkNodes(n, fn));
if (node.shadowRoot) walkNodes(node.shadowRoot, fn);
};
const cleanupNode = (node) => walkNodes(node, n => {
const s = nodeState.get(n);
if (s) {
s.effects?.forEach(e => e.stop());
s.onunmount?.(n);
nodeState.delete(n);
}
});
const mountNode = (node) => walkNodes(node, n => {
nodeState.get(n)?.onmount?.(n);
});
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.removedNodes.forEach(cleanupNode);
mutation.addedNodes.forEach(mountNode);
});
});
// Wait for DOM to be ready before observing
const startObserving = () => {
if (document.body) {
observer.observe(document.body, {
childList: true,
subtree: true
});
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startObserving);
} else {
startObserving();
}
}
}