0.0.3: Exotic Type Gymnastics #3
204
src/main.ts
204
src/main.ts
|
@ -1,4 +1,4 @@
|
|||
import { parseTemplate } from './utils/parseTemplate'
|
||||
import utils from './utils/index'
|
||||
|
||||
interface ComponentOptions {
|
||||
tag: string
|
||||
|
@ -140,10 +140,24 @@ export default (options: ComponentOptions) => {
|
|||
this.shadowRoot?.appendChild(styleElement)
|
||||
}
|
||||
|
||||
const rootElement = parseTemplate(template)
|
||||
const rootElement = utils.parseTemplate(template)
|
||||
shadow.appendChild(rootElement)
|
||||
|
||||
this._processTemplateMacros(rootElement)
|
||||
utils.processTemplateMacros(rootElement, this, {
|
||||
updateTextNode: this._updateTextNode.bind(this),
|
||||
setupAttributeBinding: this._setupAttributeBinding.bind(this),
|
||||
setupArrowFunctionHandler: this._setupArrowFunctionHandler.bind(this),
|
||||
setupFunctionCallHandler: this._setupFunctionCallHandler.bind(this),
|
||||
setupExpressionHandler: this._setupExpressionHandler.bind(this),
|
||||
setupTwoWayBinding: this._setupTwoWayBinding.bind(this),
|
||||
setupConditionRendering: this._setupConditionRendering.bind(this),
|
||||
setupListRendering: this._setupListRendering.bind(this),
|
||||
stateToElementsMap: this._stateToElementsMap,
|
||||
textBindings: this._textBindings,
|
||||
availableFuncs: Object.getOwnPropertyNames(Object.getPrototypeOf(this)).filter(
|
||||
name => typeof (this as Record<string, unknown>)[name] === 'function' && name !== 'constructor'
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
private _triggerDomUpdates(keyPath: string) {
|
||||
|
@ -215,190 +229,6 @@ export default (options: ComponentOptions) => {
|
|||
}
|
||||
}
|
||||
|
||||
private _processTemplateMacros(element: Element) {
|
||||
/*
|
||||
* We define that those prefix are available as macros:
|
||||
* - @ means event binding macro, such as @click="handleClick"
|
||||
* - : means dynamic attribute macro, such as :src="imageUrl"
|
||||
* - % means component controlling macro, 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
|
||||
}> = []
|
||||
const ifDirectivesToProcess: Array<{ element: Element; expr: string }> =
|
||||
[]
|
||||
|
||||
// Traverse the DOM tree
|
||||
let currentNode: Node | null
|
||||
let flag = true
|
||||
while (flag) {
|
||||
currentNode = walker.nextNode()
|
||||
if (!currentNode) {
|
||||
flag = false
|
||||
break
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for (const match of matches) {
|
||||
// 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) {
|
||||
const currentElementNode = currentNode as Element // Renamed to avoid conflict with outer 'element'
|
||||
|
||||
// Traverse all macro attributes
|
||||
|
||||
// Detect :attr="" bindings, such as :src="imageUrl"
|
||||
for (const attr of Array.from(currentElementNode.attributes)) {
|
||||
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
|
||||
currentElementNode.removeAttribute(attr.name)
|
||||
|
||||
// Set up attribute binding
|
||||
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) => {
|
||||
for (const attr of eventBindings) {
|
||||
const eventName = attr.name.substring(1) // Remove '@'
|
||||
const handlerValue = attr.value.trim()
|
||||
|
||||
// Remove the attribute, as it is not a standard HTML attribute
|
||||
currentElementNode.removeAttribute(attr.name)
|
||||
|
||||
// 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(')')
|
||||
) {
|
||||
// Handle function call: @click="increment(5)"
|
||||
this._setupFunctionCallHandler(
|
||||
currentElementNode,
|
||||
eventName,
|
||||
handlerValue,
|
||||
)
|
||||
} else if (
|
||||
typeof (this as Record<string, unknown>)[handlerValue] ===
|
||||
'function'
|
||||
) {
|
||||
// Handle method reference: @click="handleClick"
|
||||
currentElementNode.addEventListener(
|
||||
eventName,
|
||||
(
|
||||
this as unknown as Record<
|
||||
string,
|
||||
(...args: unknown[]) => void
|
||||
>
|
||||
)[handlerValue].bind(this),
|
||||
)
|
||||
} else {
|
||||
// Handle simple expression: @click="count++" or @input="name = $event.target.value"
|
||||
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) => {
|
||||
for (const attr of macroBindings) {
|
||||
const macroName = attr.name.substring(1) // Remove '%'
|
||||
const expr = attr.value.trim()
|
||||
|
||||
// Remove the attribute, as it is not a standard HTML attribute
|
||||
currentElementNode.removeAttribute(attr.name)
|
||||
|
||||
// Handle different types of macros
|
||||
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') continue
|
||||
else console.warn(`Unknown macro: %${macroName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save text binding relationships for updates
|
||||
this._textBindings = textBindings
|
||||
|
||||
// Process all collected %if directives after the main traversal
|
||||
for (const { element: ifElement, expr } of ifDirectivesToProcess) {
|
||||
this._setupConditionRendering(ifElement, expr)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle two-way data binding (%connect macro)
|
||||
private _setupTwoWayBinding(element: Element, expr: string) {
|
||||
// Get the initial value
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { parseTemplate } from './parseTemplate'
|
||||
import { processTemplateMacros } from './processTemplateMarco'
|
||||
|
||||
export default {
|
||||
parseTemplate,
|
||||
processTemplateMacros,
|
||||
}
|
||||
|
|
218
src/utils/processTemplateMarco.ts
Normal file
218
src/utils/processTemplateMarco.ts
Normal file
|
@ -0,0 +1,218 @@
|
|||
export function processTemplateMacros(
|
||||
element: Element,
|
||||
context: CustomElement,
|
||||
options: {
|
||||
updateTextNode: (node: Text, expr: string, originalContent: string) => void
|
||||
setupAttributeBinding: (
|
||||
element: Element,
|
||||
attrName: string,
|
||||
expr: string,
|
||||
attrValue: string,
|
||||
) => void
|
||||
setupArrowFunctionHandler: (
|
||||
element: Element,
|
||||
eventName: string,
|
||||
handlerValue: string,
|
||||
) => void
|
||||
setupFunctionCallHandler: (
|
||||
element: Element,
|
||||
eventName: string,
|
||||
handlerValue: string,
|
||||
) => void
|
||||
setupExpressionHandler: (
|
||||
element: Element,
|
||||
eventName: string,
|
||||
handlerValue: string,
|
||||
) => void
|
||||
setupTwoWayBinding: (element: Element, expr: string) => void
|
||||
setupConditionRendering: (element: Element, expr: string) => void
|
||||
setupListRendering: (element: Element, expr: string) => void
|
||||
stateToElementsMap: Record<string, Set<HTMLElement>>
|
||||
textBindings: {
|
||||
node: Text
|
||||
expr: string
|
||||
originalContent: string
|
||||
}[],
|
||||
availableFuncs: string[]
|
||||
},
|
||||
) {
|
||||
/*
|
||||
* We define that those prefix are available as macros:
|
||||
* - @ means event binding macro, such as @click="handleClick"
|
||||
* - : means dynamic attribute macro, such as :src="imageUrl"
|
||||
* - % means component controlling macro, 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
|
||||
}> = []
|
||||
const ifDirectivesToProcess: Array<{ element: Element; expr: string }> = []
|
||||
|
||||
// Traverse the DOM tree
|
||||
let currentNode: Node | null
|
||||
let flag = true
|
||||
while (flag) {
|
||||
currentNode = walker.nextNode()
|
||||
if (!currentNode) {
|
||||
flag = false
|
||||
break
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for (const match of matches) {
|
||||
// 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
|
||||
options.updateTextNode(textNode, expr, originalContent)
|
||||
|
||||
// Add dependency relationship for this state path
|
||||
if (!options.stateToElementsMap[expr])
|
||||
options.stateToElementsMap[expr] = new Set()
|
||||
|
||||
options.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) {
|
||||
const currentElementNode = currentNode as Element // Renamed to avoid conflict with outer 'element'
|
||||
|
||||
// Traverse all macro attributes
|
||||
|
||||
// Detect :attr="" bindings, such as :src="imageUrl"
|
||||
for (const attr of Array.from(currentElementNode.attributes)) {
|
||||
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
|
||||
currentElementNode.removeAttribute(attr.name)
|
||||
|
||||
// Set up attribute binding
|
||||
options.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) => {
|
||||
for (const attr of eventBindings) {
|
||||
const eventName = attr.name.substring(1) // Remove '@'
|
||||
const handlerValue = attr.value.trim()
|
||||
|
||||
// Remove the attribute, as it is not a standard HTML attribute
|
||||
currentElementNode.removeAttribute(attr.name)
|
||||
|
||||
// Handle different types of event handlers
|
||||
if (handlerValue.includes('=>')) { // Handle arrow function: @click="e => setState('count', count + 1)"
|
||||
options.setupArrowFunctionHandler(
|
||||
currentElementNode,
|
||||
eventName,
|
||||
handlerValue,
|
||||
)
|
||||
} else if (
|
||||
handlerValue.includes('(') &&
|
||||
handlerValue.includes(')')
|
||||
) { // Handle function call: @click="increment(5)"
|
||||
options.setupFunctionCallHandler(
|
||||
currentElementNode,
|
||||
eventName,
|
||||
handlerValue,
|
||||
)
|
||||
} else if (
|
||||
options.availableFuncs.includes(handlerValue) &&
|
||||
typeof (context as unknown as Record<string, unknown>)[
|
||||
handlerValue
|
||||
] === 'function'
|
||||
) { // Handle method reference: @click="handleClick"
|
||||
currentElementNode.addEventListener(
|
||||
eventName,
|
||||
(
|
||||
context as unknown as Record<
|
||||
string,
|
||||
(...args: unknown[]) => void
|
||||
>
|
||||
)[handlerValue].bind(context),
|
||||
)
|
||||
} else {
|
||||
// Handle simple expression: @click="count++" or @input="name = $event.target.value"
|
||||
options.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) => {
|
||||
for (const attr of macroBindings) {
|
||||
const macroName = attr.name.substring(1) // Remove '%'
|
||||
const expr = attr.value.trim()
|
||||
|
||||
// Remove the attribute, as it is not a standard HTML attribute
|
||||
currentElementNode.removeAttribute(attr.name)
|
||||
|
||||
// Handle different types of macros
|
||||
if (macroName === 'connect')
|
||||
// Handle state connection: %connect="stateName"
|
||||
options.setupTwoWayBinding(currentElementNode, expr)
|
||||
else if (macroName === 'if') {
|
||||
ifDirectivesToProcess.push({ element: currentElementNode, expr })
|
||||
} else if (macroName === 'for')
|
||||
options.setupListRendering(currentElementNode, expr)
|
||||
else if (macroName === 'key') continue
|
||||
else console.warn(`Unknown macro: %${macroName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save text binding relationships for updates
|
||||
options.textBindings = textBindings
|
||||
|
||||
// Process all collected %if directives after the main traversal
|
||||
for (const { element: ifElement, expr } of ifDirectivesToProcess) {
|
||||
options.setupConditionRendering(ifElement, expr)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user