interface CustomElement extends HTMLElement {
setState(key_path: string, value: any): void
getState(key_path: string): any
}
interface ComponentOptions {
tag: string
template: string
style?: string
onMount?: (this: CustomElement) => void
onUnmount?: () => void
onAttributeChanged?: (attrName: string, oldValue: string, newValue: string) => void
states?: Record
statesListeners?: { [key: string]: (value: any) => void }
events?: { [key: string]: (event: Event) => void }
}
export default (options: ComponentOptions) => {
const { tag, template, style, onMount, onUnmount, onAttributeChanged, states, statesListeners } = options
const componentRegistry = new Map()
componentRegistry.set(tag, options)
class CustomElementImpl extends HTMLElement {
private _states: Record = {}
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()
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] = {}
}
currentTarget = currentTarget[key]
}
}
// trigger dom updates
this._triggerDomUpdates(keyPath)
if (this._statesListeners[keyPath])
this._statesListeners[keyPath](value)
// trigger state update events
if (statesListeners && statesListeners[keyPath]) {
statesListeners[keyPath](value)
}
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
}
})
// initialize dom tree and append to shadow root
this._initialize()
}
private _initialize() {
// initialize shadow dom
const shadow = this.attachShadow({ mode: 'open' })
if (style) {
const styleElement = document.createElement('style')
styleElement.textContent = style
this.shadowRoot?.appendChild(styleElement)
}
const parser = new DOMParser()
const doc = parser.parseFromString(template, 'text/html')
const mainContent = doc.body.firstElementChild
let rootElement
if (mainContent) {
rootElement = document.importNode(mainContent, true)
shadow.appendChild(rootElement)
} else {
const container = document.createElement('div')
container.innerHTML = template
rootElement = container
shadow.appendChild(container)
}
this._processTemplateMarcos(rootElement)
}
private _triggerDomUpdates(keyPath: string) {
if (this._stateToElementsMap[keyPath]) {
const updateQueue = new Set()
this._stateToElementsMap[keyPath].forEach(element => {
updateQueue.add(element)
})
this._scheduleUpdate(updateQueue)
}
// 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)
}
})
}
// Update attribute bindings that depend on this state
if (this._attributeBindings) {
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))
}
}
})
}
}
private _scheduleUpdate(elements: Set) {
requestAnimationFrame(() => {
elements.forEach(element => {
this._updateElement(element)
})
})
}
private _updateElement(element: HTMLElement) {
const renderFunction = (element as any)._renderFunction
if (renderFunction) {
// Set rendering context
this._currentRenderingElement = element
// Execute rendering
const result = renderFunction()
// Update DOM
if (typeof result === 'string') {
element.innerHTML = result
} else if (result instanceof Node) {
element.innerHTML = ''
element.appendChild(result)
}
// Clear rendering context
this._currentRenderingElement = null
}
}
private _processTemplateMarcos(element: Element) {
/*
* We define that those prefix are available as macros:
* - @ means event binding marco, such as @click="handleClick"
* - : means dynamic attribute marco, such as :src="imageUrl"
* - % means component controlling marco, such as %if="condition", %for="item in items" and %connect="stateName"
*/
// Traverse all child nodes, including text nodes
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
null
)
// Store nodes and expressions that need to be updated
const textBindings: Array<{ node: Text, expr: string, originalContent: string }> = []
// Traverse the DOM tree
let currentNode: Node | null
while (currentNode = walker.nextNode()) {
// Handle text nodes
if (currentNode.nodeType === Node.TEXT_NODE) {
const textContent = currentNode.textContent || ''
const textNode = currentNode as Text
// Check if it contains Handlebars expressions {{ xxx }}
if (textContent.includes('{{')) {
// Save the original content, including expressions
const originalContent = textContent
// Record nodes and expressions that need to be updated
const matches = textContent.match(/\{\{\s*([^}]+)\s*\}\}/g)
if (matches) {
matches.forEach(match => {
// Extract the expression content, removing {{ }} and spaces
const expr = match.replace(/\{\{\s*|\s*\}\}/g, '').trim()
// Store the node, expression, and original content for later updates
textBindings.push({ node: textNode, expr, originalContent })
// Set the initial value
this._updateTextNode(textNode, expr, originalContent)
// Add dependency relationship for this state path
if (!this._stateToElementsMap[expr]) {
this._stateToElementsMap[expr] = new Set()
}
this._stateToElementsMap[expr].add(textNode as unknown as HTMLElement)
})
}
}
}
// Handle element nodes (can extend to handle attribute bindings, etc.)
else if (currentNode.nodeType === Node.ELEMENT_NODE) {
// Handle element attribute bindings, such as
const element = currentNode as Element
// Traverse all marco attributes
// Detect :attr="" bindings, such as :src="imageUrl"
Array.from(element.attributes).forEach(attr => {
if (attr.name.startsWith(':')) {
const attrName = attr.name.substring(1) // Remove ':'
const expr = attr.value.trim()
// Remove the attribute, as it is not a standard HTML attribute
element.removeAttribute(attr.name)
// Set up attribute binding
this._setupAttributeBinding(element, attrName, expr, attr.value)
}
})
// Process @event bindings, such as @click="handleClick"
const eventBindings = Array.from(element.attributes).filter(attr => attr.name.startsWith('@'))
eventBindings.forEach(attr => {
const eventName = attr.name.substring(1) // Remove '@'
const handlerValue = attr.value.trim()
// Remove the attribute, as it is not a standard HTML attribute
element.removeAttribute(attr.name)
// Handle different types of event handlers
if (handlerValue.includes('=>')) {
// Handle arrow function: @click="e => setState('count', count + 1)"
this._setupArrowFunctionHandler(element, eventName, handlerValue)
} else if (handlerValue.includes('(') && handlerValue.includes(')')) {
// Handle function call: @click="increment(5)"
this._setupFunctionCallHandler(element, eventName, handlerValue)
} else if (typeof (this as any)[handlerValue] === 'function') {
// Handle method reference: @click="handleClick"
element.addEventListener(eventName, (this as any)[handlerValue].bind(this))
} else {
// Handle simple expression: @click="count++" or @input="name = $event.target.value"
this._setupExpressionHandler(element, eventName, handlerValue)
}
})
// Process %-started marcos, such as %connect="stateName", %if="condition", %for="item in items"
const macroBindings = Array.from(element.attributes).filter(attr => attr.name.startsWith('%'))
macroBindings.forEach(attr => {
const macroName = attr.name.substring(1) // Remove '%'
const expr = attr.value.trim()
// Remove the attribute, as it is not a standard HTML attribute
element.removeAttribute(attr.name)
// Handle different types of macros
if (macroName === 'connect') // Handle state connection: %connect="stateName"
this._setupTwoWayBinding(element, expr)
else if (macroName === 'if')
this._setupConditionRendering(element, expr)
else if (macroName === 'for')
this._setupAttributeBinding(element, 'data-laterano-for', expr, attr.value)
else
console.warn(`Unknown macro: %${macroName}`)
})
}
}
// Save text binding relationships for updates
this._textBindings = textBindings
}
// Handle two-way data binding (%connect marco)
private _setupTwoWayBinding(element: Element, expr: string) {
console.log("setting up two-way binding for", expr)
// Get the initial value
const value = this._getNestedState(expr)
// Set the initial value
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.`)
// Add event listener for input events
element.addEventListener('input', (event: Event) => {
const target = event.target as HTMLInputElement
const newValue = target.value
// Update the state
this.setState(expr, newValue)
})
// Add event listener for state changes
this._statesListeners[expr] = (newValue: any) => {
if (element instanceof HTMLInputElement) {
element.value = newValue
} else {
element.setAttribute('data-laterano-connect', String(newValue))
}
}
}
// Handle condition rendering (%if marco)
private _setupConditionRendering(element: Element, expr: string) {
console.log("setting up condition rendering for", expr)
const placeholder = document.createComment(` %if: ${expr} `)
element.parentNode?.insertBefore(placeholder, element)
this._conditionalElements.set(element, {
expr,
placeholder,
isPresent: false
})
this._evaluateIfCondition(element, expr)
const statePaths = this._extractStatePathsFromExpression(expr)
statePaths.forEach(path => {
if (!this._stateToElementsMap[path]) {
this._stateToElementsMap[path] = new Set()
}
this._stateToElementsMap[path].add(element as HTMLElement)
})
}
private _evaluateIfCondition(element: Element, condition: string) {
const info = this._conditionalElements.get(element)
if (!info) return
// Evaluate the condition
const result = this._evaluateExpression(condition)
const shouldShow = Boolean(result)
if (shouldShow) console.log(`Condition "${condition}" is true, showing element.`)
else console.log(`Condition "${condition}" is false, hiding element.`)
if (info.isPresent)
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)
// Update the state
info.isPresent = shouldShow
this._conditionalElements.set(element, info)
}
}
private _evaluateExpression(expression: string): any {
try {
// get the state keys and values
if (this._states[expression] !== undefined)
return this._states[expression]
// execute the expression
const stateKeys = Object.keys(this._states)
const stateValues = Object.values(this._states)
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.`)
return execRes
} catch (error) {
console.error(`Error evaluating expression: ${expression}`, error)
return undefined
}
}
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)
)
}
// Handle arrow function
private _setupArrowFunctionHandler(element: Element, eventName: string, handlerValue: string) {
element.addEventListener(eventName, (event: Event) => {
try {
// Arrow function parsing
const splitted = handlerValue.split('=>')
if (splitted.length !== 2) {
throw new Error(`Invalid arrow function syntax: ${handlerValue}`)
}
const paramsStr = (() => {
if (splitted[0].includes('(')) {
return splitted[0].trim()
} else {
return `(${splitted[0].trim()})`
}
})()
const bodyStr = splitted[1].trim()
// Check if the function body is wrapped in {}
const isMultiline = bodyStr.startsWith('{') && bodyStr.endsWith('}')
// If it is a multiline function body, remove the outer braces
if (isMultiline) {
// Remove the outer braces
let bodyStr = handlerValue.split('=>')[1].trim()
bodyStr = bodyStr.substring(1, bodyStr.length - 1)
// Build code for multiline arrow function
const functionCode = `
return function${paramsStr} {
${bodyStr}
}
`
// Create context object
const context = this._createHandlerContext(event, element)
// Create and call function
const handlerFn = new Function(functionCode).call(null)
handlerFn.apply(context, [event])
} else {
// Single line arrow function, directly return expression result
const functionCode = `
return function${paramsStr} {
return ${bodyStr}
}
`
// Create context object
const context = this._createHandlerContext(event, element)
// Create and call function
const handlerFn = new Function(functionCode).call(null)
handlerFn.apply(context, [event])
}
} catch (err) {
console.error(`Error executing arrow function handler: ${handlerValue}`, err)
}
})
}
// Create handler context
private _createHandlerContext(event: Event, element: Element) {
// Basic context, including state
const context: {
[key: string]: any
$event: Event
$el: Element
this: CustomElementImpl // Provide reference to the component instance
setState: (keyPath: string, value: any) => void
getState: (keyPath: string) => any
} = {
...this._states,
$event: event,
$el: element,
this: this, // Provide reference to the component instance
setState: this.setState.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)
}
})
return context
}
// Handle function call, such as @click="increment(5)"
private _setupFunctionCallHandler(element: Element, eventName: string, handlerValue: string) {
element.addEventListener(eventName, (event: Event) => {
try {
// Create context object
const context = this._createHandlerContext(event, element)
// Create and execute function call
const fnStr = `
with(this) {
${handlerValue}
}
`
new Function(fnStr).call(context)
} catch (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) {
element.addEventListener(eventName, (event: Event) => {
try {
// Create context object
const context = this._createHandlerContext(event, element)
// Create expression function
const fnStr = `
with(this) {
${handlerValue}
}
`
// Execute expression
const result = new Function(fnStr).call(context)
// 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)
}
})
}
// Update text node
private _updateTextNode(node: Text, expr: string, template: string) {
// Replace all expressions with the current state value
let newContent = template
const replaceExpr = (match: string, expr: string) => {
// Get the value of the expression
const value = this._getNestedState(expr.trim())
return value !== undefined ? String(value) : ''
}
// Replace all {{ xxx }} expressions
newContent = newContent.replace(/\{\{\s*([^}]+)\s*\}\}/g, replaceExpr)
// Update node content
node.textContent = newContent
}
// Set up attribute binding
private _setupAttributeBinding(element: Element, attrName: string, expr: string, template: string) {
// Initialize attribute value
const value = this._getNestedState(expr)
// Set the initial attribute
if (value !== undefined) {
element.setAttribute(attrName, String(value))
}
// Add update function to the map
if (!this._attributeBindings) {
this._attributeBindings = []
}
this._attributeBindings.push({
element,
attrName,
expr,
template
})
}
// Get nested state value
private _getNestedState(path: string): any {
// Handle nested paths, such as "profile.name"
const parts = path.split('.')
let result = this._states
for (const part of parts) {
if (result === undefined || result === null) {
return undefined
}
result = result[part]
}
return result
}
connectedCallback() {
if (onMount) onMount.call(this)
}
disconnectedCallback() {
if (onUnmount) onUnmount.call(this)
}
static get observedAttributes() {
return ['data-attribute']
}
attributeChangedCallback(attrName: string, oldValue: string, newValue: string) {
if (onAttributeChanged) onAttributeChanged(attrName, oldValue, newValue)
}
// state manager
setState(keyPath: string, value: any) {
this._states[keyPath] = value
}
getState(keyPath: string) {
return this._states[keyPath]
}
}
customElements.define(tag, CustomElementImpl)
}