import type { HeadlessController, HeadlessControllerClass } from '@42/core'; import { registry } from './registry'; export { registry } from './registry'; /** Attribute used to mark an element whose controller is already mounted. */ const MOUNTED_ATTR = 'data-c42-mounted'; /** Handle returned by {@link mount}. */ export interface BladeApp { /** Re-scan the root and mount any newly-added, not-yet-mounted markup. */ refresh(): void; /** Disconnect the observer and destroy every controller this app mounted. */ unmount(): void; } /** A DOM root that can be scanned for `data-c42-*` markup. */ export type MountRoot = Document | HTMLElement; function isElement(node: Node): node is Element { return node.nodeType === 1; } /** Collect `node` itself (if it matches) plus all descendant matches. */ function collectMatches(node: Node, selector: string): HTMLElement[] { const found: HTMLElement[] = []; if (node instanceof HTMLElement && node.matches(selector)) { found.push(node); } if (isElement(node) || node instanceof Document) { for (const el of node.querySelectorAll(selector)) { found.push(el); } } return found; } /** * Auto-mount every `@42/core` controller in the {@link registry} onto matching * `data-c42-*` markup found under `root`. * * A `MutationObserver` keeps the tree in sync: nodes added later are mounted and * removed nodes have their controllers destroyed. Mounting is idempotent — an * element already carrying `data-c42-mounted` is never wrapped twice — so * calling `mount()` again or invoking `refresh()` is safe. * * @example * import { mount } from '@42/blade'; * const app = mount(); * // later: app.refresh() / app.unmount() */ export function mount(root: MountRoot = document): BladeApp { const instances = new Map(); const entries = Object.entries(registry); function mountElement(el: HTMLElement, Controller: HeadlessControllerClass): void { if (instances.has(el) || el.hasAttribute(MOUNTED_ATTR)) { return; } let instance: HeadlessController; try { instance = new Controller(el); } catch (error) { // A single malformed element must not abort the whole mount pass. console.warn('[42/blade] failed to mount element', el, error); return; } el.setAttribute(MOUNTED_ATTR, ''); instances.set(el, instance); } function mountTree(node: Node): void { for (const [selector, Controller] of entries) { for (const el of collectMatches(node, selector)) { mountElement(el, Controller); } } } function destroyElement(el: Element): void { const instance = instances.get(el); if (!instance) { return; } try { instance.destroy(); } catch (error) { console.warn('[42/blade] failed to destroy element', el, error); } instances.delete(el); el.removeAttribute(MOUNTED_ATTR); } function unmountTree(node: Node): void { if (!isElement(node)) { return; } // Destroy the removed node itself and any tracked controllers inside it. const removed = [node, ...instances.keys()].filter((el) => el === node || node.contains(el)); for (const el of removed) { destroyElement(el); } } mountTree(root); const target = root instanceof Document ? root.documentElement : root; const observer = new MutationObserver((records) => { for (const record of records) { record.removedNodes.forEach((node) => unmountTree(node)); record.addedNodes.forEach((node) => mountTree(node)); } }); observer.observe(target, { childList: true, subtree: true }); return { refresh(): void { mountTree(root); }, unmount(): void { observer.disconnect(); for (const el of [...instances.keys()]) { destroyElement(el); } instances.clear(); }, }; }