Skip to content

Source: lib/dom/DomSmith.js

/**
 * Returns an array of supported DOM event names for the given tag.
 * @private
 * @memberof module:lib/dom/DomSmith
 * @param   {HTMLElement} [ele]  The element for which to retrieve supported events.
 * @returns {string[]}           Sorted array of event names.
 */
function getSupportedDomEvents(ele = document.createElement('div')) {

    const events = [];
    for (const key in ele) {
        if (key.startsWith('on')) events.push(key.slice(2));
    }
    return events.sort();

}

// Private vars
const plugins = [], // holds all added plugins
      legacyKeys = ['ref', 'tag', 'nodes', 'text', 'events'], // old keys for deprecation warning
      rootProps = ['_dom', '_refs', '_parent', '_events', 'constructor', 'prototype', 'addNode', 'replaceNode', 'removeNode', 'addEvent', 'removeEvent', 'destroy', 'teardown'], // List of reserved root properties.
      defaultEvents = getSupportedDomEvents(); // Pre-calculate supported events for a generic element.

let pluginsSorted = false; // make sure plugins are only sorted once

/**
 * The `DomSmith` is a utility class that helps to dynamically generate a DOM tree
 * based on a declarative configuration. It supports event handling, DOM node creation,
 * manipulation, and removal. Additionally, it ensures proper cleanup by removing events
 * and references when the `destroy()` or `removeNode()` methods are called.
 * This is especially useful for building UI components in a modular way where nodes can
 * be dynamically added, updated, or removed.
 * @exports module:lib/dom/DomSmith
 * @author    Frank Kudermann - alphanull
 * @version   2.1.0
 * @license   MIT
 */
export default class DomSmith {

    /**
     * Creates a new DomSmith instance.
     * @param {module:lib/dom/DomSmith~NodeDefinition|module:lib/dom/DomSmith~NodeDefinition[]|string} nodeDef    Node definition. Can be an object, a string, or an array of definitions.
     * @param {HTMLElement|module:lib/dom/DomSmith~Options}                                            [options]  Either a target HTMLElement or an options object.
     */
    constructor(nodeDef, options) {

        if (!pluginsSorted) {
            pluginsSorted = true;
            plugins.sort((a, b) => b.priority - a.priority);
        }

        /**
         * Stores mount information like target node, insertMode type, parent, and next sibling.
         * @private
         * @type {Object}
         */
        this._mountContext = {};

        if (options instanceof HTMLElement) {
            this._mountContext = {
                ele: options,
                insertMode: 'append'
            };
        } else if (options && typeof options === 'object' && options.ele) {
            this._mountContext = {
                ele: options.ele,
                insertMode: options.insertMode || 'append'
            };
        }

        // Create a synthetic root parent object if a node is given (required by internal logic)
        const rootParent = this._mountContext.ele
            ? {
                _ele: this._mountContext.insertMode && this._mountContext.insertMode !== 'append' && this._mountContext.insertMode !== 'top'
                    ? this._mountContext.ele.parentNode
                    : this._mountContext.ele,
                _nodes: []
            } : null;

        /**
         * Stores additional node data for cleanup.
         * @private
         * @type {WeakMap}
         */
        this._refs = new WeakMap();

        /**
         * Central repository for event listeners.
         * @private
         * @type {WeakMap<HTMLElement, Object>}
         */
        this._events = new WeakMap();

        /**
         * The root node definition or an array of node definitions.
         * @private
         * @type {module:lib/dom/DomSmith~NodeDefinition|module:lib/dom/DomSmith~NodeDefinition[]}
         */
        this._dom = this.addNode(nodeDef, rootParent);

        /**
         * The parent DOM element where the constructed DOM tree is mounted.
         * @private
         * @type {HTMLElement}
         */
        this._mountContext.parent = rootParent && rootParent._ele;

        // If a synthetic root parent was used and the result is an array, update its nodes array.
        if (rootParent && Array.isArray(this._dom)) rootParent._nodes = this._dom;

        // Automatically mount the DOM node(s) if the 'mount' option is true
        if (this._mountContext.ele) this.mount();

    }

    /**
     * Mounts the created DOM element(s) into the specified location.
     * Supports insertModes: 'append' (default), 'before', 'replace' and 'top.
     * If called after unmount() without arguments, reuses the original location.
     * @param  {HTMLElement|HTMLElement|module:lib/dom/DomSmith~Options} [options]  Either a target HTMLElement or an options object.
     * @throws {Error}                                                              If injection node was not found.
     */
    mount(options) {

        if (this._mountContext.mounted) return;

        if (options) {
            if (options instanceof HTMLElement) {
                this._mountContext = {
                    ele: options,
                    insertMode: 'append'
                };
            } else if (typeof options === 'object' && options.ele) {
                this._mountContext = {
                    ele: options.ele,
                    insertMode: options.insertMode || 'append'
                };
            }

            this._mountContext.parent = this._mountContext.insertMode === 'append' || this._mountContext.insertMode === 'top'
                ? this._mountContext.ele
                : this._mountContext.ele.parentNode;
        }

        const { ele, insertMode, parent, nextSibling } = this._mountContext;

        if (!ele || !parent) throw new Error('[DomSmith] Invalid insertMode ele.');

        // Insert mode 'top': Insert as first child of parent (before parent.firstChild)
        if (Array.isArray(this._dom)) {

            // Use fragment for efficient batch insertion
            const fragment = document.createDocumentFragment();
            let hasNodesToMount = false;

            this._dom.forEach(nd => {
                if (!nd._ele.parentNode) {
                    fragment.appendChild(nd._ele);
                    hasNodesToMount = true;
                }
            });

            if (!hasNodesToMount) return; // all nodes already mounted

            if (insertMode === 'top') {
                // Insert fragment as first child of parent
                parent.insertBefore(fragment, parent.firstChild);
            } else if (insertMode === 'before') {
                parent.insertBefore(fragment, ele);
            } else if (insertMode === 'replace') {
                parent.insertBefore(fragment, ele);
                if (parent.contains(ele)) parent.removeChild(ele);
            } else if (nextSibling && nextSibling.parentNode === parent) {
                parent.insertBefore(fragment, nextSibling);
            } else {
                parent.appendChild(fragment);
            }

        } else {
            const el = this._dom._ele;
            if (el.parentNode) return; // already mounted

            // Insert single element using appropriate strategy
            if (insertMode === 'top') {
                // Insert element as first child of parent
                parent.insertBefore(el, parent.firstChild);
            } else if (insertMode === 'before') {
                parent.insertBefore(el, ele);
            } else if (insertMode === 'replace') {
                parent.replaceChild(el, ele);
            } else if (nextSibling && nextSibling.parentNode === parent) {
                parent.insertBefore(el, nextSibling);
            } else {
                parent.appendChild(el);
            }
        }

        plugins.forEach(({ plugin }) => {
            if (typeof plugin.mount === 'function') plugin.mount(this._dom, this._mountContext);
        });

        this._mountContext.mounted = true;

    }

    /**
     * Unmounts the created DOM element(s) from their parent node.
     * For multiple nodes, each child element is removed individually.
     * Also stores the original parent and nextSibling for potential re-mounting.
     */
    unmount() {

        if (!this._mountContext.mounted) return;

        plugins.forEach(({ plugin }) => {
            if (typeof plugin.unmount === 'function') plugin.unmount(this._dom, this._mountContext);
        });

        if (Array.isArray(this._dom)) {
            // Filter all currently mounted elements
            const mounted = this._dom.filter(nd => nd._ele && nd._ele.parentNode);

            if (mounted.length > 0) {
                const parent = mounted[0]._ele.parentNode,
                      allElements = Array.from(parent.childNodes),
                      lastIndex = allElements.indexOf(mounted[mounted.length - 1]._ele),
                      next = allElements[lastIndex + 1] || null;
                // Store precise remount location
                this._mountContext.parent = parent;
                this._mountContext.nextSibling = next;
            }
            // Remove all elements from DOM
            mounted.forEach(nd => nd._ele.remove());

        } else if (this._dom && this._dom._ele && this._dom._ele.parentNode) {

            const el = this._dom._ele;
            this._mountContext.parent = el.parentNode;
            this._mountContext.nextSibling = el.nextSibling;
            el.remove();

        }

        this._mountContext.mounted = false;

    }

    /**
     * Adds a DOM node based on a declarative definition.
     * Supports string, object, or an array of node definitions.
     * @param   {module:lib/dom/DomSmith~NodeDefinition}                                          nodeDefArg  Node definition or an array of definitions.
     * @param   {Object}                                                                          [parent]    Parent object (synthetic) to assign to the newly created node.
     * @returns {module:lib/dom/DomSmith~NodeDefinition|module:lib/dom/DomSmith~NodeDefinition[]}             A node definition object or an array of such objects.
     * @throws  {Error}                                                                                       If trying to add a duplicate ref or a property was not found.
     */
    addNode(nodeDefArg, parent) {

        // Clone nodeDef to avoid mutation
        let nodeDef = typeof nodeDefArg === 'string' || Array.isArray(nodeDefArg)
            ? nodeDefArg
            : { ...nodeDefArg };

        // If an array is provided, return an array of node definitions.
        if (Array.isArray(nodeDef)) {

            const nodes = nodeDef.map(nd => {
                const node = this.addNode(nd, parent);
                if (parent && Array.isArray(parent._nodes)) parent._nodes.push(node);
                return node;
            });

            return nodes;
        }

        if (typeof nodeDef !== 'string') {
            // Migrate legacy properties to underscore-prefixed keys
            legacyKeys.forEach(key => {
                if (key in nodeDef && !(`_${key}` in nodeDef)) {
                    nodeDef[`_${key}`] = nodeDef[key];
                    delete nodeDef[key];
                    // eslint-disable-next-line no-console
                    console.warn(`[DomSmith] Deprecated nodeDef key "${key}" – use "_${key}" instead.`);
                }
            });
        }

        // Helper function: assign a value to a property by path (e.g., 'style.color')
        function assignWithPath(obj, path, value) { // eslint-disable-line jsdoc/require-jsdoc
            const pathArray = path.split('.'),
                  pathObj = pathArray.slice(0, -1).reduce((acc, key) => (typeof acc === 'undefined' || typeof acc[key] === 'undefined' ? null : acc[key]), obj);
            if (typeof pathObj !== 'object' || pathObj === null) throw new Error(`[DomSmith] Did not find property with path: ${path}`);
            pathObj[pathArray.pop()] = value;
        }

        /**
         * Creates Element from node definition.
         * @param   {module:lib/dom/DomSmith~NodeDefinition} nd  The definition to parse.
         * @returns {HTMLElement}                                The newly created element.
         */
        function createEle(nd) {

            if (typeof nd._text !== 'undefined') return document.createTextNode(nd._text);

            // Use SVG namespace if the current node's tag is 'svg' (case-insensitive), or a parent is provided and its element is in the SVG namespace.
            const SVG_NS = 'http://www.w3.org/2000/svg', // Define SVG namespace
                  isParentForeign = parent && parent._ele && parent._ele.tagName.toLowerCase() === 'foreignobject',
                  useSvgNS = !isParentForeign && (nd._tag && nd._tag.toLowerCase() === 'svg' || parent && parent._ele && parent._ele.namespaceURI === SVG_NS);

            if (useSvgNS) return document.createElementNS(SVG_NS, nd._tag);
            return document.createElement(nd._tag || 'div');
        }

        if (typeof nodeDef === 'string' || nodeDef instanceof String) {
            nodeDef = { _text: nodeDef };
        }

        nodeDef._ele = createEle(nodeDef);

        plugins.forEach(({ plugin }) => {
            if (typeof plugin.addNode === 'function') {
                nodeDef = plugin.addNode(nodeDef) || nodeDef;
            }
        });

        if (!nodeDef._ele) nodeDef._ele = createEle(nodeDef);

        const { _nodes, _tag, _ref, _events, _ele } = nodeDef;

        // Process each property of nodeDef
        Object.entries(nodeDef).forEach(([key, value]) => {
            // Skip reserved keys or undefined values
            if (key.startsWith('_') || key.startsWith('$') || value === null || typeof value === 'undefined') return;

            // Determine supported events; extra events for 'video' and 'audio'
            const supportedEvents = _tag === 'video' || _tag === 'audio'
                ? getSupportedDomEvents(_ele)
                : defaultEvents;

            if (supportedEvents.includes(key)) {
                // Add event listener if key is a supported event name
                this.addEvent(_ele, key, value);

            } else if (key.includes('.')) {

                assignWithPath(_ele, key, value);

            } else if (key in _ele) {
                // Directly assign the property if it exists on the element
                try {
                    _ele[key] = value;
                } catch (e) { // eslint-disable-line no-unused-vars
                    // If direct assignment fails (e.g., property is readonly, as with some SVG elements), fallback to using setAttribute.
                    _ele.setAttribute(key, value);
                }
            } else {
                // fall back to setAttribute if key does not exist
                _ele.setAttribute(key, value);
            }
        });

        // Process explicit "events" property if provided
        if (_events) {
            Object.entries(_events).forEach(([evName, evHandler]) => {
                this.addEvent(_ele, evName, evHandler);
            });
        }

        // Set the parent reference (if provided)
        if (parent) nodeDef._parent = parent;

        // Register references for easy access
        if (_ref) {
            // Skip reserved keys
            if (rootProps.includes(_ref)) throw new Error('[DomSmith] Reserved properties are not allowed as ref');
            if (this[_ref]) throw new Error('[DomSmith] No Duplicate Refs are allowed');
            this[_ref] = _ele;
            this._refs.set(_ele, nodeDef);
        }

        // Process child nodes recursively
        if (_nodes) {
            if (typeof _nodes === 'string') nodeDef._nodes = [_nodes];
            nodeDef._nodes = nodeDef._nodes.reduce((acc, node) => {
                if (node) {
                    const childNodeDef = this.addNode(node, nodeDef);
                    _ele.appendChild(childNodeDef._ele);
                    acc.push(childNodeDef);
                }
                return acc;
            }, []);
        }

        return nodeDef;

    }

    /**
     * Replaces an existing DOM node (specified by its ref) with a new node definition.
     * This method first removes all events from the old node, then replaces it in the DOM.
     * @param  {string}                                 ref         Reference name of the node to be replaced.
     * @param  {module:lib/dom/DomSmith~NodeDefinition} replaceDef  The new node definition to replace the old node.
     * @throws {Error}                                              If an invalid ref was used.
     */
    replaceNode(ref, replaceDef) {

        const replaceEle = this[ref];

        let oldNode = this._refs.get(replaceEle);

        if (!oldNode) throw new Error('[DomSmith] invalid ref used with replaceNode');

        // Remove all events attached to the old node and clean up references
        oldNode = this.removeNode(oldNode);

        const parentIndex = oldNode._parent._nodes.findIndex(pnode => pnode === oldNode), // Index of the old node within its parent's nodes array
              newNode = this.addNode(replaceDef, oldNode._parent); // New node using the same parent reference

        newNode._parent._ele.replaceChild(newNode._ele, oldNode._ele); // Replace the old node with the new node in the parent's DOM element
        newNode._parent._nodes[parentIndex] = newNode; // Update the parent's nodes array

    }

    /**
     * Removes all events and references associated with a DOM node.
     * If the passed node definition is an array, iterates through each element.
     * @param   {module:lib/dom/DomSmith~NodeDefinition|module:lib/dom/DomSmith~NodeDefinition[]} [nodeDefArg=this._dom]  The node definition(s) from which to remove events.
     * @returns {module:lib/dom/DomSmith~NodeDefinition|module:lib/dom/DomSmith~NodeDefinition[]}                         The - possibly modified - node definition(s).
     */
    removeNode(nodeDefArg = this._dom) {

        let nodeDef = nodeDefArg;

        plugins.forEach(({ plugin }) => {
            if (typeof plugin.removeNode === 'function') nodeDef = plugin.removeNode(nodeDef) || nodeDef;
        });

        if (Array.isArray(nodeDef)) { // If nodeDef is an array, process each node individually
            nodeDef.forEach(nd => this.removeNode(nd));
            return nodeDef;
        }

        this.removeEvent(nodeDef._ele);

        if (nodeDef._events) delete nodeDef._events;

        if (nodeDef._ref) {
            this._refs.delete(this[nodeDef._ref]);
            delete this[nodeDef._ref];
        }

        if (nodeDef._nodes) {
            nodeDef._nodes.forEach(child => this.removeNode(child));
        }

        return nodeDef;

    }

    /**
     * Adds an event listener to the specified element and registers it in the central events repository.
     * @param  {HTMLElement|string}  eleOrRef   Target DOM element or ref.
     * @param  {string}              eventName  Event name (e.g., 'click').
     * @param  {Function|Function[]} handler    Event handler(s) to add.
     * @throws {Error}                          If element was not found, or handler is not a function.
     */
    addEvent(eleOrRef, eventName, handler) {

        if (!handler) return; // ignore undefined handlers

        const ele = typeof eleOrRef === 'string' ? this[eleOrRef] : eleOrRef;

        if (!ele) throw new Error(`[DomSmith] removeEvent: ele or ref: ${eleOrRef} not found`);

        if (!this._events.has(ele)) this._events.set(ele, {}); // Initialize mapping if not present
        const events = this._events.get(ele);
        if (!events[eventName]) events[eventName] = [];

        if (Array.isArray(handler)) {
            handler.forEach(h => {
                if (typeof h !== 'function') throw new Error('[DomSmith] Handler must be a function');
                ele.addEventListener(eventName, h);
                events[eventName].push(h);
            });
        } else {
            if (typeof handler !== 'function') throw new Error('[DomSmith] Handler must be a function');
            ele.addEventListener(eventName, handler);
            events[eventName].push(handler);
        }

    }

    /**
     * Removes event listener(s) from the specified element.
     * If no eventName and handler are provided, all event listeners for that element are removed.
     * If an eventName is provided but no handler, then all handlers for that event are removed.
     * Otherwise, only the specified handler for the given eventName is removed.
     * @param  {HTMLElement|string} eleOrRef     Target DOM element or ref.
     * @param  {string}             [eventName]  Event name (e.g., 'click').
     * @param  {Function}           [handler]    Event handler to remove.
     * @throws {Error}                           If element or ref was not found.
     */
    removeEvent(eleOrRef, eventName, handler) {

        const ele = typeof eleOrRef === 'string' ? this[eleOrRef] : eleOrRef;

        if (!ele) throw new Error(`[DomSmith] removeEvent: ele or ref: ${eleOrRef} not found`);

        if (!this._events.has(ele)) return;
        const events = this._events.get(ele);

        if (eventName) {
            if (!events[eventName]) return;
            if (handler) {
                ele.removeEventListener(eventName, handler);
                events[eventName] = events[eventName].filter(h => h !== handler);
            } else {
                events[eventName].forEach(h => ele.removeEventListener(eventName, h));
                delete events[eventName];
            }
            if (Object.keys(events).length === 0) this._events.delete(ele);
        } else {
            Object.keys(events).forEach(evName => {
                events[evName].forEach(h => ele.removeEventListener(evName, h));
            });
            this._events.delete(ele);
        }

    }

    /**
     * Cleans up all resources: removes all events, unmounts the DOM nodes, and clears all references.
     * DEPRECATED, alias for `destroy()`.
     * @deprecated Since version 2.1.0.
     */
    teardown() {

        // eslint-disable-next-line no-console
        console.warn('[DomSmith] teardown() is deprecated, use destroy() instead.');
        this.destroy();

    }

    /**
     * Cleans up all resources: removes all events, unmounts the DOM nodes, and clears all references.
     * @since 2.1.0
     */
    destroy() {

        plugins.forEach(plugin => {
            if (plugin.destroy) plugin.destroy();
        });

        this.removeNode();
        this.unmount();

        // Explicitly null out external DOM references to avoid memory leaks
        if (this._mountContext) {
            this._mountContext.ele = null;
            this._mountContext.parent = null;
            this._mountContext.nextSibling = null;
        }

        this._dom = this._refs = this._mountContext = this._events = null;

    }

    /**
     * Registers a singleton-style plugin for DomSmith.
     * The plugin may define any of the following methods: addNode, removeNode, mount, unmount & destroy.
     * Duplicate plugin instances will be ignored.
     * @param {Object} plugin                The plugin object.
     * @param {Object} [options]             Additional Options for registration.
     * @param {number} [options.priority=0]  Priority value for plugin execution order (higher runs earlier).
     */
    static registerPlugin(plugin, { priority = 0 } = {}) {
        if (plugins.some(entry => entry.plugin === plugin)) return;
        plugins.push({ plugin, priority });
    }
}

/**
 * @typedef  {Object} module:lib/dom/DomSmith~NodeDefinition       Structure of a node definition
 * @property {string}                                   [_tag]     Tag name of the element. Defaults to 'div' if unspecified.
 * @property {string}                                   [_text]    Text content for text nodes.
 * @property {string}                                   [_ref]     A unique reference name used to store a direct reference on the DomSmith instance.
 * @property {Object.<string, Function|Function[]>}     [_events]  Object containing event names and their handler(s).
 * @property {module:lib/dom/DomSmith~NodeDefinition[]} [_nodes]   Child node definitions; can be a single NodeDefinition, a string, or an array of them.
 * @property {*}                                        [any]      Additional properties that will be assigned either directly or via setAttribute.
 */

/**
 * @typedef {Object} module:lib/dom/DomSmith~Options
 * @property {HTMLElement}                 node          Target DOM node for mounting or insertion.
 * @property {'append'|'before'|'replace'} [insertMode]  Injection strategy. Use 'append' to append, 'before' to insert before the node, or 'replace' to replace it. Defaults to 'append'.
 */