UNPKG

hooktml

Version:

A reactive HTML component library with hooks-based lifecycle management

1,405 lines (1,380 loc) 64.1 kB
var HookTML = (() => { var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // index.browser.js var index_browser_exports = {}; __export(index_browser_exports, { HookTML: () => HookTML, computed: () => computed, getConfig: () => getConfig, registerChainableHook: () => registerChainableHook, registerComponent: () => registerComponent, registerHook: () => registerHook, scan: () => scan, signal: () => signal, start: () => start, useAttributes: () => useAttributes, useChildren: () => useChildren, useClasses: () => useClasses, useEffect: () => useEffect, useEvents: () => useEvents, useStyles: () => useStyles, with: () => with_ }); // src/utils/type-guards.js var isNil = (value) => value === null || typeof value === "undefined"; var isUndefined = (value) => typeof value === "undefined"; var isNotNil = (value) => !isNil(value); var isNumeric = (value) => typeof value === "string" && value.trim() !== "" && !isNaN(Number(value)); var isString = (value) => typeof value === "string"; var isNonEmptyString = (value) => isString(value) && value.length > 0; var isEmptyString = (value) => isString(value) && value.length === 0; var isNonEmptyObject = (value) => { return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0; }; var isFunction = (value) => typeof value === "function"; var isArray = (value) => Array.isArray(value); var isEmptyArray = (value) => isArray(value) && value.length === 0; var isNonEmptyArray = (value) => isArray(value) && value.length > 0; var isHTMLElement = (value) => value instanceof HTMLElement; var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value); var isSignal = (obj) => { return isNonEmptyObject(obj) && "value" in obj && "subscribe" in obj && isFunction(obj.subscribe); }; // src/core/config.js var defaultConfig = { componentPath: void 0, debug: false, attributePrefix: "", formattedPrefix: "" }; var config = { ...defaultConfig }; var formatPrefix = (prefix) => { if (isNil(prefix) || !isString(prefix) || isEmptyString(prefix)) return ""; return prefix.endsWith("-") ? prefix : `${prefix}-`; }; var initConfig = (options = {}) => { const normalizedOptions = { ...options }; if ("attributePrefix" in normalizedOptions) { normalizedOptions.formattedPrefix = formatPrefix(normalizedOptions.attributePrefix); } config = { ...defaultConfig, ...normalizedOptions }; }; var getConfig = () => ({ ...config }); // src/utils/logger.js var prefixMessage = (message) => `[HookTML] ${message}`; var logger = { /** * Log a message (only when debug is true) * @param {string} message - The message to log * @param {...any} args - Additional arguments to log */ log: (message, ...args) => { const { debug } = getConfig(); if (debug && isFunction(console.log)) { console.log(prefixMessage(message), ...args); } }, /** * Log an info message (only when debug is true) * @param {string} message - The message to log * @param {...any} args - Additional arguments to log */ info: (message, ...args) => { const { debug } = getConfig(); if (debug && isFunction(console.info)) { console.info(prefixMessage(message), ...args); } }, /** * Log a warning message (always) * @param {string} message - The message to log * @param {...any} args - Additional arguments to log */ warn: (message, ...args) => { if (isFunction(console.warn)) { console.warn(prefixMessage(message), ...args); } }, /** * Log an error message (always) * @param {string} message - The message to log * @param {...any} args - Additional arguments to log */ error: (message, ...args) => { if (isFunction(console.error)) { console.error(prefixMessage(message), ...args); } } }; // src/core/hookRegistry.js var isValidHookName = (name) => { if (isEmptyString(name)) return false; return name.startsWith("use") && name.length > 3; }; var hookRegistry = /* @__PURE__ */ new Map(); var chainableHookRegistry = /* @__PURE__ */ new Map(); var registerHook = (callback) => { if (!isFunction(callback)) { logger.warn("Invalid hook: must be a function"); return false; } const name = callback.name; if (isEmptyString(name)) { logger.warn("Invalid hook: must be a named function"); return false; } if (!isValidHookName(name)) { logger.warn(`Invalid hook name: "${name}". Hook names must start with "use"`); return false; } const isNew = !hookRegistry.has(name); if (isNew) { hookRegistry.set(name, callback); logger.log(`Registered hook: ${name}`); } return isNew; }; var registerChainableHook = (callback) => { if (!isFunction(callback)) { logger.warn("Invalid chainable hook: must be a function"); return false; } const name = callback.name; if (isEmptyString(name)) { logger.warn("Invalid chainable hook: must be a named function"); return false; } if (!isValidHookName(name)) { logger.warn(`Invalid chainable hook name: "${name}". Hook names must start with "use"`); return false; } const isNew = !chainableHookRegistry.has(name); if (isNew) { chainableHookRegistry.set(name, callback); logger.log(`Registered chainable hook: ${name}`); } return isNew; }; var getRegisteredHook = (name) => { if (!isString(name)) return void 0; return hookRegistry.get(name); }; var getRegisteredHooks = () => { return new Map(hookRegistry); }; var getRegisteredChainableHooks = () => { return new Map(chainableHookRegistry); }; // src/utils/try-catch.js var tryCatch = ({ fn, onError, onFinally }) => { try { return fn(); } catch (error) { return onError(error); } finally { if (isNotNil(onFinally)) { onFinally(); } } }; // src/core/hookContext.js var hookContextStack = []; var componentCleanups = /* @__PURE__ */ new WeakMap(); var effectSubscriptions = /* @__PURE__ */ new WeakMap(); var effectOrder = /* @__PURE__ */ new WeakMap(); var effectCleanups = /* @__PURE__ */ new WeakMap(); var initializedEffects = /* @__PURE__ */ new WeakMap(); var createHookContext = (element) => { const context = { element, effectQueue: [], cleanups: [] }; const existingCleanups = componentCleanups.get(element) || []; componentCleanups.set(element, existingCleanups); effectOrder.set(element, 0); return context; }; var getCurrentContext = () => { return hookContextStack.length > 0 ? hookContextStack[hookContextStack.length - 1] : null; }; var executeEffect = (effectFn, element, order) => { let cleanup; tryCatch({ fn: () => { let elementCleanups = effectCleanups.get(element); if (!elementCleanups) { elementCleanups = /* @__PURE__ */ new Map(); effectCleanups.set(element, elementCleanups); } const existingCleanup = elementCleanups.get(order); if (isFunction(existingCleanup)) { runCleanup(existingCleanup); } cleanup = effectFn(); if (isFunction(cleanup)) { elementCleanups.set(order, cleanup); } let elementInitialized = initializedEffects.get(element); if (!elementInitialized) { elementInitialized = /* @__PURE__ */ new Set(); initializedEffects.set(element, elementInitialized); } elementInitialized.add(order); }, onError: (error) => { logger.error("Error in effect execution:", error); } }); return cleanup; }; var executeEffectQueue = (context) => { const { element, effectQueue } = context; if (isEmptyArray(effectQueue)) { return; } logger.log(`Executing ${effectQueue.length} effect(s) for element:`, element); let elementInitialized = initializedEffects.get(element); if (!elementInitialized) { elementInitialized = /* @__PURE__ */ new Set(); initializedEffects.set(element, elementInitialized); } effectQueue.forEach((effect, index) => { if (!elementInitialized.has(index)) { executeEffect(effect, element, index); } }); effectQueue.length = 0; effectOrder.set(element, 0); }; var withHookContext = (element, callback) => { const context = createHookContext(element); hookContextStack.push(context); return tryCatch({ fn: () => { const result = callback(); executeEffectQueue(context); return result; }, onError: (error) => { logger.error("Error in withHookContext:", error); return null; }, onFinally: () => { hookContextStack.pop(); } }); }; var runCleanup = (cleanup) => { if (!isFunction(cleanup)) return; tryCatch({ fn: cleanup, onError: (error) => { logger.error("Error in effect cleanup:", error); } }); }; var useEffect = (setupFn, dependencies) => { const context = getCurrentContext(); if (!context) { logger.warn("useEffect called outside component/directive context"); return; } if (isNil(dependencies)) { throw new Error("[HookTML] useEffect requires a dependencies array. For one-time effects, use an empty array []."); } if (!isArray(dependencies)) { throw new Error("[HookTML] useEffect dependencies must be an array."); } const { element } = context; const currentOrder = effectOrder.get(element) || 0; effectOrder.set(element, currentOrder + 1); if (isNonEmptyArray(dependencies)) { const nonSignalDeps = dependencies.filter((dep) => !isSignal(dep) && !isNil(dep)); if (!isEmptyArray(nonSignalDeps)) { const { debug } = getConfig(); const formatValue = (val) => isObject(val) ? JSON.stringify(val).slice(0, 50) : String(val); const debugInfo = debug ? ` Non-reactive values: ${nonSignalDeps.map(formatValue).join(", ")}` : ""; logger.warn( `useEffect dependency array contains ${nonSignalDeps.length} non-signal value(s) that won't trigger re-runs. To make values reactive, convert them to signals with signal().${debugInfo}` ); } } const effectWrapper = () => { let elementSubs = effectSubscriptions.get(element); if (!elementSubs) { elementSubs = /* @__PURE__ */ new Map(); effectSubscriptions.set(element, elementSubs); } let effectSubs = elementSubs.get(currentOrder); if (!effectSubs) { effectSubs = /* @__PURE__ */ new Set(); elementSubs.set(currentOrder, effectSubs); } effectSubs.forEach((unsub) => unsub()); effectSubs.clear(); dependencies.forEach((dep) => { if (isSignal(dep)) { const unsubscribe = dep.subscribe(() => { runEffect(); }); effectSubs.add(unsubscribe); } }); const runEffect = () => { let elementCleanups = effectCleanups.get(element); if (!elementCleanups) { elementCleanups = /* @__PURE__ */ new Map(); effectCleanups.set(element, elementCleanups); } const existingCleanup = elementCleanups.get(currentOrder); if (isFunction(existingCleanup)) { runCleanup(existingCleanup); } const cleanup = setupFn(); if (isFunction(cleanup)) { elementCleanups.set(currentOrder, cleanup); } return cleanup; }; return runEffect(); }; context.effectQueue.push(effectWrapper); }; var runCleanupFunctions = (element) => { const cleanups = componentCleanups.get(element); let hasCleanups = false; if (isNotNil(cleanups) && isNonEmptyArray(cleanups)) { cleanups.forEach((cleanup) => { runCleanup(cleanup); }); componentCleanups.delete(element); hasCleanups = true; } const elementSubs = effectSubscriptions.get(element); if (elementSubs) { elementSubs.forEach((effectSubs) => { effectSubs.forEach((unsub) => unsub()); effectSubs.clear(); }); effectSubscriptions.delete(element); hasCleanups = true; } const elementEffectCleanups = effectCleanups.get(element); if (elementEffectCleanups) { elementEffectCleanups.forEach((cleanup) => { runCleanup(cleanup); }); effectCleanups.delete(element); hasCleanups = true; } effectOrder.delete(element); initializedEffects.delete(element); return hasCleanups; }; // src/utils/strings.js var kebabToCamel = (str) => { return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); }; var camelToKebab = (str) => { return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); }; var pluralize = (str) => { if (/[^aeiou]y$/.test(str)) { return str.slice(0, -1) + "ies"; } if (/(s|x|z|ch|sh)$/.test(str)) { return str + "es"; } return str + "s"; }; // src/hooks/useChildren.js var useChildren = (element, prefix) => { if (!isHTMLElement(element)) { throw new Error("[HookTML] useChildren requires an HTMLElement as first argument"); } if (isNil(prefix) || !isNonEmptyString(prefix)) { throw new Error("[HookTML] useChildren requires a non-empty string prefix as second argument"); } const useHookSelector = `[use-${prefix}]`; const prefixWithHyphen = `${prefix}-`; const children = {}; const elementsByKey = {}; const all = element.getElementsByTagName("*"); for (let i = 0; i < all.length; i++) { const el = all[i]; if (!isHTMLElement(el)) continue; let hasMatchingAttr = false; const matchingAttrs = []; for (let j = 0; j < el.attributes.length; j++) { const attr = el.attributes[j]; if (attr.name.startsWith(prefixWithHyphen)) { hasMatchingAttr = true; matchingAttrs.push(attr); } } if (!hasMatchingAttr) continue; const closestHook = el.closest(useHookSelector); if (closestHook && closestHook !== element) continue; for (let k = 0; k < matchingAttrs.length; k++) { const attr = matchingAttrs[k]; const suffix = attr.name.slice(prefixWithHyphen.length); const key = kebabToCamel(suffix); if (!isArray(elementsByKey[key])) { elementsByKey[key] = []; } elementsByKey[key].push(el); } } Object.keys(elementsByKey).forEach((key) => { const elements = elementsByKey[key]; const pluralKey = pluralize(key); children[key] = elements[0]; children[pluralKey] = elements; }); return children; }; // src/hooks/useEvents.js var useEvents = (element, eventMap) => { if (!isHTMLElement(element)) { throw new Error("[HookTML] useEvents requires an HTMLElement as first argument"); } if (!isNonEmptyObject(eventMap)) { throw new Error("[HookTML] useEvents requires a non-empty object mapping event names to listeners"); } const signalDeps = Object.values(eventMap).filter(isSignal); const currentHandlers = /* @__PURE__ */ new Map(); const updateEventListeners = () => { currentHandlers.forEach((handler, eventName) => { element.removeEventListener(eventName, handler); }); currentHandlers.clear(); Object.entries(eventMap).forEach(([eventName, handlerOrSignal]) => { const handler = isSignal(handlerOrSignal) ? handlerOrSignal.value : handlerOrSignal; if (!isFunction(handler)) { logger.warn(`Event handler for '${eventName}' is not a function, skipping`); return; } element.addEventListener(eventName, handler); currentHandlers.set(eventName, handler); }); }; updateEventListeners(); if (signalDeps.length > 0) { useEffect(() => { updateEventListeners(); }, signalDeps); } return () => { currentHandlers.forEach((handler, eventName) => { element.removeEventListener(eventName, handler); }); currentHandlers.clear(); }; }; // src/hooks/useClasses.js var useClasses = (element, classMap) => { if (!isHTMLElement(element)) { throw new Error("[HookTML] useClasses requires an HTMLElement as first argument"); } if (!isNonEmptyObject(classMap)) { throw new Error("[HookTML] useClasses requires a non-empty object mapping class names to boolean conditions"); } const signalDeps = Object.values(classMap).filter(isSignal); const addedClasses = /* @__PURE__ */ new Set(); const updateClasses = () => { addedClasses.forEach((className) => { element.classList.remove(className); }); addedClasses.clear(); Object.entries(classMap).forEach(([className, condition]) => { const isActive = isSignal(condition) ? condition.value : Boolean(condition); if (isActive) { element.classList.add(className); addedClasses.add(className); } }); }; updateClasses(); if (isNonEmptyArray(signalDeps)) { useEffect(() => { updateClasses(); }, signalDeps); } return () => { addedClasses.forEach((className) => { element.classList.remove(className); }); addedClasses.clear(); }; }; // src/hooks/useAttributes.js var useAttributes = (element, attrMap) => { if (!isHTMLElement(element)) { throw new Error("[HookTML] useAttributes requires an HTMLElement as first argument"); } if (!isNonEmptyObject(attrMap)) { throw new Error("[HookTML] useAttributes requires a non-empty object mapping attribute names to values"); } const signalDeps = Object.values(attrMap).filter(isSignal); const modifiedAttributes = /* @__PURE__ */ new Map(); const applyAttributes = () => { Object.entries(attrMap).forEach(([attrName, valueOrSignal]) => { if (!modifiedAttributes.has(attrName)) { modifiedAttributes.set( attrName, element.hasAttribute(attrName) ? element.getAttribute(attrName) : null ); } const value = isSignal(valueOrSignal) ? valueOrSignal.value : valueOrSignal; if (isNil(value)) { element.removeAttribute(attrName); } else { element.setAttribute(attrName, value); } }); }; applyAttributes(); if (isNonEmptyArray(signalDeps)) { tryCatch({ fn: () => { useEffect(() => { applyAttributes(); }, signalDeps); }, onError: (error) => { logger.error("Error in useAttributes:", error); const unsubscribes = signalDeps.map((signal2) => { return isSignal(signal2) ? signal2.subscribe(() => applyAttributes()) : null; }).filter(isNotNil); const originalCleanup = modifiedAttributes.get("__cleanup"); modifiedAttributes.set("__cleanup", () => { unsubscribes.forEach((unsub) => unsub()); if (isFunction(originalCleanup)) originalCleanup(); }); } }); } return () => { const cleanup = modifiedAttributes.get("__cleanup"); if (isFunction(cleanup)) cleanup(); modifiedAttributes.forEach((originalValue, attrName) => { if (attrName === "__cleanup") return; if (isNil(originalValue)) { element.removeAttribute(attrName); } else { element.setAttribute(attrName, originalValue); } }); modifiedAttributes.clear(); }; }; // src/hooks/useStyles.js var useStyles = (element, styleMap) => { if (!isHTMLElement(element)) { throw new Error("[HookTML] useStyles requires an HTMLElement as first argument"); } if (!isNonEmptyObject(styleMap)) { throw new Error("[HookTML] useStyles requires a non-empty object mapping style properties to values"); } const signalDeps = Object.values(styleMap).filter(isSignal); const modifiedStyles = /* @__PURE__ */ new Map(); const applyStyles = () => { Object.entries(styleMap).forEach(([prop, valueOrSignal]) => { const cssProp = prop.includes("-") ? kebabToCamel(prop) : prop; if (!modifiedStyles.has(cssProp)) { modifiedStyles.set(cssProp, element.style[cssProp]); } const value = isSignal(valueOrSignal) ? valueOrSignal.value : valueOrSignal; element.style[cssProp] = value; }); }; applyStyles(); if (isNonEmptyArray(signalDeps)) { tryCatch({ fn: () => { useEffect(() => { applyStyles(); }, signalDeps); }, onError: (error) => { logger.error("Error in useStyles:", error); const unsubscribes = signalDeps.map((signal2) => signal2.subscribe(() => applyStyles())); const originalCleanup = modifiedStyles.get("__cleanup"); modifiedStyles.set("__cleanup", () => { unsubscribes.forEach((unsub) => unsub()); if (isFunction(originalCleanup)) originalCleanup(); }); } }); } return () => { const cleanup = modifiedStyles.get("__cleanup"); if (isFunction(cleanup)) cleanup(); modifiedStyles.forEach((originalValue, prop) => { if (prop === "__cleanup") return; element.style[prop] = originalValue; }); modifiedStyles.clear(); }; }; // src/core/with.js var with_ = (element) => { if (!isHTMLElement(element)) { throw new Error("[HookTML] with(el) requires an HTMLElement as argument"); } const chain = { /** * Apply event listeners to the element * @param {Record<string, EventListener>} eventMap - Object mapping event names to handlers * @returns {WithChain} The chainable object for further operations */ useEvents: (eventMap) => { useEvents(element, eventMap); return chain; }, /** * Apply conditional classes to the element * @param {Record<string, boolean>} classMap - Object mapping class names to boolean conditions * @returns {WithChain} The chainable object for further operations */ useClasses: (classMap) => { useClasses(element, classMap); return chain; }, /** * Set HTML attributes on the element * @param {Record<string, string|null>} attrMap - Object mapping attribute names to values * @returns {WithChain} The chainable object for further operations */ useAttributes: (attrMap) => { useAttributes(element, attrMap); return chain; }, /** * Apply inline styles to the element * @param {Partial<CSSStyleDeclaration>} styleMap - Object mapping style properties to values * @returns {WithChain} The chainable object for further operations */ useStyles: (styleMap) => { useStyles(element, styleMap); return chain; } }; const registeredChainableHooks = getRegisteredChainableHooks(); registeredChainableHooks.forEach((hookFn, hookName) => { if (isFunction(hookFn) && !chain[hookName]) { chain[hookName] = (...args) => { hookFn(element, ...args); return chain; }; } }); return chain; }; // src/core/registry.js var componentRegistry = /* @__PURE__ */ new Map(); var isValidComponentName = (name) => { if (isEmptyString(name)) return false; return /^[A-Z][A-Za-z0-9]*$/.test(name); }; var registerComponent = (callback) => { if (!isFunction(callback)) { logger.warn("Invalid component: must be a function"); return false; } const name = callback.name; if (!isValidComponentName(name)) { logger.warn(`Invalid component name: "${name}". Must be a non-empty PascalCase name`); return false; } const isNew = !componentRegistry.has(name); if (isNew) { componentRegistry.set(name, callback); logger.log(`Registered component: ${name}`); } return isNew; }; var getRegisteredComponentNames = () => { return Array.from(componentRegistry.keys()); }; var getRegisteredComponent = (name) => { if (!isString(name)) return void 0; return componentRegistry.get(name); }; // src/core/stateManager.js var StateManager = class { constructor() { this.stateRegistry = /* @__PURE__ */ new WeakMap(); } /** * Marks an element as initialized * @param {HTMLElement} element - The DOM element * @throws {Error} If element is not an HTMLElement */ markInitialized(element) { if (!isHTMLElement(element)) { throw new Error("[HookTML] markInitialized requires an HTMLElement"); } const state = this.getOrCreateState(element); state.initialized = true; } /** * Marks a directive as initialized for an element * @param {HTMLElement} element - The DOM element * @param {string} directiveName - The name of the directive * @throws {Error} If element is not an HTMLElement */ markDirectiveInitialized(element, directiveName) { if (!isHTMLElement(element)) { throw new Error("[HookTML] markDirectiveInitialized requires an HTMLElement"); } if (!directiveName) { throw new Error("[HookTML] directiveName is required"); } const state = this.getOrCreateState(element); if (!state.initializedDirectives.includes(directiveName)) { state.initializedDirectives.push(directiveName); } } /** * Checks if an element is initialized * @param {HTMLElement} element - The DOM element * @returns {boolean} Whether the element is initialized */ isInitialized(element) { if (!isHTMLElement(element)) { return false; } const state = this.stateRegistry.get(element); return state?.initialized ?? false; } /** * Checks if a directive is initialized for an element * @param {HTMLElement} element - The DOM element * @param {string} directiveName - The name of the directive * @returns {boolean} Whether the directive is initialized */ isDirectiveInitialized(element, directiveName) { if (!isHTMLElement(element) || !directiveName) { return false; } const state = this.stateRegistry.get(element); return state?.initializedDirectives.includes(directiveName) ?? false; } /** * Gets all initialized directives for an element * @param {HTMLElement} element - The DOM element * @returns {string[]} Array of initialized directive names */ getInitializedDirectives(element) { if (!isHTMLElement(element)) { return []; } const state = this.stateRegistry.get(element); return state?.initializedDirectives ?? []; } /** * Clears all state for an element * @param {HTMLElement} element - The DOM element */ clearState(element) { if (!isHTMLElement(element)) { return; } this.stateRegistry.delete(element); } /** * Gets or creates state for an element * @private * @param {HTMLElement} element - The DOM element * @returns {ElementState} The element's state */ getOrCreateState(element) { let state = this.stateRegistry.get(element); if (!state) { state = { initialized: false, initializedDirectives: [] }; this.stateRegistry.set(element, state); } return state; } }; // src/core/lifecycleManager.js var LifecycleManager = class { constructor() { this.teardownRegistry = /* @__PURE__ */ new WeakMap(); this.stateManager = new StateManager(); } /** * Registers a component and marks it as initialized * @param {HTMLElement} element - The DOM element * @param {Function} teardown - The teardown function * @returns {boolean} Whether registration was successful */ registerComponent(element, teardown) { if (!isHTMLElement(element)) { throw new Error("[HookTML] registerComponent requires an HTMLElement"); } if (!isFunction(teardown)) { return false; } let registration = this.teardownRegistry.get(element); if (!registration) { registration = { component: void 0, directives: [] }; this.teardownRegistry.set(element, registration); } registration.component = teardown; this.stateManager.markInitialized(element); return true; } /** * Registers a directive and marks it as initialized * @param {HTMLElement} element - The DOM element * @param {Function} teardown - The teardown function * @param {string} directiveName - The name of the directive * @returns {boolean} Whether registration was successful */ registerDirective(element, teardown, directiveName) { if (!isHTMLElement(element)) { throw new Error("[HookTML] registerDirective requires an HTMLElement"); } if (!isFunction(teardown)) { return false; } if (!directiveName) { throw new Error("[HookTML] directiveName is required"); } let registration = this.teardownRegistry.get(element); if (!registration) { registration = { component: void 0, directives: [] }; this.teardownRegistry.set(element, registration); } registration.directives.push(teardown); this.stateManager.markDirectiveInitialized(element, directiveName); return true; } /** * Gets the component teardown function for an element * @param {HTMLElement} element - The DOM element * @returns {Function | undefined} The teardown function if it exists */ getComponentTeardown(element) { if (!isHTMLElement(element)) { throw new Error("[HookTML] getComponentTeardown requires an HTMLElement"); } const registration = this.teardownRegistry.get(element); return registration?.component; } /** * Gets the directive teardown functions for an element * @param {HTMLElement} element - The DOM element * @returns {Function[]} Array of teardown functions */ getDirectiveTeardowns(element) { if (!isHTMLElement(element)) { throw new Error("[HookTML] getDirectiveTeardowns requires an HTMLElement"); } const registration = this.teardownRegistry.get(element); return registration?.directives ?? []; } /** * Gets all teardown functions for an element * @param {HTMLElement} element - The DOM element * @returns {Registration} Object containing component and directive teardowns */ getTeardowns(element) { if (!isHTMLElement(element)) { throw new Error("[HookTML] getTeardowns requires an HTMLElement"); } return this.teardownRegistry.get(element) ?? { component: void 0, directives: [] }; } /** * Checks if an element has any registered teardown functions * @param {HTMLElement} element - The DOM element * @returns {boolean} Whether the element has any teardown functions */ hasRegistration(element) { if (!isHTMLElement(element)) return false; const registration = this.teardownRegistry.get(element); return isNotNil(registration) && (isFunction(registration.component) || isNonEmptyArray(registration.directives)); } /** * Executes teardown for a component * @param {HTMLElement} element - The DOM element * @returns {TeardownResult} The result of the teardown operation */ executeComponentTeardown(element) { if (!isHTMLElement(element)) { throw new Error("[HookTML] executeComponentTeardown requires an HTMLElement"); } const registration = this.teardownRegistry.get(element); if (!registration?.component) { return { success: true, error: void 0 }; } const teardown = registration.component; return tryCatch({ fn: () => { teardown(); registration.component = void 0; return { success: true, error: void 0 }; }, onError: (error) => { logger.error("Error in component teardown:", error); return { success: false, error }; } }); } /** * Executes all directive teardowns for an element * @param {HTMLElement} element - The DOM element * @returns {TeardownResult[]} Array of results for each teardown operation */ executeDirectiveTeardowns(element) { if (!isHTMLElement(element)) { throw new Error("[HookTML] executeDirectiveTeardowns requires an HTMLElement"); } const registration = this.teardownRegistry.get(element); if (!registration?.directives.length) { return []; } const results = registration.directives.map( (teardown) => tryCatch({ fn: () => { teardown(); return { success: true, error: void 0 }; }, onError: (error) => { logger.error("Error in directive teardown:", error); return { success: false, error }; } }) ); registration.directives = []; return results; } /** * Executes all teardowns for an element and removes its registration * @param {HTMLElement} element - The DOM element * @returns {{ component: TeardownResult, directives: TeardownResult[] }} Results of all teardown operations */ executeTeardowns(element) { if (!isHTMLElement(element)) { throw new Error("[HookTML] executeTeardowns requires an HTMLElement"); } const componentResult = this.executeComponentTeardown(element); const directiveResults = this.executeDirectiveTeardowns(element); this.teardownRegistry.delete(element); this.stateManager.clearState(element); return { component: componentResult, directives: directiveResults }; } /** * Checks if an element is initialized * @param {HTMLElement} element - The DOM element * @returns {boolean} Whether the element is initialized */ isInitialized(element) { return this.stateManager.isInitialized(element); } /** * Checks if a directive is initialized for an element * @param {HTMLElement} element - The DOM element * @param {string} directiveName - The name of the directive * @returns {boolean} Whether the directive is initialized */ isDirectiveInitialized(element, directiveName) { return this.stateManager.isDirectiveInitialized(element, directiveName); } /** * Gets all initialized directives for an element * @param {HTMLElement} element - The DOM element * @returns {string[]} Array of initialized directive names */ getInitializedDirectives(element) { return this.stateManager.getInitializedDirectives(element); } /** * Marks an element as initialized (convenience method) * @param {HTMLElement} element - The DOM element */ markInitialized(element) { this.stateManager.markInitialized(element); } /** * Clears all state for an element (convenience method) * @param {HTMLElement} element - The DOM element */ clearState(element) { this.stateManager.clearState(element); } }; // src/core/initialization.js var lifecycleManager = new LifecycleManager(); var markInitialized = (element) => { if (isHTMLElement(element)) { lifecycleManager.markInitialized(element); } }; // src/utils/children.js var hasSameComponent = (element, componentName) => { const { formattedPrefix } = getConfig(); return element.classList.contains(componentName) || isHTMLElement(element) && element.getAttribute(`${formattedPrefix}use-component`) === componentName; }; var addPluralizedChild = (children, key, child) => { const pluralKey = pluralize(key); if (pluralKey in children) { if (isArray(children[pluralKey])) { children[pluralKey].push(child); } } else { children[pluralKey] = [ /** @type {Element} */ children[key], child ]; } }; var extractChildren = (element, componentName) => { const { formattedPrefix } = getConfig(); const prefix = `${formattedPrefix}${componentName.toLowerCase()}-`; const children = {}; const descendants = Array.from(element.getElementsByTagName("*")); descendants.some((child) => { if (hasSameComponent(child, componentName)) { return true; } Array.from(child.attributes).forEach(({ name }) => { if (name.startsWith(prefix)) { const key = kebabToCamel(name.slice(prefix.length)); if (children[key]) { addPluralizedChild(children, key, child); } else { children[key] = child; } } }); return false; }); return children; }; // src/utils/props.js var coerceValue = (value) => { if (value === "true") return true; if (value === "false") return false; if (value === "null") return null; if (isNumeric(value)) return Number(value); return value; }; var extractProps = (element, componentName) => { const { formattedPrefix } = getConfig(); const prefix = `${formattedPrefix}${componentName.toLowerCase()}-`; const props = {}; Array.from(element.attributes).forEach(({ name, value }) => { if (name.startsWith(prefix)) { const propName = kebabToCamel(name.slice(prefix.length)); props[propName] = coerceValue(value); } }); const children = extractChildren(element, componentName); if (Object.keys(children).length > 0) { props.children = children; } return props; }; // src/core/componentLifecycle.js var removeCloak = (element) => { if (!isHTMLElement(element)) { throw new Error("[HookTML] removeCloak requires an HTMLElement"); } element.removeAttribute("data-hooktml-cloak"); }; // src/core/styleInjection.js var STYLE_TAG_ID = "__hooktml"; var CLOAK_RULE = "[data-hooktml-cloak] { visibility: hidden; }"; var getStyleTag = () => { const styleTag = document.getElementById(STYLE_TAG_ID); if (styleTag instanceof HTMLStyleElement) { return styleTag; } const initStyleTag = document.createElement("style"); initStyleTag.id = STYLE_TAG_ID; initStyleTag.textContent = CLOAK_RULE; document.head.appendChild(initStyleTag); return initStyleTag; }; var getProperty = (component) => { const mode = getConfig().componentSelectorMode; return mode === "class" ? `.${component.name}` : `[data-component="${component.name}"]`; }; var minifyCss = (css) => { if (!css) return ""; return css.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\s*([{};:,])\s*/g, "$1").replace(/\s+/g, " ").trim(); }; var getComparisonKey = (rule) => { return minifyCss(rule); }; var toCssRule = (component) => { const styles = component.styles?.trim() ?? ""; const property = getProperty(component); return `${property} { ${styles} }`; }; var ruleExists = (sheet, ruleText) => { const newRuleKey = getComparisonKey(ruleText); return Array.from(sheet.cssRules).some((rule) => { const existingRuleKey = getComparisonKey(rule.cssText); return existingRuleKey === newRuleKey; }); }; var injectComponentStyles = (component, element) => { const { debug } = getConfig(); if (!isHTMLElement(element)) { throw new Error("[HookTML] injectComponentStyles requires an HTMLElement as second argument"); } if (isNotNil(component.styles) && !isString(component.styles)) { if (debug) { logger.warn( `Component "${component.name}" has non-string styles property (type: ${typeof component.styles}). Styles must be a string.`, component ); } removeCloak(element); return; } if (isNil(component.styles) || isEmptyString(component.styles)) { removeCloak(element); return; } const tag = getStyleTag(); const sheet = tag.sheet; const rule = toCssRule(component); if (isNotNil(sheet)) { const isDuplicate = ruleExists(sheet, rule); if (isDuplicate) { logger.warn( `Duplicate style injection skipped for component "${component.name}". Styles already present in stylesheet.`, element ); } else { sheet.insertRule(rule, sheet.cssRules.length); logger.log(`Injected styles for component "${component.name}"`); } } removeCloak(element); }; // src/core/scanComponents.js var createClassSelector = (componentNames) => { return componentNames.map((name) => `.${name}`).join(", "); }; var createUseComponentSelector = (componentNames, prefix = "") => { return componentNames.map((name) => `[${prefix}use-component="${name}"]`).join(", "); }; var getComponentNameFromElement = (element, componentNames, prefix = "") => { const classList = Array.from(element.classList); const classMatch = classList.find((className) => componentNames.includes(className)); if (isNotNil(classMatch)) { return classMatch; } const useComponentAttr = element.getAttribute(`${prefix}use-component`); if (isNotNil(useComponentAttr) && componentNames.includes(useComponentAttr)) { return useComponentAttr; } return null; }; var scanComponents = () => { logger.log("Scanning for components..."); const componentNames = getRegisteredComponentNames(); const { formattedPrefix } = getConfig(); if (!componentNames.length) { logger.log("No components registered yet"); return []; } const classSelector = createClassSelector(componentNames); const useComponentSelector = createUseComponentSelector(componentNames, formattedPrefix); const selector = `${classSelector}, ${useComponentSelector}`; logger.log(`Scanning DOM with selector: "${selector}"`); const elements = Array.from(document.querySelectorAll(selector)); return elements.map((element) => { const componentName = getComponentNameFromElement(element, componentNames, formattedPrefix); return isNotNil(componentName) ? { element, componentName } : null; }).filter(isNotNil); }; var initializeComponents = (components) => { if (isNil(components) || isEmptyArray(components)) { logger.log("No components to initialize"); return []; } logger.log(`Initializing ${components.length} component(s)...`); const instances = components.map(({ element, componentName }) => { if (lifecycleManager.isInitialized(element)) { logger.log(`Skipping already initialized component: ${componentName}`); return null; } const componentFn = getRegisteredComponent(componentName); if (isNil(componentFn)) { logger.warn(`No registered function found for component: ${componentName}`); return null; } return tryCatch({ fn: () => { logger.log(`Initializing component: ${componentName}`); const props = extractProps(element, componentName); const result = withHookContext(element, () => { return componentFn(element, props); }); if (result === null) { return null; } if (isFunction(result)) { lifecycleManager.registerComponent(element, result); } else if (isObject(result)) { if (isNotNil(result.context)) { Object.defineProperty(element, "component", { value: result.context, writable: true, configurable: true }); } if (isFunction(result.cleanup)) { lifecycleManager.registerComponent(element, result.cleanup); } } injectComponentStyles(componentFn, element); markInitialized(element); return { element, componentName, instance: result }; }, onError: (error) => { logger.error(`Error initializing component ${componentName}:`, error); return null; } }); }).filter(isNotNil); logger.log(`Successfully initialized ${instances.length} component(s)`); return instances; }; // src/core/hookInstanceRegistry.js var hookInstanceRegistry = /* @__PURE__ */ new WeakMap(); var getHookInstance = (element, hookName) => { if (!isHTMLElement(element)) return void 0; const elementHooks = hookInstanceRegistry.get(element); if (!elementHooks) return void 0; return elementHooks.get(hookName); }; var storeHookInstance = (element, hookName, instance) => { if (!isHTMLElement(element)) return; let elementHooks = hookInstanceRegistry.get(element); if (!elementHooks) { elementHooks = /* @__PURE__ */ new Map(); hookInstanceRegistry.set(element, elementHooks); } elementHooks.set(hookName, instance); logger.log(`Stored hook instance for "${hookName}" on element:`, element); }; var clearHookInstances = (element) => { if (!isHTMLElement(element)) return; hookInstanceRegistry.delete(element); }; // src/core/scanDirectives.js var createHookSelector = (hookNames, prefix = "") => { if (!hookNames.length) return ""; const attributeNames = hookNames.map((name) => `[${prefix}${camelToKebab(name)}]`); return attributeNames.join(", "); }; var 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 })); }; var processElementHooks = (element) => { const hasTeardowns = lifecycleManager.hasRegistration(element); if (hasTeardowns) { logger.log("\u23ED\uFE0F Skipping already processed element", element); return; } const { formattedPrefix } = getConfig(); const hookAttributes = getHookAttributesFromElement(element, formattedPrefix); logger.log(`Processing element with ${hookAttributes.length} hook(s):`, element); hookAttributes.forEach(({ name, originalName, value }) => { 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); const existingInstance = getHookInstance(element, hookName); if (existingInstance) { logger.log(`Using existing instance for hook "${hookName}"`); return; } const parsedValue = isEmptyString(value) ? true : coerceValue(value); if (isNotNil(value)) { logger.log(`Passing value to hook "${hookName}":`, parsedValue); } const props = {}; if (isNonEmptyString(value)) { props.value = parsedValue; } const resultRef = { current: void 0 }; tryCatch({ fn: () => { logger.log(`Calling hook function for "${hookName}" with hook context`); resultRef.current = withHookContext(element, () => { const instance = hookFn(element, props); 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 (isFunction(resultRef.current)) { logger.log(`Storing teardown function for hook "${hookName}" on element:`, element); lifecycleManager.registerDirective(element, resultRef.current, hookName); const verifyTeardowns = lifecycleManager.hasRegistration(element); logger.log(`\u2705 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); } }); }; var scanDirectives = () => { const hooks