Add Biome for lint and code quality check #2

Merged
Astrian merged 15 commits from dev into main 2025-05-16 10:18:55 +00:00
4 changed files with 535 additions and 166 deletions
Showing only changes of commit 4def8050a1 - Show all commits

31
biome.json Normal file
View File

@ -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"
}
}
}

165
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.1", "version": "0.0.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4",
"@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.1.2", "@rollup/plugin-typescript": "^12.1.2",
@ -49,6 +50,170 @@
"node": ">=6.9.0" "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": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",

View File

@ -17,6 +17,7 @@
"license": "MIT", "license": "MIT",
"description": "", "description": "",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4",
"@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.1.2", "@rollup/plugin-typescript": "^12.1.2",

View File

@ -9,14 +9,28 @@ interface ComponentOptions {
style?: string style?: string
onMount?: (this: CustomElement) => void onMount?: (this: CustomElement) => void
onUnmount?: () => void onUnmount?: () => void
onAttributeChanged?: (attrName: string, oldValue: string, newValue: string) => void onAttributeChanged?: (
attrName: string,
oldValue: string,
newValue: string,
) => void
states?: Record<string, any> states?: Record<string, any>
statesListeners?: { [key: string]: (value: any) => void } statesListeners?: { [key: string]: (value: any) => void }
funcs?: { [key: string]: (...args: any[]) => void } funcs?: { [key: string]: (...args: any[]) => void }
} }
export default (options: ComponentOptions) => { 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() const componentRegistry = new Map()
componentRegistry.set(tag, options) componentRegistry.set(tag, options)
@ -25,75 +39,92 @@ export default (options: ComponentOptions) => {
private _stateToElementsMap: Record<string, Set<HTMLElement>> = {} private _stateToElementsMap: Record<string, Set<HTMLElement>> = {}
private _currentRenderingElement: HTMLElement | null = null private _currentRenderingElement: HTMLElement | null = null
private _statesListeners: Record<string, Function> = {} private _statesListeners: Record<string, Function> = {}
private _textBindings: Array<{ node: Text, expr: string, originalContent: string }> = [] private _textBindings: Array<{
private _attributeBindings: Array<{ element: Element, attrName: string, expr: string, template: string }> = [] node: Text
private _conditionalElements: Map<Element, { expr: string
expr: string, originalContent: string
placeholder: Comment, }> = []
isPresent: boolean private _attributeBindings: Array<{
}> = new Map() element: Element
attrName: string
expr: string
template: string
}> = []
private _conditionalElements: Map<
Element,
{
expr: string
placeholder: Comment
isPresent: boolean
}
> = new Map()
constructor() { constructor() {
super() super()
// copy state from options // copy state from options
this._states = new Proxy({ ...(states || {}) }, { this._states = new Proxy(
set: (target: Record<string, any>, keyPath: string, value: any) => { { ...(states || {}) },
const valueRoute = keyPath.split('.') {
let currentTarget = target set: (target: Record<string, any>, keyPath: string, value: any) => {
for (let i in valueRoute) { const valueRoute = keyPath.split('.')
const key = valueRoute[i] let currentTarget = target
if (parseInt(i) === valueRoute.length - 1) { for (let i in valueRoute) {
currentTarget[key] = value const key = valueRoute[i]
} else { if (parseInt(i) === valueRoute.length - 1) {
if (!currentTarget[key]) { currentTarget[key] = value
currentTarget[key] = {} } else {
if (!currentTarget[key]) {
currentTarget[key] = {}
}
currentTarget = currentTarget[key]
} }
currentTarget = currentTarget[key]
} }
} // trigger dom updates
// trigger dom updates this._triggerDomUpdates(keyPath)
this._triggerDomUpdates(keyPath) if (this._statesListeners[keyPath])
if (this._statesListeners[keyPath]) this._statesListeners[keyPath](value)
this._statesListeners[keyPath](value)
// trigger %if macros // trigger %if macros
if (this._conditionalElements.size > 0) if (this._conditionalElements.size > 0)
this._conditionalElements.forEach((info, element) => { this._conditionalElements.forEach((info, element) => {
if (info.expr.includes(keyPath)) if (info.expr.includes(keyPath))
this._evaluateIfCondition(element, info.expr) this._evaluateIfCondition(element, info.expr)
}) })
// trigger state update events // trigger state update events
if (statesListeners && statesListeners[keyPath]) if (statesListeners && statesListeners[keyPath])
statesListeners[keyPath](value) statesListeners[keyPath](value)
return true return true
},
get: (target: Record<string, any>, 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<string, any>, 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 // initialize dom tree and append to shadow root
this._initialize() this._initialize()
@ -132,7 +163,7 @@ export default (options: ComponentOptions) => {
if (this._stateToElementsMap[keyPath]) { if (this._stateToElementsMap[keyPath]) {
const updateQueue = new Set<HTMLElement>() const updateQueue = new Set<HTMLElement>()
this._stateToElementsMap[keyPath].forEach(element => { this._stateToElementsMap[keyPath].forEach((element) => {
updateQueue.add(element) updateQueue.add(element)
}) })
@ -141,17 +172,27 @@ export default (options: ComponentOptions) => {
// Update text bindings that depend on this state // Update text bindings that depend on this state
if (this._textBindings) { if (this._textBindings) {
this._textBindings.forEach(binding => { this._textBindings.forEach((binding) => {
if (binding.expr === keyPath || binding.expr.startsWith(keyPath + '.')) { if (
this._updateTextNode(binding.node, binding.expr, binding.originalContent) binding.expr === keyPath ||
binding.expr.startsWith(keyPath + '.')
) {
this._updateTextNode(
binding.node,
binding.expr,
binding.originalContent,
)
} }
}) })
} }
// Update attribute bindings that depend on this state // Update attribute bindings that depend on this state
if (this._attributeBindings) { if (this._attributeBindings) {
this._attributeBindings.forEach(binding => { this._attributeBindings.forEach((binding) => {
if (binding.expr === keyPath || binding.expr.startsWith(keyPath + '.')) { if (
binding.expr === keyPath ||
binding.expr.startsWith(keyPath + '.')
) {
const value = this._getNestedState(binding.expr) const value = this._getNestedState(binding.expr)
if (value !== undefined) { if (value !== undefined) {
binding.element.setAttribute(binding.attrName, String(value)) binding.element.setAttribute(binding.attrName, String(value))
@ -163,7 +204,7 @@ export default (options: ComponentOptions) => {
private _scheduleUpdate(elements: Set<HTMLElement>) { private _scheduleUpdate(elements: Set<HTMLElement>) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
elements.forEach(element => { elements.forEach((element) => {
this._updateElement(element) this._updateElement(element)
}) })
}) })
@ -203,16 +244,21 @@ export default (options: ComponentOptions) => {
const walker = document.createTreeWalker( const walker = document.createTreeWalker(
element, element,
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
null null,
) )
// Store nodes and expressions that need to be updated // Store nodes and expressions that need to be updated
const textBindings: Array<{ node: Text, expr: string, originalContent: string }> = [] const textBindings: Array<{
const ifDirectivesToProcess: Array<{ element: Element, expr: string }> = [] node: Text
expr: string
originalContent: string
}> = []
const ifDirectivesToProcess: Array<{ element: Element; expr: string }> =
[]
// Traverse the DOM tree // Traverse the DOM tree
let currentNode: Node | null let currentNode: Node | null
while (currentNode = walker.nextNode()) { while ((currentNode = walker.nextNode())) {
// Handle text nodes // Handle text nodes
if (currentNode.nodeType === Node.TEXT_NODE) { if (currentNode.nodeType === Node.TEXT_NODE) {
const textContent = currentNode.textContent || '' const textContent = currentNode.textContent || ''
@ -226,7 +272,7 @@ export default (options: ComponentOptions) => {
// Record nodes and expressions that need to be updated // Record nodes and expressions that need to be updated
const matches = textContent.match(/\{\{\s*([^}]+)\s*\}\}/g) const matches = textContent.match(/\{\{\s*([^}]+)\s*\}\}/g)
if (matches) { if (matches) {
matches.forEach(match => { matches.forEach((match) => {
// Extract the expression content, removing {{ }} and spaces // Extract the expression content, removing {{ }} and spaces
const expr = match.replace(/\{\{\s*|\s*\}\}/g, '').trim() const expr = match.replace(/\{\{\s*|\s*\}\}/g, '').trim()
@ -240,7 +286,9 @@ export default (options: ComponentOptions) => {
if (!this._stateToElementsMap[expr]) if (!this._stateToElementsMap[expr])
this._stateToElementsMap[expr] = new Set() 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.) // Handle element nodes (can extend to handle attribute bindings, etc.)
else if (currentNode.nodeType === Node.ELEMENT_NODE) { else if (currentNode.nodeType === Node.ELEMENT_NODE) {
const currentElementNode = currentNode as Element // Renamed to avoid conflict with outer 'element' const currentElementNode = currentNode as Element // Renamed to avoid conflict with outer 'element'
// Traverse all macro attributes // Traverse all macro attributes
// Detect :attr="" bindings, such as :src="imageUrl" // Detect :attr="" bindings, such as :src="imageUrl"
Array.from(currentElementNode.attributes).forEach(attr => { Array.from(currentElementNode.attributes).forEach((attr) => {
if (attr.name.startsWith(':')) { if (attr.name.startsWith(':')) {
const attrName = attr.name.substring(1) // Remove ':' const attrName = attr.name.substring(1) // Remove ':'
const expr = attr.value.trim() const expr = attr.value.trim()
@ -263,13 +310,20 @@ export default (options: ComponentOptions) => {
currentElementNode.removeAttribute(attr.name) currentElementNode.removeAttribute(attr.name)
// Set up attribute binding // 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" // Process @event bindings, such as @click="handleClick"
const eventBindings = Array.from(currentElementNode.attributes).filter(attr => attr.name.startsWith('@')) const eventBindings = Array.from(
eventBindings.forEach(attr => { currentElementNode.attributes,
).filter((attr) => attr.name.startsWith('@'))
eventBindings.forEach((attr) => {
const eventName = attr.name.substring(1) // Remove '@' const eventName = attr.name.substring(1) // Remove '@'
const handlerValue = attr.value.trim() const handlerValue = attr.value.trim()
@ -279,22 +333,42 @@ export default (options: ComponentOptions) => {
// Handle different types of event handlers // Handle different types of event handlers
if (handlerValue.includes('=>')) { if (handlerValue.includes('=>')) {
// Handle arrow function: @click="e => setState('count', count + 1)" // Handle arrow function: @click="e => setState('count', count + 1)"
this._setupArrowFunctionHandler(currentElementNode, eventName, handlerValue) this._setupArrowFunctionHandler(
} else if (handlerValue.includes('(') && handlerValue.includes(')')) { currentElementNode,
eventName,
handlerValue,
)
} else if (
handlerValue.includes('(') &&
handlerValue.includes(')')
) {
// Handle function call: @click="increment(5)" // Handle function call: @click="increment(5)"
this._setupFunctionCallHandler(currentElementNode, eventName, handlerValue) this._setupFunctionCallHandler(
currentElementNode,
eventName,
handlerValue,
)
} else if (typeof (this as any)[handlerValue] === 'function') { } else if (typeof (this as any)[handlerValue] === 'function') {
// Handle method reference: @click="handleClick" // Handle method reference: @click="handleClick"
currentElementNode.addEventListener(eventName, (this as any)[handlerValue].bind(this)) currentElementNode.addEventListener(
eventName,
(this as any)[handlerValue].bind(this),
)
} else { } else {
// Handle simple expression: @click="count++" or @input="name = $event.target.value" // 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" // Process %-started macros, such as %connect="stateName", %if="condition", %for="item in items"
const macroBindings = Array.from(currentElementNode.attributes).filter(attr => attr.name.startsWith('%')) const macroBindings = Array.from(
macroBindings.forEach(attr => { currentElementNode.attributes,
).filter((attr) => attr.name.startsWith('%'))
macroBindings.forEach((attr) => {
const macroName = attr.name.substring(1) // Remove '%' const macroName = attr.name.substring(1) // Remove '%'
const expr = attr.value.trim() const expr = attr.value.trim()
@ -302,19 +376,16 @@ export default (options: ComponentOptions) => {
currentElementNode.removeAttribute(attr.name) currentElementNode.removeAttribute(attr.name)
// Handle different types of macros // 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) this._setupTwoWayBinding(currentElementNode, expr)
else if (macroName === 'if') else if (macroName === 'if')
ifDirectivesToProcess.push({ element: currentElementNode, expr }) ifDirectivesToProcess.push({ element: currentElementNode, expr })
else if (macroName === 'for') else if (macroName === 'for')
this._setupListRendering(currentElementNode, expr) this._setupListRendering(currentElementNode, expr)
else if (macroName === 'key') else if (macroName === 'key') return
return else console.warn(`Unknown macro: %${macroName}`)
else
console.warn(`Unknown macro: %${macroName}`)
}) })
} }
} }
@ -336,7 +407,9 @@ export default (options: ComponentOptions) => {
if (value !== undefined) if (value !== undefined)
element.setAttribute('data-laterano-connect', String(value)) element.setAttribute('data-laterano-connect', String(value))
else 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 // Add event listener for input events
element.addEventListener('input', (event: Event) => { element.addEventListener('input', (event: Event) => {
@ -359,20 +432,19 @@ export default (options: ComponentOptions) => {
// Handle condition rendering (%if macro) // Handle condition rendering (%if macro)
private _setupConditionRendering(element: Element, expr: string) { private _setupConditionRendering(element: Element, expr: string) {
const placeholder = document.createComment(` %if: ${expr} `) const placeholder = document.createComment(` %if: ${expr} `)
element.parentNode?.insertBefore(placeholder, element) element.parentNode?.insertBefore(placeholder, element)
this._conditionalElements.set(element, { this._conditionalElements.set(element, {
expr, expr,
placeholder, placeholder,
isPresent: true isPresent: true,
}) })
this._evaluateIfCondition(element, expr) this._evaluateIfCondition(element, expr)
const statePaths = this._extractStatePathsFromExpression(expr) const statePaths = this._extractStatePathsFromExpression(expr)
statePaths.forEach(path => { statePaths.forEach((path) => {
if (!this._stateToElementsMap[path]) { if (!this._stateToElementsMap[path]) {
this._stateToElementsMap[path] = new Set() this._stateToElementsMap[path] = new Set()
} }
@ -383,7 +455,9 @@ export default (options: ComponentOptions) => {
// Handle list rendering (%for macro) // Handle list rendering (%for macro)
private _setupListRendering(element: Element, expr: string) { private _setupListRendering(element: Element, expr: string) {
// Parse the expression (e.g., "item in items" or "(item, index) in items") // 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) { if (!match) {
console.error(`Invalid %for expression: ${expr}`) console.error(`Invalid %for expression: ${expr}`)
return return
@ -404,9 +478,9 @@ export default (options: ComponentOptions) => {
// Store current rendered items // Store current rendered items
const renderedItems: Array<{ const renderedItems: Array<{
element: Element, element: Element
key: any, key: any
data: any, data: any
index: number index: number
}> = [] }> = []
@ -414,7 +488,9 @@ export default (options: ComponentOptions) => {
const updateList = () => { const updateList = () => {
const collection = this._evaluateExpression(collectionExpr) const collection = this._evaluateExpression(collectionExpr)
if (!collection || !Array.isArray(collection)) { 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 return
} }
@ -425,19 +501,22 @@ export default (options: ComponentOptions) => {
} }
// Detach all currently rendered DOM items managed by this instance. // Detach all currently rendered DOM items managed by this instance.
renderedItems.forEach(item => { renderedItems.forEach((item) => {
if (item.element.parentNode === parentNode) { if (item.element.parentNode === parentNode) {
parentNode.removeChild(item.element); parentNode.removeChild(item.element)
} }
}); })
// Get key attribute if available // Get key attribute if available
const keyAttr = template.getAttribute('%key') 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 // Store a map of existing items by key for reuse
const existingElementsByKey = new Map() const existingElementsByKey = new Map()
renderedItems.forEach(item => { renderedItems.forEach((item) => {
if (item.key !== undefined) { if (item.key !== undefined) {
existingElementsByKey.set(item.key, item) existingElementsByKey.set(item.key, item)
} }
@ -452,7 +531,15 @@ export default (options: ComponentOptions) => {
// Create or update items in the list // Create or update items in the list
collection.forEach((item, index) => { collection.forEach((item, index) => {
// Determine the key for this item // 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 // Check if we can reuse an existing element
const existingItem = existingElementsByKey.get(key) const existingItem = existingElementsByKey.get(key)
@ -472,19 +559,21 @@ export default (options: ComponentOptions) => {
element: itemElement, element: itemElement,
key, key,
data: item, data: item,
index index,
}) })
// Create item context for this item // Create item context for this item
const itemContext = { const itemContext = {
[itemVar]: item [itemVar]: item,
} }
if (indexVar) if (indexVar) itemContext[indexVar] = index
itemContext[indexVar] = index
// insert %key attribute, which dynamically bind the key // insert %key attribute, which dynamically bind the key
if (keyAttr) { if (keyAttr) {
const keyValue = this._evaluateExpressionWithItemContext(keyAttr, itemContext) const keyValue = this._evaluateExpressionWithItemContext(
keyAttr,
itemContext,
)
itemElement.setAttribute('data-laterano-key', String(keyValue)) itemElement.setAttribute('data-laterano-key', String(keyValue))
} }
@ -503,7 +592,7 @@ export default (options: ComponentOptions) => {
placeholder.parentNode?.insertBefore(fragment, placeholder.nextSibling) placeholder.parentNode?.insertBefore(fragment, placeholder.nextSibling)
// Remove any remaining unused items // Remove any remaining unused items
existingElementsByKey.forEach(item => { existingElementsByKey.forEach((item) => {
if (item.element.parentNode) { if (item.element.parentNode) {
item.element.parentNode.removeChild(item.element) item.element.parentNode.removeChild(item.element)
} }
@ -519,7 +608,9 @@ export default (options: ComponentOptions) => {
} }
// Using a unique identifier for this list rendering instance // Using a unique identifier for this list rendering instance
const listVirtualElement = document.createElement('div') const listVirtualElement = document.createElement('div')
this._stateToElementsMap[collectionExpr].add(listVirtualElement as HTMLElement) this._stateToElementsMap[collectionExpr].add(
listVirtualElement as HTMLElement,
)
// Add listener for state changes // Add listener for state changes
this._statesListeners[collectionExpr] = () => { this._statesListeners[collectionExpr] = () => {
@ -528,9 +619,12 @@ export default (options: ComponentOptions) => {
} }
// Recursively process the element and its children, applying the item context // Recursively process the element and its children, applying the item context
private _processElementWithItemContext(element: Element, itemContext: Record<string, any>) { private _processElementWithItemContext(
element: Element,
itemContext: Record<string, any>,
) {
// 1. Store the item context of the element so that subsequent updates can find it // 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 // 2. Process bindings in text nodes
const processTextNodes = (node: Node) => { const processTextNodes = (node: Node) => {
@ -538,28 +632,37 @@ export default (options: ComponentOptions) => {
const textContent = node.textContent || '' const textContent = node.textContent || ''
if (textContent.includes('{{')) { if (textContent.includes('{{')) {
const textNode = node as Text const textNode = node as Text
const updatedContent = textContent.replace(/\{\{\s*([^}]+)\s*\}\}/g, (match, expr) => { const updatedContent = textContent.replace(
const value = this._evaluateExpressionWithItemContext(expr.trim(), itemContext) /\{\{\s*([^}]+)\s*\}\}/g,
return value !== undefined ? String(value) : '' (match, expr) => {
}) const value = this._evaluateExpressionWithItemContext(
expr.trim(),
itemContext,
)
return value !== undefined ? String(value) : ''
},
)
textNode.textContent = updatedContent textNode.textContent = updatedContent
} }
} }
} }
// Process the text nodes of the element itself // 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) { if (node.nodeType === Node.TEXT_NODE) {
processTextNodes(node) processTextNodes(node)
} }
}) })
// 3. Process attribute bindings (:attr) // 3. Process attribute bindings (:attr)
Array.from(element.attributes).forEach(attr => { Array.from(element.attributes).forEach((attr) => {
if (attr.name.startsWith(':')) { if (attr.name.startsWith(':')) {
const attrName = attr.name.substring(1) const attrName = attr.name.substring(1)
const expr = attr.value.trim() const expr = attr.value.trim()
const value = this._evaluateExpressionWithItemContext(expr, itemContext) const value = this._evaluateExpressionWithItemContext(
expr,
itemContext,
)
if (value !== undefined) { if (value !== undefined) {
element.setAttribute(attrName, String(value)) element.setAttribute(attrName, String(value))
@ -571,7 +674,7 @@ export default (options: ComponentOptions) => {
}) })
// 4. Process event bindings (@event) // 4. Process event bindings (@event)
Array.from(element.attributes).forEach(attr => { Array.from(element.attributes).forEach((attr) => {
if (attr.name.startsWith('@')) { if (attr.name.startsWith('@')) {
const eventName = attr.name.substring(1) const eventName = attr.name.substring(1)
const handlerValue = attr.value.trim() const handlerValue = attr.value.trim()
@ -587,14 +690,17 @@ export default (options: ComponentOptions) => {
...this._createHandlerContext(event, element), ...this._createHandlerContext(event, element),
...itemContext, ...itemContext,
$event: event, $event: event,
$el: element $el: element,
} }
// Execute the expression // Execute the expression
const fnStr = `with(this) { ${handlerValue} }` const fnStr = `with(this) { ${handlerValue} }`
new Function(fnStr).call(mergedContext) new Function(fnStr).call(mergedContext)
} catch (err) { } 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 isConditional = false
let shouldDisplay = true let shouldDisplay = true
Array.from(element.attributes).forEach(attr => { Array.from(element.attributes).forEach((attr) => {
if (attr.name === '%if') { if (attr.name === '%if') {
isConditional = true isConditional = true
const expr = attr.value.trim() const expr = attr.value.trim()
@ -613,12 +719,14 @@ export default (options: ComponentOptions) => {
element.removeAttribute(attr.name) element.removeAttribute(attr.name)
// Calculate the condition // Calculate the condition
const result = this._evaluateExpressionWithItemContext(expr, itemContext) const result = this._evaluateExpressionWithItemContext(
expr,
itemContext,
)
shouldDisplay = Boolean(result) shouldDisplay = Boolean(result)
// Apply the condition (in the list item context, we use display style to simplify) // Apply the condition (in the list item context, we use display style to simplify)
if (!shouldDisplay) if (!shouldDisplay) (element as HTMLElement).style.display = 'none'
(element as HTMLElement).style.display = 'none'
} }
}) })
@ -630,7 +738,7 @@ export default (options: ComponentOptions) => {
// 6. Process nested list rendering (%for) // 6. Process nested list rendering (%for)
let hasForDirective = false let hasForDirective = false
Array.from(element.attributes).forEach(attr => { Array.from(element.attributes).forEach((attr) => {
if (attr.name === '%for') { if (attr.name === '%for') {
hasForDirective = true hasForDirective = true
const forExpr = attr.value.trim() const forExpr = attr.value.trim()
@ -650,16 +758,22 @@ export default (options: ComponentOptions) => {
} }
// 7. Recursively process all child elements // 7. Recursively process all child elements
Array.from(element.children).forEach(child => { Array.from(element.children).forEach((child) => {
this._processElementWithItemContext(child, itemContext) this._processElementWithItemContext(child, itemContext)
}) })
} }
// Set up nested list rendering // Set up nested list rendering
private _setupNestedListRendering(element: Element, expr: string, parentItemContext: Record<string, any>) { private _setupNestedListRendering(
element: Element,
expr: string,
parentItemContext: Record<string, any>,
) {
// Similar to _setupListRendering, but applies to nested situations // Similar to _setupListRendering, but applies to nested situations
// Parse the expression (e.g., "subItem in item.subItems") // 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) { if (!match) {
console.error(`Invalid nested %for expression: ${expr}`) console.error(`Invalid nested %for expression: ${expr}`)
return return
@ -671,10 +785,15 @@ export default (options: ComponentOptions) => {
const collectionExpr = match[4].trim() const collectionExpr = match[4].trim()
// Evaluate the collection expression, using the parent item context // 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)) { 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 return
} }
@ -693,7 +812,7 @@ export default (options: ComponentOptions) => {
// Create a nested item context, merging the parent context // Create a nested item context, merging the parent context
const nestedItemContext = { const nestedItemContext = {
...parentItemContext, ...parentItemContext,
[itemVar]: item [itemVar]: item,
} }
if (indexVar) { if (indexVar) {
@ -707,12 +826,21 @@ export default (options: ComponentOptions) => {
// to improve performance // to improve performance
// Insert the item element into the DOM // 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 // Evaluate expressions using the item context
private _evaluateExpressionWithItemContext(expression: string, itemContext: Record<string, any>, index?: number, itemVar?: string, indexVar?: string): any { private _evaluateExpressionWithItemContext(
expression: string,
itemContext: Record<string, any>,
index?: number,
itemVar?: string,
indexVar?: string,
): any {
try { try {
// Check if the expression directly references the item variable // Check if the expression directly references the item variable
if (itemVar && expression === itemVar) { if (itemVar && expression === itemVar) {
@ -751,7 +879,10 @@ export default (options: ComponentOptions) => {
const func = new Function(...contextKeys, `return ${expression}`) const func = new Function(...contextKeys, `return ${expression}`)
return func(...contextValues) return func(...contextValues)
} catch (error) { } catch (error) {
console.error(`Error evaluating expression with item context: ${expression}`, error) console.error(
`Error evaluating expression with item context: ${expression}`,
error,
)
return undefined return undefined
} }
} }
@ -765,10 +896,14 @@ export default (options: ComponentOptions) => {
const shouldShow = Boolean(result) const shouldShow = Boolean(result)
if (shouldShow !== info.isPresent) { if (shouldShow !== info.isPresent) {
if (shouldShow) // Insert the element back into the DOM if (shouldShow)
info.placeholder.parentNode?.insertBefore(element, info.placeholder.nextSibling) // Insert the element back into the DOM
else // Remove the element from the DOM info.placeholder.parentNode?.insertBefore(
element.parentNode?.removeChild(element) element,
info.placeholder.nextSibling,
)
// Remove the element from the DOM
else element.parentNode?.removeChild(element)
// Update the state // Update the state
info.isPresent = shouldShow info.isPresent = shouldShow
@ -789,7 +924,9 @@ export default (options: ComponentOptions) => {
const func = new Function(...stateKeys, `return ${expression}`) const func = new Function(...stateKeys, `return ${expression}`)
const execRes = func(...stateValues) const execRes = func(...stateValues)
if (typeof execRes !== 'boolean') 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 return execRes
} catch (error) { } catch (error) {
console.error(`Error evaluating expression: ${expression}`, error) console.error(`Error evaluating expression: ${expression}`, error)
@ -799,13 +936,18 @@ export default (options: ComponentOptions) => {
private _extractStatePathsFromExpression(expression: string): string[] { private _extractStatePathsFromExpression(expression: string): string[] {
const matches = expression.match(/[a-zA-Z_$][a-zA-Z0-9_$]*/g) || [] const matches = expression.match(/[a-zA-Z_$][a-zA-Z0-9_$]*/g) || []
return matches.filter(match => return matches.filter(
!['true', 'false', 'null', 'undefined', 'this'].includes(match) (match) =>
!['true', 'false', 'null', 'undefined', 'this'].includes(match),
) )
} }
// Handle arrow function // Handle arrow function
private _setupArrowFunctionHandler(element: Element, eventName: string, handlerValue: string) { private _setupArrowFunctionHandler(
element: Element,
eventName: string,
handlerValue: string,
) {
element.addEventListener(eventName, (event: Event) => { element.addEventListener(eventName, (event: Event) => {
try { try {
// Arrow function parsing // Arrow function parsing
@ -860,7 +1002,10 @@ export default (options: ComponentOptions) => {
handlerFn.apply(context, [event]) handlerFn.apply(context, [event])
} }
} catch (err) { } 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, $el: element,
this: this, // Provide reference to the component instance this: this, // Provide reference to the component instance
setState: this.setState.bind(this), setState: this.setState.bind(this),
getState: this.getState.bind(this) getState: this.getState.bind(this),
} }
// Add all methods of the component // Add all methods of the component
Object.getOwnPropertyNames(Object.getPrototypeOf(this)).forEach(name => { Object.getOwnPropertyNames(Object.getPrototypeOf(this)).forEach(
if (typeof (this as any)[name] === 'function' && name !== 'constructor') { (name) => {
context[name] = (this as any)[name].bind(this) if (
} typeof (this as any)[name] === 'function' &&
}) name !== 'constructor'
) {
context[name] = (this as any)[name].bind(this)
}
},
)
return context return context
} }
// Handle function call, such as @click="increment(5)" // 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) => { element.addEventListener(eventName, (event: Event) => {
try { try {
// Create context object // Create context object
@ -910,13 +1064,20 @@ export default (options: ComponentOptions) => {
new Function(fnStr).call(context) new Function(fnStr).call(context)
} catch (err) { } 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" // 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) => { element.addEventListener(eventName, (event: Event) => {
try { try {
// Create context object // 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 // If the expression returns a value, it can be used for two-way binding
return result return result
} catch (err) { } 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 // 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 // Initialize attribute value
const value = this._getNestedState(expr) const value = this._getNestedState(expr)
@ -977,7 +1146,7 @@ export default (options: ComponentOptions) => {
element, element,
attrName, attrName,
expr, expr,
template template,
}) })
} }
@ -1009,7 +1178,11 @@ export default (options: ComponentOptions) => {
return ['data-attribute'] return ['data-attribute']
} }
attributeChangedCallback(attrName: string, oldValue: string, newValue: string) { attributeChangedCallback(
attrName: string,
oldValue: string,
newValue: string,
) {
if (onAttributeChanged) onAttributeChanged(attrName, oldValue, newValue) if (onAttributeChanged) onAttributeChanged(attrName, oldValue, newValue)
} }
@ -1032,8 +1205,7 @@ export default (options: ComponentOptions) => {
// function trigger // function trigger
triggerFunc(eventName: string, ...args: any[]) { triggerFunc(eventName: string, ...args: any[]) {
if (funcs && funcs[eventName]) if (funcs && funcs[eventName]) funcs[eventName].call(this, ...args)
funcs[eventName].call(this, ...args)
} }
} }