diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..089d265 --- /dev/null +++ b/biome.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "ignore": [] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded" + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 03fdc95..8c66f2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "MIT", "devDependencies": { + "@biomejs/biome": "1.9.4", "@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", @@ -49,6 +50,170 @@ "node": ">=6.9.0" } }, + "node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", diff --git a/package.json b/package.json index aa7cc25..414ce86 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "license": "MIT", "description": "", "devDependencies": { + "@biomejs/biome": "1.9.4", "@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", diff --git a/src/main.ts b/src/main.ts index e0aab45..85a1dba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,14 +9,28 @@ interface ComponentOptions { style?: string onMount?: (this: CustomElement) => void onUnmount?: () => void - onAttributeChanged?: (attrName: string, oldValue: string, newValue: string) => void + onAttributeChanged?: ( + attrName: string, + oldValue: string, + newValue: string, + ) => void states?: Record statesListeners?: { [key: string]: (value: any) => void } funcs?: { [key: string]: (...args: any[]) => void } } export default (options: ComponentOptions) => { - const { tag, template, style, onMount, onUnmount, onAttributeChanged, states, statesListeners, funcs } = options + const { + tag, + template, + style, + onMount, + onUnmount, + onAttributeChanged, + states, + statesListeners, + funcs, + } = options const componentRegistry = new Map() componentRegistry.set(tag, options) @@ -25,75 +39,92 @@ export default (options: ComponentOptions) => { private _stateToElementsMap: Record> = {} private _currentRenderingElement: HTMLElement | null = null private _statesListeners: Record = {} - private _textBindings: Array<{ node: Text, expr: string, originalContent: string }> = [] - private _attributeBindings: Array<{ element: Element, attrName: string, expr: string, template: string }> = [] - private _conditionalElements: Map = new Map() + private _textBindings: Array<{ + node: Text + expr: string + originalContent: string + }> = [] + private _attributeBindings: Array<{ + element: Element + attrName: string + expr: string + template: string + }> = [] + private _conditionalElements: Map< + Element, + { + expr: string + placeholder: Comment + isPresent: boolean + } + > = new Map() constructor() { super() // copy state from options - this._states = new Proxy({ ...(states || {}) }, { - set: (target: Record, keyPath: string, value: any) => { - const valueRoute = keyPath.split('.') - let currentTarget = target - for (let i in valueRoute) { - const key = valueRoute[i] - if (parseInt(i) === valueRoute.length - 1) { - currentTarget[key] = value - } else { - if (!currentTarget[key]) { - currentTarget[key] = {} + this._states = new Proxy( + { ...(states || {}) }, + { + set: (target: Record, keyPath: string, value: any) => { + const valueRoute = keyPath.split('.') + let currentTarget = target + for (let i in valueRoute) { + const key = valueRoute[i] + if (parseInt(i) === valueRoute.length - 1) { + currentTarget[key] = value + } else { + if (!currentTarget[key]) { + currentTarget[key] = {} + } + currentTarget = currentTarget[key] } - currentTarget = currentTarget[key] } - } - // trigger dom updates - this._triggerDomUpdates(keyPath) - if (this._statesListeners[keyPath]) - this._statesListeners[keyPath](value) + // trigger dom updates + this._triggerDomUpdates(keyPath) + if (this._statesListeners[keyPath]) + this._statesListeners[keyPath](value) - // trigger %if macros - if (this._conditionalElements.size > 0) - this._conditionalElements.forEach((info, element) => { - if (info.expr.includes(keyPath)) - this._evaluateIfCondition(element, info.expr) - }) + // trigger %if macros + if (this._conditionalElements.size > 0) + this._conditionalElements.forEach((info, element) => { + if (info.expr.includes(keyPath)) + this._evaluateIfCondition(element, info.expr) + }) - // trigger state update events - if (statesListeners && statesListeners[keyPath]) - statesListeners[keyPath](value) + // trigger state update events + if (statesListeners && statesListeners[keyPath]) + statesListeners[keyPath](value) - return true + return true + }, + get: (target: Record, keyPath: string) => { + // collect state dependencies + if (this._currentRenderingElement) { + if (!this._stateToElementsMap[keyPath]) + this._stateToElementsMap[keyPath] = new Set() + this._stateToElementsMap[keyPath].add( + this._currentRenderingElement, + ) + } + + const valueRoute = keyPath.split('.') + let currentTarget = target + for (let i in valueRoute) { + const key = valueRoute[i] + if (parseInt(i) === valueRoute.length - 1) { + return currentTarget[key] + } else { + if (!currentTarget[key]) { + currentTarget[key] = {} + } + currentTarget = currentTarget[key] + } + } + return undefined + }, }, - get: (target: Record, keyPath: string) => { - // collect state dependencies - if (this._currentRenderingElement) { - if (!this._stateToElementsMap[keyPath]) - this._stateToElementsMap[keyPath] = new Set() - this._stateToElementsMap[keyPath].add(this._currentRenderingElement) - } - - const valueRoute = keyPath.split('.') - let currentTarget = target - for (let i in valueRoute) { - const key = valueRoute[i] - if (parseInt(i) === valueRoute.length - 1) { - return currentTarget[key] - } else { - if (!currentTarget[key]) { - currentTarget[key] = {} - } - currentTarget = currentTarget[key] - } - } - return undefined - } - }) + ) // initialize dom tree and append to shadow root this._initialize() @@ -132,7 +163,7 @@ export default (options: ComponentOptions) => { if (this._stateToElementsMap[keyPath]) { const updateQueue = new Set() - this._stateToElementsMap[keyPath].forEach(element => { + this._stateToElementsMap[keyPath].forEach((element) => { updateQueue.add(element) }) @@ -141,17 +172,27 @@ export default (options: ComponentOptions) => { // Update text bindings that depend on this state if (this._textBindings) { - this._textBindings.forEach(binding => { - if (binding.expr === keyPath || binding.expr.startsWith(keyPath + '.')) { - this._updateTextNode(binding.node, binding.expr, binding.originalContent) + this._textBindings.forEach((binding) => { + if ( + binding.expr === keyPath || + binding.expr.startsWith(keyPath + '.') + ) { + this._updateTextNode( + binding.node, + binding.expr, + binding.originalContent, + ) } }) } // Update attribute bindings that depend on this state if (this._attributeBindings) { - this._attributeBindings.forEach(binding => { - if (binding.expr === keyPath || binding.expr.startsWith(keyPath + '.')) { + this._attributeBindings.forEach((binding) => { + if ( + binding.expr === keyPath || + binding.expr.startsWith(keyPath + '.') + ) { const value = this._getNestedState(binding.expr) if (value !== undefined) { binding.element.setAttribute(binding.attrName, String(value)) @@ -163,7 +204,7 @@ export default (options: ComponentOptions) => { private _scheduleUpdate(elements: Set) { requestAnimationFrame(() => { - elements.forEach(element => { + elements.forEach((element) => { this._updateElement(element) }) }) @@ -203,16 +244,21 @@ export default (options: ComponentOptions) => { const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, - null + null, ) // Store nodes and expressions that need to be updated - const textBindings: Array<{ node: Text, expr: string, originalContent: string }> = [] - const ifDirectivesToProcess: Array<{ element: Element, expr: string }> = [] + const textBindings: Array<{ + node: Text + expr: string + originalContent: string + }> = [] + const ifDirectivesToProcess: Array<{ element: Element; expr: string }> = + [] // Traverse the DOM tree let currentNode: Node | null - while (currentNode = walker.nextNode()) { + while ((currentNode = walker.nextNode())) { // Handle text nodes if (currentNode.nodeType === Node.TEXT_NODE) { const textContent = currentNode.textContent || '' @@ -226,7 +272,7 @@ export default (options: ComponentOptions) => { // Record nodes and expressions that need to be updated const matches = textContent.match(/\{\{\s*([^}]+)\s*\}\}/g) if (matches) { - matches.forEach(match => { + matches.forEach((match) => { // Extract the expression content, removing {{ }} and spaces const expr = match.replace(/\{\{\s*|\s*\}\}/g, '').trim() @@ -240,7 +286,9 @@ export default (options: ComponentOptions) => { if (!this._stateToElementsMap[expr]) this._stateToElementsMap[expr] = new Set() - this._stateToElementsMap[expr].add(textNode as unknown as HTMLElement) + this._stateToElementsMap[expr].add( + textNode as unknown as HTMLElement, + ) }) } } @@ -248,13 +296,12 @@ export default (options: ComponentOptions) => { // Handle element nodes (can extend to handle attribute bindings, etc.) else if (currentNode.nodeType === Node.ELEMENT_NODE) { - const currentElementNode = currentNode as Element // Renamed to avoid conflict with outer 'element' // Traverse all macro attributes // Detect :attr="" bindings, such as :src="imageUrl" - Array.from(currentElementNode.attributes).forEach(attr => { + Array.from(currentElementNode.attributes).forEach((attr) => { if (attr.name.startsWith(':')) { const attrName = attr.name.substring(1) // Remove ':' const expr = attr.value.trim() @@ -263,13 +310,20 @@ export default (options: ComponentOptions) => { currentElementNode.removeAttribute(attr.name) // Set up attribute binding - this._setupAttributeBinding(currentElementNode, attrName, expr, attr.value) + this._setupAttributeBinding( + currentElementNode, + attrName, + expr, + attr.value, + ) } }) // Process @event bindings, such as @click="handleClick" - const eventBindings = Array.from(currentElementNode.attributes).filter(attr => attr.name.startsWith('@')) - eventBindings.forEach(attr => { + const eventBindings = Array.from( + currentElementNode.attributes, + ).filter((attr) => attr.name.startsWith('@')) + eventBindings.forEach((attr) => { const eventName = attr.name.substring(1) // Remove '@' const handlerValue = attr.value.trim() @@ -279,22 +333,42 @@ export default (options: ComponentOptions) => { // Handle different types of event handlers if (handlerValue.includes('=>')) { // Handle arrow function: @click="e => setState('count', count + 1)" - this._setupArrowFunctionHandler(currentElementNode, eventName, handlerValue) - } else if (handlerValue.includes('(') && handlerValue.includes(')')) { + this._setupArrowFunctionHandler( + currentElementNode, + eventName, + handlerValue, + ) + } else if ( + handlerValue.includes('(') && + handlerValue.includes(')') + ) { // Handle function call: @click="increment(5)" - this._setupFunctionCallHandler(currentElementNode, eventName, handlerValue) + this._setupFunctionCallHandler( + currentElementNode, + eventName, + handlerValue, + ) } else if (typeof (this as any)[handlerValue] === 'function') { // Handle method reference: @click="handleClick" - currentElementNode.addEventListener(eventName, (this as any)[handlerValue].bind(this)) + currentElementNode.addEventListener( + eventName, + (this as any)[handlerValue].bind(this), + ) } else { // Handle simple expression: @click="count++" or @input="name = $event.target.value" - this._setupExpressionHandler(currentElementNode, eventName, handlerValue) + this._setupExpressionHandler( + currentElementNode, + eventName, + handlerValue, + ) } }) // Process %-started macros, such as %connect="stateName", %if="condition", %for="item in items" - const macroBindings = Array.from(currentElementNode.attributes).filter(attr => attr.name.startsWith('%')) - macroBindings.forEach(attr => { + const macroBindings = Array.from( + currentElementNode.attributes, + ).filter((attr) => attr.name.startsWith('%')) + macroBindings.forEach((attr) => { const macroName = attr.name.substring(1) // Remove '%' const expr = attr.value.trim() @@ -302,19 +376,16 @@ export default (options: ComponentOptions) => { currentElementNode.removeAttribute(attr.name) // Handle different types of macros - if (macroName === 'connect') // Handle state connection: %connect="stateName" + if (macroName === 'connect') + // Handle state connection: %connect="stateName" this._setupTwoWayBinding(currentElementNode, expr) else if (macroName === 'if') ifDirectivesToProcess.push({ element: currentElementNode, expr }) else if (macroName === 'for') this._setupListRendering(currentElementNode, expr) - else if (macroName === 'key') - return - else - console.warn(`Unknown macro: %${macroName}`) + else if (macroName === 'key') return + else console.warn(`Unknown macro: %${macroName}`) }) - - } } @@ -336,7 +407,9 @@ export default (options: ComponentOptions) => { if (value !== undefined) element.setAttribute('data-laterano-connect', String(value)) else - console.error(`State \`${expr}\` not found in the component state. Although Laterano will try to work with it, it may has potentially unexpected behavior.`) + console.error( + `State \`${expr}\` not found in the component state. Although Laterano will try to work with it, it may has potentially unexpected behavior.`, + ) // Add event listener for input events element.addEventListener('input', (event: Event) => { @@ -359,20 +432,19 @@ export default (options: ComponentOptions) => { // Handle condition rendering (%if macro) private _setupConditionRendering(element: Element, expr: string) { - const placeholder = document.createComment(` %if: ${expr} `) element.parentNode?.insertBefore(placeholder, element) this._conditionalElements.set(element, { expr, placeholder, - isPresent: true + isPresent: true, }) this._evaluateIfCondition(element, expr) const statePaths = this._extractStatePathsFromExpression(expr) - statePaths.forEach(path => { + statePaths.forEach((path) => { if (!this._stateToElementsMap[path]) { this._stateToElementsMap[path] = new Set() } @@ -383,7 +455,9 @@ export default (options: ComponentOptions) => { // Handle list rendering (%for macro) private _setupListRendering(element: Element, expr: string) { // Parse the expression (e.g., "item in items" or "(item, index) in items") - const match = expr.match(/(?:\(([^,]+),\s*([^)]+)\)|([^,\s]+))\s+in\s+(.+)/) + const match = expr.match( + /(?:\(([^,]+),\s*([^)]+)\)|([^,\s]+))\s+in\s+(.+)/, + ) if (!match) { console.error(`Invalid %for expression: ${expr}`) return @@ -404,9 +478,9 @@ export default (options: ComponentOptions) => { // Store current rendered items const renderedItems: Array<{ - element: Element, - key: any, - data: any, + element: Element + key: any + data: any index: number }> = [] @@ -414,7 +488,9 @@ export default (options: ComponentOptions) => { const updateList = () => { const collection = this._evaluateExpression(collectionExpr) if (!collection || !Array.isArray(collection)) { - console.warn(`Collection "${collectionExpr}" is not an array or does not exist`) + console.warn( + `Collection "${collectionExpr}" is not an array or does not exist`, + ) return } @@ -425,19 +501,22 @@ export default (options: ComponentOptions) => { } // Detach all currently rendered DOM items managed by this instance. - renderedItems.forEach(item => { + renderedItems.forEach((item) => { if (item.element.parentNode === parentNode) { - parentNode.removeChild(item.element); + parentNode.removeChild(item.element) } - }); + }) // Get key attribute if available const keyAttr = template.getAttribute('%key') - if (!keyAttr) console.warn(`%key attribute not found in the template, which is not a recommended practice.`) + if (!keyAttr) + console.warn( + `%key attribute not found in the template, which is not a recommended practice.`, + ) // Store a map of existing items by key for reuse const existingElementsByKey = new Map() - renderedItems.forEach(item => { + renderedItems.forEach((item) => { if (item.key !== undefined) { existingElementsByKey.set(item.key, item) } @@ -452,7 +531,15 @@ export default (options: ComponentOptions) => { // Create or update items in the list collection.forEach((item, index) => { // Determine the key for this item - const key = keyAttr ? this._evaluateExpressionWithItemContext(keyAttr ?? '', item, index, itemVar, indexVar ? indexVar : undefined) : index + const key = keyAttr + ? this._evaluateExpressionWithItemContext( + keyAttr ?? '', + item, + index, + itemVar, + indexVar ? indexVar : undefined, + ) + : index // Check if we can reuse an existing element const existingItem = existingElementsByKey.get(key) @@ -472,19 +559,21 @@ export default (options: ComponentOptions) => { element: itemElement, key, data: item, - index + index, }) // Create item context for this item const itemContext = { - [itemVar]: item + [itemVar]: item, } - if (indexVar) - itemContext[indexVar] = index + if (indexVar) itemContext[indexVar] = index // insert %key attribute, which dynamically bind the key if (keyAttr) { - const keyValue = this._evaluateExpressionWithItemContext(keyAttr, itemContext) + const keyValue = this._evaluateExpressionWithItemContext( + keyAttr, + itemContext, + ) itemElement.setAttribute('data-laterano-key', String(keyValue)) } @@ -503,7 +592,7 @@ export default (options: ComponentOptions) => { placeholder.parentNode?.insertBefore(fragment, placeholder.nextSibling) // Remove any remaining unused items - existingElementsByKey.forEach(item => { + existingElementsByKey.forEach((item) => { if (item.element.parentNode) { item.element.parentNode.removeChild(item.element) } @@ -519,7 +608,9 @@ export default (options: ComponentOptions) => { } // Using a unique identifier for this list rendering instance const listVirtualElement = document.createElement('div') - this._stateToElementsMap[collectionExpr].add(listVirtualElement as HTMLElement) + this._stateToElementsMap[collectionExpr].add( + listVirtualElement as HTMLElement, + ) // Add listener for state changes this._statesListeners[collectionExpr] = () => { @@ -528,9 +619,12 @@ export default (options: ComponentOptions) => { } // Recursively process the element and its children, applying the item context - private _processElementWithItemContext(element: Element, itemContext: Record) { + private _processElementWithItemContext( + element: Element, + itemContext: Record, + ) { // 1. Store the item context of the element so that subsequent updates can find it - (element as any)._itemContext = itemContext + ;(element as any)._itemContext = itemContext // 2. Process bindings in text nodes const processTextNodes = (node: Node) => { @@ -538,28 +632,37 @@ export default (options: ComponentOptions) => { const textContent = node.textContent || '' if (textContent.includes('{{')) { const textNode = node as Text - const updatedContent = textContent.replace(/\{\{\s*([^}]+)\s*\}\}/g, (match, expr) => { - const value = this._evaluateExpressionWithItemContext(expr.trim(), itemContext) - return value !== undefined ? String(value) : '' - }) + const updatedContent = textContent.replace( + /\{\{\s*([^}]+)\s*\}\}/g, + (match, expr) => { + const value = this._evaluateExpressionWithItemContext( + expr.trim(), + itemContext, + ) + return value !== undefined ? String(value) : '' + }, + ) textNode.textContent = updatedContent } } } // Process the text nodes of the element itself - Array.from(element.childNodes).forEach(node => { + Array.from(element.childNodes).forEach((node) => { if (node.nodeType === Node.TEXT_NODE) { processTextNodes(node) } }) // 3. Process attribute bindings (:attr) - Array.from(element.attributes).forEach(attr => { + Array.from(element.attributes).forEach((attr) => { if (attr.name.startsWith(':')) { const attrName = attr.name.substring(1) const expr = attr.value.trim() - const value = this._evaluateExpressionWithItemContext(expr, itemContext) + const value = this._evaluateExpressionWithItemContext( + expr, + itemContext, + ) if (value !== undefined) { element.setAttribute(attrName, String(value)) @@ -571,7 +674,7 @@ export default (options: ComponentOptions) => { }) // 4. Process event bindings (@event) - Array.from(element.attributes).forEach(attr => { + Array.from(element.attributes).forEach((attr) => { if (attr.name.startsWith('@')) { const eventName = attr.name.substring(1) const handlerValue = attr.value.trim() @@ -587,14 +690,17 @@ export default (options: ComponentOptions) => { ...this._createHandlerContext(event, element), ...itemContext, $event: event, - $el: element + $el: element, } // Execute the expression const fnStr = `with(this) { ${handlerValue} }` new Function(fnStr).call(mergedContext) } catch (err) { - console.error(`Error executing event handler with item context: ${handlerValue}`, err) + console.error( + `Error executing event handler with item context: ${handlerValue}`, + err, + ) } }) } @@ -604,7 +710,7 @@ export default (options: ComponentOptions) => { let isConditional = false let shouldDisplay = true - Array.from(element.attributes).forEach(attr => { + Array.from(element.attributes).forEach((attr) => { if (attr.name === '%if') { isConditional = true const expr = attr.value.trim() @@ -613,12 +719,14 @@ export default (options: ComponentOptions) => { element.removeAttribute(attr.name) // Calculate the condition - const result = this._evaluateExpressionWithItemContext(expr, itemContext) + const result = this._evaluateExpressionWithItemContext( + expr, + itemContext, + ) shouldDisplay = Boolean(result) // Apply the condition (in the list item context, we use display style to simplify) - if (!shouldDisplay) - (element as HTMLElement).style.display = 'none' + if (!shouldDisplay) (element as HTMLElement).style.display = 'none' } }) @@ -630,7 +738,7 @@ export default (options: ComponentOptions) => { // 6. Process nested list rendering (%for) let hasForDirective = false - Array.from(element.attributes).forEach(attr => { + Array.from(element.attributes).forEach((attr) => { if (attr.name === '%for') { hasForDirective = true const forExpr = attr.value.trim() @@ -650,16 +758,22 @@ export default (options: ComponentOptions) => { } // 7. Recursively process all child elements - Array.from(element.children).forEach(child => { + Array.from(element.children).forEach((child) => { this._processElementWithItemContext(child, itemContext) }) } // Set up nested list rendering - private _setupNestedListRendering(element: Element, expr: string, parentItemContext: Record) { + private _setupNestedListRendering( + element: Element, + expr: string, + parentItemContext: Record, + ) { // Similar to _setupListRendering, but applies to nested situations // Parse the expression (e.g., "subItem in item.subItems") - const match = expr.match(/(?:\(([^,]+),\s*([^)]+)\)|([^,\s]+))\s+in\s+(.+)/) + const match = expr.match( + /(?:\(([^,]+),\s*([^)]+)\)|([^,\s]+))\s+in\s+(.+)/, + ) if (!match) { console.error(`Invalid nested %for expression: ${expr}`) return @@ -671,10 +785,15 @@ export default (options: ComponentOptions) => { const collectionExpr = match[4].trim() // Evaluate the collection expression, using the parent item context - const collection = this._evaluateExpressionWithItemContext(collectionExpr, parentItemContext) + const collection = this._evaluateExpressionWithItemContext( + collectionExpr, + parentItemContext, + ) if (!collection || !Array.isArray(collection)) { - console.warn(`Nested collection "${collectionExpr}" is not an array or does not exist`) + console.warn( + `Nested collection "${collectionExpr}" is not an array or does not exist`, + ) return } @@ -693,7 +812,7 @@ export default (options: ComponentOptions) => { // Create a nested item context, merging the parent context const nestedItemContext = { ...parentItemContext, - [itemVar]: item + [itemVar]: item, } if (indexVar) { @@ -705,14 +824,23 @@ export default (options: ComponentOptions) => { // TODO: detect list items existed inside the view, use replace instead of remove and re-add, // to improve performance - + // Insert the item element into the DOM - placeholder.parentNode?.insertBefore(itemElement, placeholder.nextSibling) + placeholder.parentNode?.insertBefore( + itemElement, + placeholder.nextSibling, + ) }) } // Evaluate expressions using the item context - private _evaluateExpressionWithItemContext(expression: string, itemContext: Record, index?: number, itemVar?: string, indexVar?: string): any { + private _evaluateExpressionWithItemContext( + expression: string, + itemContext: Record, + index?: number, + itemVar?: string, + indexVar?: string, + ): any { try { // Check if the expression directly references the item variable if (itemVar && expression === itemVar) { @@ -751,7 +879,10 @@ export default (options: ComponentOptions) => { const func = new Function(...contextKeys, `return ${expression}`) return func(...contextValues) } catch (error) { - console.error(`Error evaluating expression with item context: ${expression}`, error) + console.error( + `Error evaluating expression with item context: ${expression}`, + error, + ) return undefined } } @@ -765,10 +896,14 @@ export default (options: ComponentOptions) => { const shouldShow = Boolean(result) if (shouldShow !== info.isPresent) { - if (shouldShow) // Insert the element back into the DOM - info.placeholder.parentNode?.insertBefore(element, info.placeholder.nextSibling) - else // Remove the element from the DOM - element.parentNode?.removeChild(element) + if (shouldShow) + // Insert the element back into the DOM + info.placeholder.parentNode?.insertBefore( + element, + info.placeholder.nextSibling, + ) + // Remove the element from the DOM + else element.parentNode?.removeChild(element) // Update the state info.isPresent = shouldShow @@ -789,7 +924,9 @@ export default (options: ComponentOptions) => { const func = new Function(...stateKeys, `return ${expression}`) const execRes = func(...stateValues) if (typeof execRes !== 'boolean') - throw new Error(`The expression "${expression}" must return a boolean value.`) + throw new Error( + `The expression "${expression}" must return a boolean value.`, + ) return execRes } catch (error) { console.error(`Error evaluating expression: ${expression}`, error) @@ -799,13 +936,18 @@ export default (options: ComponentOptions) => { private _extractStatePathsFromExpression(expression: string): string[] { const matches = expression.match(/[a-zA-Z_$][a-zA-Z0-9_$]*/g) || [] - return matches.filter(match => - !['true', 'false', 'null', 'undefined', 'this'].includes(match) + return matches.filter( + (match) => + !['true', 'false', 'null', 'undefined', 'this'].includes(match), ) } // Handle arrow function - private _setupArrowFunctionHandler(element: Element, eventName: string, handlerValue: string) { + private _setupArrowFunctionHandler( + element: Element, + eventName: string, + handlerValue: string, + ) { element.addEventListener(eventName, (event: Event) => { try { // Arrow function parsing @@ -860,7 +1002,10 @@ export default (options: ComponentOptions) => { handlerFn.apply(context, [event]) } } catch (err) { - console.error(`Error executing arrow function handler: ${handlerValue}`, err) + console.error( + `Error executing arrow function handler: ${handlerValue}`, + err, + ) } }) } @@ -881,21 +1026,30 @@ export default (options: ComponentOptions) => { $el: element, this: this, // Provide reference to the component instance setState: this.setState.bind(this), - getState: this.getState.bind(this) + getState: this.getState.bind(this), } // Add all methods of the component - Object.getOwnPropertyNames(Object.getPrototypeOf(this)).forEach(name => { - if (typeof (this as any)[name] === 'function' && name !== 'constructor') { - context[name] = (this as any)[name].bind(this) - } - }) + Object.getOwnPropertyNames(Object.getPrototypeOf(this)).forEach( + (name) => { + if ( + typeof (this as any)[name] === 'function' && + name !== 'constructor' + ) { + context[name] = (this as any)[name].bind(this) + } + }, + ) return context } // Handle function call, such as @click="increment(5)" - private _setupFunctionCallHandler(element: Element, eventName: string, handlerValue: string) { + private _setupFunctionCallHandler( + element: Element, + eventName: string, + handlerValue: string, + ) { element.addEventListener(eventName, (event: Event) => { try { // Create context object @@ -910,13 +1064,20 @@ export default (options: ComponentOptions) => { new Function(fnStr).call(context) } catch (err) { - console.error(`Error executing function call handler: ${handlerValue}`, err) + console.error( + `Error executing function call handler: ${handlerValue}`, + err, + ) } }) } // Handle simple expression, such as @click="count++" or @input="name = $event.target.value" - private _setupExpressionHandler(element: Element, eventName: string, handlerValue: string) { + private _setupExpressionHandler( + element: Element, + eventName: string, + handlerValue: string, + ) { element.addEventListener(eventName, (event: Event) => { try { // Create context object @@ -935,7 +1096,10 @@ export default (options: ComponentOptions) => { // If the expression returns a value, it can be used for two-way binding return result } catch (err) { - console.error(`Error executing expression handler: ${handlerValue}`, err) + console.error( + `Error executing expression handler: ${handlerValue}`, + err, + ) } }) } @@ -959,7 +1123,12 @@ export default (options: ComponentOptions) => { } // Set up attribute binding - private _setupAttributeBinding(element: Element, attrName: string, expr: string, template: string) { + private _setupAttributeBinding( + element: Element, + attrName: string, + expr: string, + template: string, + ) { // Initialize attribute value const value = this._getNestedState(expr) @@ -977,7 +1146,7 @@ export default (options: ComponentOptions) => { element, attrName, expr, - template + template, }) } @@ -1009,7 +1178,11 @@ export default (options: ComponentOptions) => { return ['data-attribute'] } - attributeChangedCallback(attrName: string, oldValue: string, newValue: string) { + attributeChangedCallback( + attrName: string, + oldValue: string, + newValue: string, + ) { if (onAttributeChanged) onAttributeChanged(attrName, oldValue, newValue) } @@ -1032,10 +1205,9 @@ export default (options: ComponentOptions) => { // function trigger triggerFunc(eventName: string, ...args: any[]) { - if (funcs && funcs[eventName]) - funcs[eventName].call(this, ...args) + if (funcs && funcs[eventName]) funcs[eventName].call(this, ...args) } } customElements.define(tag, CustomElementImpl) -} \ No newline at end of file +}