lightview
Version:
A reactive UI library with features of Bau, Juris, and HTMX plus safe LLM UI generation
568 lines (507 loc) • 22.1 kB
JavaScript
/**
* LIGHTVIEW-CDOM
* The Reactive Path and Expression Engine for Lightview.
*/
import { registerHelper, registerOperator, parseExpression, resolvePath, resolvePathAsContext, resolveExpression, parseCDOMC, parseJPRX as oldParseJPRX, unwrapSignal, BindingTarget } from '../jprx/parser.js';
import { registerMathHelpers } from '../jprx/helpers/math.js';
import { registerLogicHelpers } from '../jprx/helpers/logic.js';
import { registerStringHelpers } from '../jprx/helpers/string.js';
import { registerArrayHelpers } from '../jprx/helpers/array.js';
import { registerCompareHelpers } from '../jprx/helpers/compare.js';
import { registerConditionalHelpers } from '../jprx/helpers/conditional.js';
import { registerDateTimeHelpers } from '../jprx/helpers/datetime.js';
import { registerFormatHelpers } from '../jprx/helpers/format.js';
import { registerLookupHelpers } from '../jprx/helpers/lookup.js';
import { registerStatsHelpers } from '../jprx/helpers/stats.js';
import { registerStateHelpers, set } from '../jprx/helpers/state.js';
import { registerNetworkHelpers } from '../jprx/helpers/network.js';
import { registerCalcHelpers } from '../jprx/helpers/calc.js';
import { registerDOMHelpers } from '../jprx/helpers/dom.js';
import { signal, effect, getRegistry } from './reactivity/signal.js';
import { state } from './reactivity/state.js';
// Initialize Standard Helpers
registerMathHelpers(registerHelper);
registerLogicHelpers(registerHelper);
registerStringHelpers(registerHelper);
registerArrayHelpers(registerHelper);
registerCompareHelpers(registerHelper);
registerConditionalHelpers(registerHelper);
registerDateTimeHelpers(registerHelper);
registerFormatHelpers(registerHelper);
registerLookupHelpers(registerHelper);
registerStatsHelpers(registerHelper);
registerStateHelpers((name, fn) => registerHelper(name, fn, { pathAware: true }));
registerNetworkHelpers(registerHelper);
registerCalcHelpers(registerHelper);
registerDOMHelpers(registerHelper);
registerHelper('move', (selector, location = 'beforeend') => {
return {
isLazy: true,
resolve: (eventOrNode) => {
const isEvent = eventOrNode && typeof eventOrNode === 'object' && 'target' in eventOrNode;
const node = isEvent ? (eventOrNode.currentTarget || eventOrNode.target) : eventOrNode;
if (!(node instanceof Node) || !selector) return;
const target = document.querySelector(selector);
if (!target) {
console.warn(`[Lightview-CDOM] move target not found: ${selector}`);
return;
}
// Identity logic: if node has ID, check for existing sibling or descendant in target
if (node.id) {
// We escape the ID for querySelector
const escapedId = CSS.escape(node.id);
// Check if the target itself is the node (unlikely but safe)
if (target.id === node.id && target !== node) {
target.replaceWith(node);
return;
}
// Check for existing element in target
const existing = target.querySelector(`#${escapedId}`);
if (existing && existing !== node) {
existing.replaceWith(node);
return;
}
}
// Use Lightview's standard placement logic
globalThis.Lightview.$(target).content(node, location);
}
};
}, { pathAware: true });
registerHelper('mount', async (url, options = {}) => {
const { target = 'body', location = 'beforeend' } = options;
try {
const fetchOptions = { ...options };
delete fetchOptions.target;
delete fetchOptions.location;
const headers = { ...fetchOptions.headers };
let body = fetchOptions.body;
if (body !== undefined) {
if (body !== null && typeof body === 'object') {
body = JSON.stringify(body);
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
} else {
body = String(body);
if (!headers['Content-Type']) headers['Content-Type'] = 'text/plain';
}
fetchOptions.body = body;
fetchOptions.headers = headers;
}
const response = await globalThis.fetch(url, fetchOptions);
const contentType = response.headers.get('Content-Type') || '';
const text = await response.text();
let content = text;
const isCDOM = contentType.includes('application/cdom') ||
contentType.includes('application/jprx') ||
contentType.includes('application/vdom') ||
contentType.includes('application/odom') ||
url.endsWith('.cdom') || url.endsWith('.jprx') ||
url.endsWith('.vdom') || url.endsWith('.odom');
if (isCDOM || (contentType.includes('application/json') && text.trim().startsWith('{'))) {
try {
content = hydrate(parseCDOMC(text));
} catch (e) {
// Fail gracefully to text
}
}
const targetEl = document.querySelector(target);
if (targetEl) {
globalThis.Lightview.$(targetEl).content(content, location);
} else {
console.warn(`[Lightview-CDOM] $mount target not found: ${target}`);
}
} catch (err) {
console.error(`[Lightview-CDOM] $mount failed for ${url}:`, err);
}
});
// Register Standard Operators
// Mutation operators (prefix and postfix)
registerOperator('increment', '++', 'prefix', 80);
registerOperator('increment', '++', 'postfix', 80);
registerOperator('decrement', '--', 'prefix', 80);
registerOperator('decrement', '--', 'postfix', 80);
registerOperator('toggle', '!!', 'prefix', 80);
registerOperator('set', '=', 'infix', 20);
// Math infix operators (for expression syntax like $/a + $/b)
// These REQUIRE surrounding whitespace to avoid ambiguity with path separators (especially for /)
registerOperator('+', '+', 'infix', 50);
registerOperator('-', '-', 'infix', 50, { requiresWhitespace: true });
registerOperator('*', '*', 'infix', 60, { requiresWhitespace: true });
registerOperator('/', '/', 'infix', 60, { requiresWhitespace: true });
// Comparison infix operators
registerOperator('gt', '>', 'infix', 40);
registerOperator('lt', '<', 'infix', 40);
registerOperator('gte', '>=', 'infix', 40);
registerOperator('lte', '<=', 'infix', 40);
registerOperator('neq', '!=', 'infix', 40);
registerOperator('strictNeq', '!==', 'infix', 40);
registerOperator('eq', '==', 'infix', 40);
registerOperator('strictEq', '===', 'infix', 40);
const localStates = new WeakMap();
/**
* Builds a reactive context object for a node by chaining all ancestor states.
*/
const getContext = (node, event = null) => {
return new Proxy({}, {
get(_, prop) {
if (prop === '$event' || prop === 'event') return event;
if (prop === '$this' || prop === 'this' || prop === '__node__') return node;
return unwrapSignal(globalThis.Lightview.getState(prop, { scope: node }));
},
set(_, prop, value) {
const res = globalThis.Lightview.getState(prop, { scope: node });
if (res && (typeof res === 'object' || typeof res === 'function') && 'value' in res) {
res.value = value;
return true;
}
return false;
}
});
};
/**
* Hook for Lightview core to process $bind markers.
*/
globalThis.Lightview.hooks.processAttribute = (domNode, key, value) => {
if (value?.__JPRX_BIND__) {
const { path, options } = value;
const type = domNode.type || '';
const tagName = domNode.tagName.toLowerCase();
let prop = 'value';
let event = 'input';
if (type === 'checkbox' || type === 'radio') {
prop = 'checked';
event = 'change';
} else if (tagName === 'select') {
event = 'change';
}
const res = globalThis.Lightview.get(path.replace(/^=/, ''), { scope: domNode });
// State -> DOM
const runner = globalThis.Lightview.effect(() => {
const val = unwrapSignal(res);
if (domNode[prop] !== val) {
domNode[prop] = val === undefined ? '' : val;
}
});
globalThis.Lightview.internals.trackEffect(domNode, runner);
// DOM -> State
domNode.addEventListener(event, () => {
if (res && 'value' in res) res.value = domNode[prop];
});
// Use initial value if available
return unwrapSignal(res) ?? domNode[prop];
}
return undefined;
};
/**
* Legacy activation no longer needed.
*/
const activate = (root = document.body) => { };
const makeEventHandler = (expr) => (eventOrNode) => {
const isEvent = eventOrNode && typeof eventOrNode === 'object' && 'target' in eventOrNode;
const target = isEvent ? (eventOrNode.currentTarget || eventOrNode.target) : eventOrNode;
const context = getContext(target, isEvent ? eventOrNode : null);
const result = resolveExpression(expr, context);
if (result && typeof result === 'object' && result.isLazy) return result.resolve(context);
return result;
};
/**
* Hydrates a static CDOM object into a reactive CDOM graph.
* Traverses the object, converting expression strings (=...) into Signals/Computeds.
* Establishes a __parent__ link for relative path resolution.
*/
const hydrate = (node, parent = null) => {
if (!node) return node;
// 1. Handle Expressions with new wrapper syntax
if (typeof node === 'string') {
// New wrapper syntax: #(xpath) for XPath expressions
const xpathMatch = node.match(/^#\((.*)\)$/);
if (xpathMatch) {
return { __xpath__: xpathMatch[1], __static__: true };
}
// New wrapper syntax: =(expr) for JPRX expressions
const jprxMatch = node.match(/^=\((.*)\)$/);
if (jprxMatch) {
return parseExpression('=' + jprxMatch[1], parent);
}
// Legacy syntax: # at start (backward compatibility)
if (node.startsWith('#')) {
return { __xpath__: node.slice(1), __static__: true };
}
// Legacy syntax: = at start (backward compatibility)
if (node.startsWith('=')) {
return parseExpression(node, parent);
}
}
if (typeof node !== 'object') return node;
// 2. Handle Arrays
if (Array.isArray(node)) {
return node.map(item => hydrate(item, parent));
}
// 2. Handle String Objects (rare but possible)
if (node instanceof String) return node.toString();
// 3. Handle Nodes
// Parent link
if (parent && !('__parent__' in node)) {
Object.defineProperty(node, '__parent__', { value: parent, enumerable: false, writable: true });
globalThis.Lightview?.internals?.parents?.set(node, parent);
}
// oDOM Normalization - convert shorthand { div: "text" } to { tag: "div", children: ["text"] }
if (!node.tag) {
let potentialTag = null;
const reserved = ['children', 'attributes', 'tag', '__parent__'];
for (const key in node) {
if (reserved.includes(key) || key.startsWith('on')) continue;
potentialTag = key;
break;
}
if (potentialTag) {
const content = node[potentialTag];
node.tag = potentialTag;
if (Array.isArray(content)) {
node.children = content;
} else if (typeof content === 'object') {
node.attributes = node.attributes || {};
for (const k in content) {
if (k === 'children') node.children = content[k];
else node.attributes[k] = content[k];
}
} else node.children = [content];
delete node[potentialTag];
}
}
// Recursive Processing
for (const key in node) {
if (key === 'tag' || key === '__parent__') continue;
const value = node[key];
// Special case: attributes object
if (key === 'attributes' && typeof value === 'object' && value !== null) {
for (const attrKey in value) {
const attrVal = value[attrKey];
if (typeof attrVal === 'string') {
// New wrapper syntax: #(xpath)
const xpathMatch = attrVal.match(/^#\((.*)\)$/);
if (xpathMatch) {
value[attrKey] = { __xpath__: xpathMatch[1], __static__: true };
continue;
}
// New wrapper syntax: =(expr)
const jprxMatch = attrVal.match(/^=\((.*)\)$/);
if (jprxMatch) {
const expr = '=' + jprxMatch[1];
if (attrKey.startsWith('on')) {
value[attrKey] = makeEventHandler(expr);
} else {
value[attrKey] = parseExpression(expr, node);
}
continue;
}
// Legacy syntax: # at start
if (attrVal.startsWith('#')) {
value[attrKey] = { __xpath__: attrVal.slice(1), __static__: true };
} else if (attrVal.startsWith('=')) {
if (attrKey.startsWith('on')) {
value[attrKey] = makeEventHandler(attrVal);
} else {
value[attrKey] = parseExpression(attrVal, node);
}
} else if (typeof attrVal === 'object' && attrVal !== null) {
value[attrKey] = hydrate(attrVal, node);
}
} else if (typeof attrVal === 'object' && attrVal !== null) {
value[attrKey] = hydrate(attrVal, node);
}
}
continue;
}
if (typeof value === 'string') {
// New wrapper syntax: #(xpath)
const xpathMatch = value.match(/^#\((.*)\)$/);
if (xpathMatch) {
node[key] = { __xpath__: xpathMatch[1], __static__: true };
}
// New wrapper syntax: =(expr)
else if (value.match(/^=\((.*)\)$/)) {
const jprxMatch = value.match(/^=\((.*)\)$/);
const expr = '=' + jprxMatch[1];
if (key === 'onmount' || key === 'onunmount' || key.startsWith('on')) {
node[key] = makeEventHandler(expr);
} else if (key === 'children') {
node[key] = [parseExpression(expr, node)];
} else {
node[key] = parseExpression(expr, node);
}
}
// Legacy syntax: # at start
else if (value.startsWith('#')) {
node[key] = { __xpath__: value.slice(1), __static__: true };
}
// Legacy syntax: = at start
else if (value.startsWith('=')) {
if (key === 'onmount' || key === 'onunmount' || key.startsWith('on')) {
node[key] = makeEventHandler(value);
} else if (key === 'children') {
node[key] = [parseExpression(value, node)];
} else {
node[key] = parseExpression(value, node);
}
} else {
node[key] = hydrate(value, node);
}
} else {
node[key] = hydrate(value, node);
}
}
// 4. Automatic XPath Resolution
// If this is a top-level hydrated element, ensure it resolves its static XPaths on mount.
// We add it to node.attributes so Lightview's element() factory picks it up.
if (!parent && node.tag) {
node.attributes = node.attributes || {};
const originalOnMount = node.attributes.onmount;
node.attributes.onmount = (el) => {
if (typeof originalOnMount === 'function') originalOnMount(el);
resolveStaticXPath(el);
};
}
return node;
};
/**
* Validates that an XPath expression only uses backward-looking axes.
* Throws an error if forward-looking axes are detected.
* @param {string} xpath - The XPath expression to validate
*/
const validateXPath = (xpath) => {
if (!xpath) return;
// Check for forbidden forward-looking axes
const forbiddenAxes = /\b(child|descendant|following|following-sibling)::/;
if (forbiddenAxes.test(xpath)) {
throw new Error(`XPath: Forward-looking axes not allowed during DOM construction: ${xpath}`);
}
// Check for shorthand forward references like /div (implies child axis)
// We allow '/' if it's followed by '@' (attribute), '.' (parent/self shorthand),
// or is the start of the document root '/html'.
const hasShorthandChild = /\/(?![@.])(?![a-zA-Z0-9_-]+::)[a-zA-Z]/.test(xpath) && !xpath.startsWith('/html');
if (hasShorthandChild) {
throw new Error(`XPath: Shorthand child axis (/) not allowed during DOM construction: ${xpath}`);
}
};
/**
* Resolves static XPath expressions marked during hydration.
* This is called after the DOM tree is fully constructed.
* Walks the tree and resolves all __xpath__ markers.
* @param {Node} rootNode - The root DOM node to start walking from
*/
/**
* Resolves static XPath markers for attributes on a specific element.
*/
const resolveAttributeXPaths = (el) => {
const attributes = [...el.attributes];
for (const attr of attributes) {
if (attr.name.startsWith('data-xpath-')) {
const realAttr = attr.name.replace('data-xpath-', '');
try {
validateXPath(attr.value);
const doc = globalThis.document || el.ownerDocument;
const result = doc.evaluate(
attr.value,
el,
null,
XPathResult.STRING_TYPE,
null
);
el.setAttribute(realAttr, result.stringValue);
el.removeAttribute(attr.name);
} catch (e) {
globalThis.console?.error(`[Lightview-CDOM] XPath attribute error ("${realAttr}") at <${el.tagName.toLowerCase()} id="${el.id}">:`, e.message);
}
}
}
};
/**
* Resolves static XPath markers for a text node.
*/
const resolveTextNodeXPath = (node) => {
if (!node.__xpathExpr) return;
const xpath = node.__xpathExpr;
try {
validateXPath(xpath);
const doc = globalThis.document || node.ownerDocument;
const result = doc.evaluate(
xpath,
node,
null,
XPathResult.STRING_TYPE,
null
);
node.textContent = result.stringValue;
} catch (e) {
globalThis.console?.error(`[Lightview-CDOM] XPath text node error on <${node.parentNode?.tagName.toLowerCase()} id="${node.parentNode?.id}">:`, e.message);
} finally {
delete node.__xpathExpr;
}
};
/**
* Walks the tree and resolves all __xpath__ markers.
* @param {Node} rootNode - The root DOM node to start walking from
*/
const resolveStaticXPath = (rootNode) => {
const node = rootNode instanceof Node ? rootNode : (rootNode?.domEl || rootNode);
if (!node || !node.nodeType) return;
// Process the root node itself
if (node.nodeType === Node.ELEMENT_NODE) resolveAttributeXPaths(node);
resolveTextNodeXPath(node);
// Process all descendants
const doc = globalThis.document || node.ownerDocument;
const walker = doc.createTreeWalker(node, NodeFilter.SHOW_ALL);
let current = walker.nextNode();
while (current) {
if (current.nodeType === Node.ELEMENT_NODE) resolveAttributeXPaths(current);
resolveTextNodeXPath(current);
current = walker.nextNode();
}
};
// Prevent tree-shaking of parser functions by creating a side-effect
// These are used externally by lightview-x.js for .cdomc file loading
// The typeof check creates a runtime branch the bundler can't eliminate
if (typeof parseCDOMC !== 'function') throw new Error('parseCDOMC not found');
if (typeof oldParseJPRX !== 'function') throw new Error('oldParseJPRX not found');
const LightviewCDOM = {
registerHelper,
registerOperator,
parseExpression,
resolvePath,
resolvePathAsContext,
resolveExpression,
parseCDOMC,
parseJPRX: parseCDOMC, // Alias parseJPRX to the more robust parseCDOMC
oldParseJPRX,
unwrapSignal,
getContext,
handleCDOMState: () => { },
handleCDOMBind: () => { },
activate,
hydrate,
resolveStaticXPath,
version: '1.1.0'
};
// Global export for non-module usage
if (typeof window !== 'undefined') {
globalThis.LightviewCDOM = {};
Object.assign(globalThis.LightviewCDOM, LightviewCDOM);
}
export {
registerHelper,
registerOperator,
parseExpression,
resolvePath,
resolvePathAsContext,
resolveExpression,
parseCDOMC,
parseCDOMC as parseJPRX,
oldParseJPRX,
unwrapSignal,
BindingTarget,
getContext,
activate,
hydrate,
resolveStaticXPath
};
export default LightviewCDOM;