UNPKG

template.ts

Version:

A powerful, lightweight TypeScript template engine with reactive data binding, conditional rendering, loops, and events

645 lines 26.1 kB
/** * TemplateBinder - A lightweight TypeScript template engine * * Features: * - Simple HTML templating with double curly braces {{ }} * - Looping through arrays with @for directive * - Dynamic attribute binding with @att:attributeName * - Event handling with @on:eventName directive * - Conditional rendering with @if directive * - Support for nested templates and components * - Efficient DOM updates - only changed parts are refreshed */ export class TemplateBinder { constructor(selectorOrElement, initialState = {}, transitionClass) { this.bindings = []; this.eventBindings = []; this.conditionalBindings = []; this.loopBindings = []; this.originalTemplate = ''; this.autoUpdate = false; if (transitionClass) { this.transitionClass = transitionClass; } // Handle both string selector and Element if (typeof selectorOrElement === 'string') { this.container = document.querySelector(selectorOrElement); if (!this.container) { throw new Error(`Container element not found: ${selectorOrElement}`); } } else { this.container = selectorOrElement; } this.state = initialState; this.originalTemplate = this.container.innerHTML; // Create a proxy to track state changes this.stateProxy = new Proxy(this.state, { set: (target, property, value) => { target[property] = value; return true; } }); } /** * Bind the template to the state */ bind() { if (!this.container) return; // Clear previous bindings this.clearBindings(); // Process the template this.processTemplate(); // Initial render this.render(); } /** * Update the DOM with current state */ update(withAnimation = true) { if (!this.container) return; // Update hierarchically from root to children this.updateHierarchically(this.container, withAnimation); } /** * Update elements hierarchically, respecting conditional rendering */ updateHierarchically(element, withAnimation = false) { // First update loops at this level this.loopBindings.forEach(binding => { if (binding.parentElement === element || (element instanceof Element && element.contains(binding.parentElement))) { this.updateSingleLoop(binding); } }); // Get all child elements const children = element instanceof Element ? [element, ...Array.from(element.querySelectorAll('*'))] : Array.from(element.querySelectorAll('*')); // Process each element in order children.forEach(el => { // 1. Check if parent is hidden by @if - skip if so if (this.isElementHiddenByParent(el)) { return; } // 2. Update conditionals for this element const conditionalBinding = this.conditionalBindings.find(b => b.element === el); if (conditionalBinding) { const shouldShow = this.evaluateCondition(conditionalBinding.condition); if (shouldShow !== conditionalBinding.isVisible) { conditionalBinding.isVisible = shouldShow; conditionalBinding.element.style.display = shouldShow ? conditionalBinding.originalDisplay : 'none'; } // If element is hidden, skip processing its content and children if (!shouldShow) { return; } } // 3. Update text bindings for this element this.bindings .filter(b => b.element === el && b.property === 'textContent') .forEach(binding => { try { const text = this.evaluateExpression(binding.expression); if (binding.element.textContent !== text) { binding.element.textContent = text; if (withAnimation) { this.applyTransition(binding.element); } } } catch (e) { // Silently skip if evaluation fails (e.g., user is undefined) console.debug('Skipping text binding evaluation:', e); } }); // 4. Update attribute bindings for this element this.bindings .filter(b => b.element === el && b.property.startsWith('attribute:')) .forEach(binding => { try { const attrName = binding.property.replace('attribute:', ''); const value = this.evaluateCode(binding.expression, this.state); if (binding.element.getAttribute(attrName) !== value) { binding.element.setAttribute(attrName, value); if (withAnimation) { this.applyTransition(binding.element); } } } catch (e) { console.debug('Skipping attribute binding evaluation:', e); } }); this.bindings .filter(b => b.element === el && b.property.startsWith('bool-attribute:')) .forEach(binding => { try { const attrName = binding.property.replace('bool-attribute:', ''); const value = this.evaluateCode(binding.expression, this.state); const hasAttr = binding.element.hasAttribute(attrName); if (value && !hasAttr) { binding.element.setAttribute(attrName, ''); if (withAnimation) { this.applyTransition(binding.element); } } else if (!value && hasAttr) { binding.element.removeAttribute(attrName); if (withAnimation) { this.applyTransition(binding.element); } } } catch (e) { console.debug('Skipping boolean attribute binding evaluation:', e); } }); }); } /** * Check if an element is hidden by any parent with @if */ isElementHiddenByParent(element) { let parent = element.parentElement; while (parent) { const conditionalBinding = this.conditionalBindings.find(b => b.element === parent); if (conditionalBinding && !conditionalBinding.isVisible) { return true; } parent = parent.parentElement; } return false; } /** * Update a single loop binding */ updateSingleLoop(binding) { const items = this.state[binding.itemsKey]; if (!Array.isArray(items)) { return; } // Clear existing rendered elements binding.renderedElements.forEach(el => el.remove()); binding.renderedElements = []; // Render new elements items.forEach((item, index) => { const element = this.createLoopElement(binding.templateElement, item, index, items); if (element) { binding.parentElement.insertBefore(element, binding.placeholder.nextSibling); binding.renderedElements.push(element); } }); } /** * Get the state proxy for reactive updates */ getState() { return this.stateProxy; } /** * Set a state value */ setState(key, value) { this.state[key] = value; } /** * Process the template and extract bindings */ processTemplate() { if (!this.container) return; // Process loops first (they generate elements) this.processLoops(this.container); // Process conditionals this.processConditionals(this.container); // Process text bindings this.processTextBindings(this.container); // Process attribute bindings this.processAttributeBindings(this.container); // Process event bindings this.processEventBindings(this.container); } /** * Process text bindings with {{ expression }} */ processTextBindings(element) { const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null); const textNodes = []; let node; while ((node = walker.nextNode())) { if (node.textContent && node.textContent.includes('{{')) { textNodes.push(node); } } textNodes.forEach(node => { const text = node.textContent || ''; const matches = text.match(/\{\{([^}]+)\}\}/g); if (matches && node.parentElement) { matches.forEach(() => { this.bindings.push({ element: node.parentElement, property: 'textContent', expression: text }); }); } }); } /** * Process attribute bindings @att:attributeName="expression" */ processAttributeBindings(element) { // Get all elements in the container const elements = element.querySelectorAll('*'); // Also check the root element itself (only if it's an Element) const allElements = element instanceof Element ? [element, ...Array.from(elements)] : Array.from(elements); allElements.forEach(el => { Array.from(el.attributes).forEach(attr => { const isBoolean = attr.name.startsWith('@batt:'); if (isBoolean || attr.name.startsWith('@att:')) { const attrName = attr.name.replace(isBoolean ? '@batt:' : '@att:', ''); const expression = attr.value; this.bindings.push({ element: el, property: isBoolean ? `bool-attribute:${attrName}` : `attribute:${attrName}`, expression: expression }); // Remove the directive attribute el.removeAttribute(attr.name); } }); }); } /** * Process event bindings @on:eventName="handler" */ processEventBindings(element) { // Get all elements in the container const elements = element.querySelectorAll('*'); // Also check the root element itself (only if it's an Element) const allElements = element instanceof Element ? [element, ...Array.from(elements)] : Array.from(elements); allElements.forEach(el => { Array.from(el.attributes).forEach(attr => { if (attr.name.startsWith('@on:')) { const eventName = attr.name.replace('@on:', ''); const handlerName = attr.value; this.eventBindings.push({ element: el, event: eventName, handler: handlerName }); // Attach the event listener const handler = this.state[handlerName]; if (typeof handler === 'function') { el.addEventListener(eventName, (ev) => { const result = handler.call(this.state, ev); // Auto-update if enabled if (this.autoUpdate) { // If handler returns a Promise, wait for it if (result && typeof result.then === 'function') { result.then(() => this.update()); } else { this.update(); } } return result; }); } // Remove the directive attribute el.removeAttribute(attr.name); } }); }); } /** * Process conditional rendering @if="condition" */ processConditionals(element) { const elements = Array.from(element.querySelectorAll('[\\@if]')); elements.forEach(el => { const condition = el.getAttribute('@if'); if (condition) { const computedStyle = window.getComputedStyle(el); const originalDisplay = computedStyle.display !== 'none' ? computedStyle.display : ''; this.conditionalBindings.push({ element: el, condition: condition, originalDisplay: originalDisplay || 'block', isVisible: true }); el.removeAttribute('@if'); } }); } /** * Process loops @for="itemsKey" */ processLoops(element) { const allElements = Array.from(element.querySelectorAll('[\\@for]')); // Filter to only top-level loops (not nested inside another @for) const topLevelElements = allElements.filter(el => { let parent = el.parentElement; while (parent && parent !== element) { if (parent.hasAttribute('@for')) { return false; // This is nested, skip it } parent = parent.parentElement; } return true; // This is top-level }); topLevelElements.forEach(el => { const itemsKey = el.getAttribute('@for'); if (itemsKey) { // Clone the element before removing it - store element, not string const templateElement = el.cloneNode(true); const parent = el.parentElement; if (parent) { // Create a placeholder comment const placeholder = document.createComment(`loop:${itemsKey}`); parent.insertBefore(placeholder, el); this.loopBindings.push({ element: el, itemsKey: itemsKey, templateElement: templateElement, // Store the cloned element parentElement: parent, placeholder: placeholder, renderedElements: [] }); // Remove the original element el.remove(); } } }); } /** * Render the template */ render() { // Use the new hierarchical update method for initial render this.updateHierarchically(this.container); } /** * Create an element for a loop iteration */ createLoopElement(templateElement, item, index, items, parentItem) { // Clone the template element - no parsing needed! const element = templateElement.cloneNode(true); // Remove @for attribute element.removeAttribute('@for'); // Create a context for this iteration const context = { item: item, index: index, items: items, parent: parentItem, ...this.state }; // FIRST: Process nested loops BEFORE evaluating text/attributes // This prevents inner template content from being evaluated with wrong context const allElements = [element, ...Array.from(element.querySelectorAll('*'))]; allElements.forEach(el => { if (el.hasAttribute('@for')) { const nestedItemsKey = el.getAttribute('@for'); if (nestedItemsKey) { const nestedItems = this.evaluateCode(nestedItemsKey, context); if (Array.isArray(nestedItems)) { const parentEl = el.parentElement; const nestedTemplateElement = el.cloneNode(true); nestedItems.forEach((nestedItem, nestedIndex) => { const nestedElement = this.createLoopElement(nestedTemplateElement, nestedItem, nestedIndex, nestedItems, context.item); if (nestedElement) { parentEl?.appendChild(nestedElement); } }); el.remove(); } } } }); // SECOND: Process text content (after nested loops are handled) const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null); const textNodes = []; let node; while ((node = walker.nextNode())) { if (node.textContent && node.textContent.includes('{{')) { textNodes.push(node); } } textNodes.forEach(node => { const text = node.textContent || ''; const evaluated = this.evaluateExpressionWithContext(text, context); node.textContent = evaluated; }); // THIRD: Process attributes (after nested loops and text) const remainingElements = [element, ...Array.from(element.querySelectorAll('*'))]; remainingElements.forEach(el => { Array.from(el.attributes).forEach(attr => { if (attr.name.startsWith('@att:')) { const attrName = attr.name.replace('@att:', ''); const value = this.evaluateCode(attr.value, context); el.setAttribute(attrName, value); el.removeAttribute(attr.name); } else if (attr.name.startsWith('@batt:')) { const attrName = attr.name.replace('@batt:', ''); const value = this.evaluateCode(attr.value, context); if (value) { el.setAttribute(attrName, ''); } else { el.removeAttribute(attrName); } el.removeAttribute(attr.name); } else if (attr.name.startsWith('@on:')) { const eventName = attr.name.replace('@on:', ''); const handlerName = attr.value; const handler = this.state[handlerName]; if (typeof handler === 'function') { el.addEventListener(eventName, (ev) => { const result = handler.call(this.state, ev, item, index); // Auto-update if enabled if (this.autoUpdate) { // If handler returns a Promise, wait for it if (result && typeof result.then === 'function') { result.then(() => this.update()); } else { this.update(); } } return result; }); } el.removeAttribute(attr.name); } else if (attr.name === '@if') { const condition = attr.value; const shouldShow = this.evaluateConditionWithContext(condition, context); el.style.display = shouldShow ? '' : 'none'; el.removeAttribute('@if'); } }); }); // Process conditionals within loop const conditionalElements = Array.from(element.querySelectorAll('[\\@if]')); conditionalElements.forEach(el => { const condition = el.getAttribute('@if'); if (condition) { const shouldShow = this.evaluateConditionWithContext(condition, context); el.style.display = shouldShow ? '' : 'none'; el.removeAttribute('@if'); } }); return element; } /** * Evaluate an expression with the current state */ evaluateExpression(expression) { let result = expression; // Replace {{ expression }} with evaluated value result = result.replace(/\{\{([^}]+)\}\}/g, (match, expr) => { const trimmedExpr = expr.trim(); return this.evaluateCode(trimmedExpr, this.state); }); return result; } /** * Evaluate an expression with a specific context */ evaluateExpressionWithContext(expression, context) { let result = expression; result = result.replace(/\{\{([^}]+)\}\}/g, (match, expr) => { const trimmedExpr = expr.trim(); return this.evaluateCode(trimmedExpr, context); }); return result; } /** * Evaluate a condition */ evaluateCondition(condition) { try { return !!this.evaluateCode(condition, this.state); } catch (e) { console.error('Error evaluating condition:', condition, e); return false; } } /** * Evaluate a condition with a specific context */ evaluateConditionWithContext(condition, context) { try { return !!this.evaluateCode(condition, context); } catch (e) { console.error('Error evaluating condition:', condition, e); return false; } } /** * Evaluate JavaScript code with a given context */ evaluateCode(code, context) { try { // Check if code is a simple function call (e.g., "functionName()" or "functionName(arg1, arg2)") const functionCallMatch = code.match(/^(\w+)\s*\((.*)\)$/); if (functionCallMatch) { const functionName = functionCallMatch[1]; const argsString = functionCallMatch[2].trim(); // Check if the function exists in the context if (typeof context[functionName] === 'function') { // Evaluate arguments if any let args = []; if (argsString) { // Create a function to evaluate the arguments const keys = Object.keys(context); const values = keys.map(key => context[key]); // eslint-disable-next-line no-new-func const argFunc = new Function(...keys, `return [${argsString}]`); args = argFunc(...values); } // Call the function with proper this context return context[functionName].apply(context, args); } } // Check if code is a simple property access (e.g., "functionName" without parentheses) if (/^\w+$/.test(code) && typeof context[code] === 'function') { // Return the function result by calling it return context[code].call(context); } // For complex expressions, use Function constructor const keys = Object.keys(context); const values = keys.map(key => context[key]); // eslint-disable-next-line no-new-func const func = new Function(...keys, `return ${code}`); return func(...values); } catch (e) { console.error('Error evaluating code:', code, e); return ''; } } /** * Clear all bindings */ clearBindings() { // Remove event listeners this.eventBindings.forEach(binding => { const handler = this.state[binding.handler]; if (typeof handler === 'function') { binding.element.removeEventListener(binding.event, handler); } }); // Clear loop rendered elements this.loopBindings.forEach(binding => { binding.renderedElements.forEach(el => el.remove()); }); this.bindings = []; this.eventBindings = []; this.conditionalBindings = []; this.loopBindings = []; } /** * Apply transition effect to an element when its value updates */ applyTransition(element) { if (!this.transitionClass) return; element.classList.add(this.transitionClass); // Remove the class after animation completes const removeTransition = () => { element.classList.remove(this.transitionClass); element.removeEventListener('animationend', removeTransition); element.removeEventListener('transitionend', removeTransition); }; // Listen for both animation and transition end events element.addEventListener('animationend', removeTransition); element.addEventListener('transitionend', removeTransition); // Fallback: remove after 600ms if no events fire setTimeout(removeTransition, 600); } /** * Destroy the binder and clean up */ destroy() { this.clearBindings(); if (this.container) { this.container.innerHTML = this.originalTemplate; } } } export default TemplateBinder; //# sourceMappingURL=template.js.map