UNPKG

hooktml

Version:

A reactive HTML component library with hooks-based lifecycle management

163 lines (131 loc) 6.16 kB
import { getRegisteredHooks, getRegisteredHook } from './hookRegistry.js' import { camelToKebab, kebabToCamel } from '../utils/strings.js' import { isHTMLElement, isNotNil, isNonEmptyString, isEmptyString, isFunction, isEmptyArray } from '../utils/type-guards.js' import { tryCatch } from '../utils/try-catch.js' import { coerceValue, extractHookProps } from '../utils/props.js' import { lifecycleManager } from './initialization.js' import { withHookContext } from './hookContext.js' import { logger } from '../utils/logger.js' import { getConfig } from './config.js' import { getHookInstance, storeHookInstance } from './hookInstanceRegistry.js' /** * Creates a combined selector for all registered hooks * @param {string[]} hookNames - Array of hook names in camelCase * @param {string|undefined} prefix - Attribute prefix to use * @returns {string} A single CSS selector for all hooks */ const createHookSelector = (hookNames, prefix = '') => { if (!hookNames.length) return '' // Convert camelCase hook names to kebab-case attributes with prefix const attributeNames = hookNames.map(name => `[${prefix}${camelToKebab(name)}]`) // Join with commas to create a single selector return attributeNames.join(', ') } /** * Extracts hook names from element attributes * @param {HTMLElement} element - DOM element to extract hook names from * @param {string|undefined} prefix - Attribute prefix to use * @returns {Array<{name: string, value: string, originalName: string}>} Array of attribute info */ const getHookAttributesFromElement = (element, prefix = '') => { const attributes = Array.from(element.attributes) const usePrefix = `${prefix}use-` return attributes .filter(attr => attr.name.startsWith(usePrefix)) .map(attr => ({ name: attr.name.substring(prefix.length), // Remove prefix for processing originalName: attr.name, // Keep original for logging value: attr.value })) } /** * Processes a single element, applying all hooks defined on it * @param {HTMLElement} element - The DOM element to process */ export const processElementHooks = (element) => { // Skip if already processed - prevents infinite loop const hasTeardowns = lifecycleManager.hasRegistration(element) if (hasTeardowns) { logger.log('⏭️ Skipping already processed element', element) return } const { formattedPrefix } = getConfig() const hookAttributes = getHookAttributesFromElement(element, formattedPrefix) logger.log(`Processing element with ${hookAttributes.length} hook(s):`, element) // For each hook attribute, find and call the corresponding function hookAttributes.forEach(({ name, originalName, value }) => { // Properly capitalize the hook name (e.g., "teardown" -> "useTeardown") // Must use exact case to match registered hook const hookName = kebabToCamel(name) logger.log(`Looking for hook "${hookName}" from attribute "${originalName}"`) const hookFn = getRegisteredHook(hookName) if (isNotNil(hookFn) && isFunction(hookFn)) { logger.log(`Found hook "${hookName}" for element:`, element) // Check if we already have an instance for this hook const existingInstance = getHookInstance(element, hookName) if (existingInstance) { logger.log(`Using existing instance for hook "${hookName}"`) return // Skip re-initialization } // Extract all props for this hook (including main value and additional props) const props = extractHookProps(element, hookName, value) if (isNotNil(value)) { logger.log(`Passing props to hook "${hookName}":`, props) } // Call the hook and store any teardown function it returns const resultRef = { current: undefined } tryCatch({ fn: () => { logger.log(`Calling hook function for "${hookName}" with hook context`) // Execute the hook function within a hook context to support useEffect resultRef.current = withHookContext(element, () => { const instance = hookFn(element, props) // Store the hook instance for future reference storeHookInstance(element, hookName, instance) return instance }) logger.log(`Hook "${hookName}" returned:`, resultRef.current, typeof resultRef.current) }, onError: (error) => { logger.error(`Error applying hook "${hookName}":`, error) } }) // If the hook returned a function, store it as a teardown function if (isFunction(resultRef.current)) { logger.log(`Storing teardown function for hook "${hookName}" on element:`, element) lifecycleManager.registerDirective(element, resultRef.current, hookName) // Verify teardown was stored const verifyTeardowns = lifecycleManager.hasRegistration(element) logger.log(`✅ Verified teardown is registered: ${verifyTeardowns}`) } else { logger.log(`Hook "${hookName}" did not return a teardown function`) } } else { logger.warn(`Unknown hook "${hookName}" requested on element:`, element) } }) } /** * Scans the DOM for elements with use-* attributes and applies registered hooks * @returns {number} The number of processed elements */ export const scanDirectives = () => { const hooks = getRegisteredHooks() const hookNames = Array.from(hooks.keys()) const { formattedPrefix } = getConfig() if (isEmptyArray(hookNames)) { logger.log('No hooks registered yet') return 0 } // Create a combined selector for all hooks const selector = createHookSelector(hookNames, formattedPrefix) logger.log(`Scanning DOM for hook directives with selector: "${selector}"`) // Find all elements with use-* attributes and ensure they are HTMLElements /** @type {HTMLElement[]} */ const elements = Array.from(document.querySelectorAll(selector)) .filter(isHTMLElement) logger.log(`Found ${elements.length} element(s) with hook directives`) // Process each element by applying registered hooks elements.forEach(element => processElementHooks(element)) return elements.length }