template.ts
Version:
A powerful, lightweight TypeScript template engine with reactive data binding, conditional rendering, loops, and events
645 lines • 26.1 kB
JavaScript
/**
* 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