hooktml
Version:
A reactive HTML component library with hooks-based lifecycle management
1,405 lines (1,380 loc) • 64.1 kB
JavaScript
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