From 7f818b5324167a79d2ab07d0a89f4f72a16f7450 Mon Sep 17 00:00:00 2001 From: Astrian Zheng Date: Thu, 15 May 2025 13:45:43 +1000 Subject: [PATCH] Enhance macro handling: implement %for macro with key attribute validation and improve list rendering logic --- src/main.ts | 223 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 218 insertions(+), 5 deletions(-) diff --git a/src/main.ts b/src/main.ts index f7a6306..9b42a19 100644 --- a/src/main.ts +++ b/src/main.ts @@ -304,12 +304,16 @@ export default (options: ComponentOptions) => { // Handle different types of macros if (macroName === 'connect') // Handle state connection: %connect="stateName" this._setupTwoWayBinding(currentElementNode, expr) - else if (macroName === 'if') { - // Defer %if processing until after the initial traversal + else if (macroName === 'if') ifDirectivesToProcess.push({ element: currentElementNode, expr }) - } - else if (macroName === 'for') - this._setupAttributeBinding(currentElementNode, 'data-laterano-for', expr, attr.value) // Placeholder, actual %for needs more complex logic + else if (macroName === 'for') { + // detect %key="keyName" attribute + const keyAttr = currentElementNode.getAttribute('%key') + if (!keyAttr) + return console.error(`%for macro requires %key attribute: %for="${expr}"`) + this._setupListRendering(currentElementNode, expr, keyAttr) + } else if (macroName === 'key') // Ignore %key macro, as it is handled in %for + return else console.warn(`Unknown macro: %${macroName}`) }) @@ -380,6 +384,215 @@ export default (options: ComponentOptions) => { }) } + // Handle list rendering (%for macro) + private _setupListRendering(element: Element, expr: string, keyAttr: string) { + // Parse the expression (e.g., "item in items" or "(item, index) in items") + const match = expr.match(/(?:\(([^,]+),\s*([^)]+)\)|([^,\s]+))\s+in\s+(.+)/) + if (!match) { + console.error(`Invalid %for expression: ${expr}`) + return + } + + // Extract the item variable name, index variable name (optional), and collection expression + const itemVar = match[3] || match[1] + const indexVar = match[2] || null + const collectionExpr = match[4].trim() + + // Create a placeholder comment to mark where the list should be rendered + const placeholder = document.createComment(` %for: ${expr} `) + element.parentNode?.insertBefore(placeholder, element) + + // Remove the original template element from the DOM + const template = element.cloneNode(true) as Element + element.parentNode?.removeChild(element) + + // Store current rendered items + const renderedItems: Array<{ + element: Element, + key: any, + data: any, + index: number + }> = [] + + // Create a function to update the list when the collection changes + const updateList = () => { + const collection = this._getNestedState(collectionExpr) + if (!collection || !Array.isArray(collection)) { + console.warn(`Collection "${collectionExpr}" is not an array or does not exist`) + return + } + + // Get key attribute if available + const keyAttr = template.getAttribute('data-laterano-for') + + // Store a map of existing items by key for reuse + const existingElementsByKey = new Map() + renderedItems.forEach(item => { + if (item.key !== undefined) { + existingElementsByKey.set(item.key, item) + } + }) + + // Clear rendered items + renderedItems.length = 0 + + // Create or update items in the list + collection.forEach((item, index) => { + // Determine the key for this item + const key = keyAttr ? this._evaluateKeyExpression(keyAttr, item, index, itemVar) : index + + // Check if we can reuse an existing element + const existingItem = existingElementsByKey.get(key) + let itemElement: Element + + if (existingItem) { + // Reuse existing element + itemElement = existingItem.element + existingElementsByKey.delete(key) // Remove from map so we know it's been used + } else { + // Create a new element + itemElement = template.cloneNode(true) as Element + + // Process template macros for this new element + this._processTemplateMarcos(itemElement) + } + + // Update item data + renderedItems.push({ + element: itemElement, + key, + data: item, + index + }) + + // Create rendering context for this item + const itemContext = { [itemVar]: item } + if (indexVar) { + itemContext[indexVar] = index + } + + // Apply the item context to the element + this._applyItemContext(itemElement, itemContext) + + // Insert the element at the correct position in the DOM + placeholder.parentNode?.insertBefore(itemElement, placeholder.nextSibling) + }) + + // Remove any remaining unused items + existingElementsByKey.forEach(item => { + if (item.element.parentNode) { + item.element.parentNode.removeChild(item.element) + } + }) + } + + // Initial render + updateList() + + // Set up state dependency for collection changes + if (!this._stateToElementsMap[collectionExpr]) { + this._stateToElementsMap[collectionExpr] = new Set() + } + // Using a unique identifier for this list rendering instance + const listVirtualElement = document.createElement('div') + this._stateToElementsMap[collectionExpr].add(listVirtualElement as HTMLElement) + + // Add listener for state changes + this._statesListeners[collectionExpr] = () => { + updateList() + } + } + + // Helper method to evaluate key expressions for list items + private _evaluateKeyExpression(keyExpr: string, itemData: any, index: number, itemVar: string): any { + try { + // If keyExpr is directly the item property, return it + if (keyExpr === itemVar) { + return itemData + } + + // If keyExpr is a property path like "item.id", extract it + if (keyExpr.startsWith(itemVar + '.')) { + const propertyPath = keyExpr.substring(itemVar.length + 1) + const parts = propertyPath.split('.') + let value = itemData + + for (const part of parts) { + if (value === undefined || value === null) { + return undefined + } + value = value[part] + } + + return value + } + + // Otherwise, evaluate as an expression + const func = new Function(itemVar, 'index', `return ${keyExpr}`) + return func(itemData, index) + } catch (error) { + console.error(`Error evaluating key expression: ${keyExpr}`, error) + return index // Fallback to index as key + } + } + + // Helper method to apply item context to elements + private _applyItemContext(element: Element, itemContext: Record) { + // Store the item context on the element + (element as any)._itemContext = itemContext + + // Update text nodes with handlebars expressions + const updateTextNodes = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + const textContent = node.textContent || '' + + if (textContent.includes('{{')) { + const textNode = node as Text + const originalContent = textContent + + // Replace expressions with values from item context + let newContent = originalContent.replace(/\{\{\s*([^}]+)\s*\}\}/g, (match, expr) => { + // Check if expression references item context + const contextVarNames = Object.keys(itemContext) + const usesContext = contextVarNames.some(varName => expr.includes(varName)) + + if (usesContext) { + try { + // Create a function that evaluates the expression with the item context + const contextValues = Object.values(itemContext) + const func = new Function(...contextVarNames, `return ${expr.trim()}`) + const result = func(...contextValues) + return result !== undefined ? String(result) : '' + } catch (error) { + console.error(`Error evaluating expression in list item: ${expr}`, error) + return '' + } + } else { + // Use the regular state value if not from item context + const value = this._getNestedState(expr.trim()) + return value !== undefined ? String(value) : '' + } + }) + + textNode.textContent = newContent + } + } + + // Recursively process child nodes + const childNodes = node.childNodes + for (let i = 0; i < childNodes.length; i++) { + updateTextNodes(childNodes[i]) + } + } + + // Update text nodes + updateTextNodes(element) + + // Also handle event handlers and other bindings if needed + // This is more complex and would require extending other methods + // to be aware of the item context + } + private _evaluateIfCondition(element: Element, condition: string) { const info = this._conditionalElements.get(element) if (!info) return