Source: lib/core/runtime/AvenxComponent.js

import { ComputedRegistry } from '../reactive/createComputed.js';
import { StateFactory } from '../reactive/createState.js';
import { TemplateRenderer } from '../renderer/renderTemplate.js';
import { DomPatcher } from '../renderer/domPatch.js';
import { EventBinder } from '../events/bindEvents.js';
import { EventExecutor } from '../events/eventExecutor.js';
import { DynamicEvaluator } from '../security/evaluator.js';
import { LifecycleManager } from './lifecycle.js';
import { ListManager } from '../renderer/listManager.js';
import { AvenxErrorCodes, formatMessage } from './AvenxError.js';
import { queueJob } from '../reactive/scheduler.js';

let currentMicrotaskPromise = null;

/**
 * Processes data-ax-bind attributes on input, textarea, and select elements.
 * Converts data-ax-bind="expr" to value="{{ expr }}" and event listener.
 * @param {string} template - The template string.
 * @returns {string} The processed template.
 */
function processBindDirectives(template) {
    if (typeof template !== 'string') return template;
    const tagRegex = /<(input|textarea|select)\b([^>]*?)>/gi;
    return template.replace(tagRegex, (match, tagName, attrs) => {
        const bindRegex = /\bdata-ax-bind\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
        const bindMatch = attrs.match(bindRegex);
        if (!bindMatch) {
            return match;
        }
        
        const bindExpr = (bindMatch[1] !== undefined ? bindMatch[1] : bindMatch[2]).trim();
        let cleanAttrs = attrs.replace(bindRegex, '').trim();
        
        let isSelfClosing = false;
        if (cleanAttrs.endsWith('/')) {
            isSelfClosing = true;
            cleanAttrs = cleanAttrs.slice(0, -1).trim();
        }
        
        const eventName = tagName.toLowerCase() === 'select' ? 'change' : 'input';
        const valueAttr = `value="{{ ${bindExpr} }}"`;
        const eventAttr = `@${eventName}="${bindExpr} = event.target.value"`;
        
        const suffix = isSelfClosing ? ' />' : '>';
        return `<${tagName} ${cleanAttrs} ${valueAttr} ${eventAttr}`.trim().replace(/\s+/g, ' ') + suffix;
    });
}

/**
 * Base class for all Avenx components.
 * Manages state, reactivity, rendering, and lifecycle.
 */
export class AvenxComponent {
    /** @type {Element|null} @private */
    #element = null;
    /** @type {string} @private */
    #template = '';
    /** @type {Object} @private */
    #methods = {};
    /** @type {Object} @private */
    #bridges = {};
    /** @type {ComputedRegistry} @private */
    #computed;
    /** @type {TemplateRenderer} @private */
    #renderer;
    /** @type {DomPatcher} @private */
    #patcher;
    /** @type {ListManager} @private */
    #listManager;
    /** @type {EventBinder} @private */
    #eventBinder;
    /** @type {EventExecutor} @private */
    #eventExecutor;
    /** @type {DynamicEvaluator} @private */
    #evaluator;
    /** @type {LifecycleManager} @private */
    #lifecycle;
    /** @type {boolean} @private */
    #isMounted = false;
    /** @type {Set<string>} @private */
    #evaluating = new Set();
    /** @type {Object|null} @private */
    #transcludedGroups = null;
    /** @type {boolean} @private */
    #updateQueued = false;
    /** @type {Function} @private */
    #updateJob = () => {
        this.#updateQueued = false;
        this.update();
    };
    /** @type {Promise|null} @private */
    #lastUpdatedPromise = null;

    /**
     * @param {Object} [initialState={}] - The initial state of the component.
     * @param {Object} [computed={}] - Computed properties definitions.
     * @param {Object} [bridges={}] - External bridges accessible to the component.
     * @param {string} [template=''] - The HTML template string.
     * @param {Object} [methods={}] - Component methods.
     */
    constructor(initialState = {}, computed = {}, bridges = {}, template = '', methods = {}, props = {}) {
        this.#template = processBindDirectives(template);
        this.#bridges = bridges;
        this.#computed = new ComputedRegistry(computed);
        this.#renderer = new TemplateRenderer();
        this.#patcher = new DomPatcher();
        this.#eventBinder = new EventBinder();
        this.#evaluator = new DynamicEvaluator();
        this.#lifecycle = new LifecycleManager();
        this.#listManager = new ListManager(this.#evaluator, this.#renderer);

        /**
         * The reactive state of the component.
         * @type {Proxy}
         */
        this.state = new StateFactory().create(initialState, {
            computedKeys: this.#computed.keys(),
            onChange: () => this.scheduleUpdate(),
            getComputedValue: key => this.#evaluateComputed(key)
        });

        /**
         * The reactive props of the component.
         * @type {Proxy}
         */
        this.props = new StateFactory().create(props, {
            onChange: () => this.scheduleUpdate()
        });

        this.#methods = this.#evaluator.createMethodMap(
            methods,
            executableMethods => this.#createScope(executableMethods),
            () => this.state
        );
        this.#eventExecutor = new EventExecutor((source, event) => this.#runEventHandler(source, event));
    }

    /**
     * Creates a scope object for expression evaluation.
     * @param {Object} [methods=this.#methods] - Methods to include in the scope.
     * @param {Object} [extras={}] - Additional variables to include.
     * @returns {Object} The combined scope.
     * @private
     */
    #createScope(methods = this.#methods, extras = {}) {
        return { ...this.state, ...methods, ...this.#bridges, props: this.props, ...extras };
    }

    /**
     * Evaluates a computed property.
     * @param {string} key - The key of the computed property.
     * @returns {any} The evaluated value.
     * @private
     */
    #evaluateComputed(key) {
        if (this.#evaluating.has(key)) {
            console.warn(formatMessage(AvenxErrorCodes.COMPUTED_CIRCULAR_DEPENDENCY, key));
            return undefined;
        }
        this.#evaluating.add(key);
        const expression = this.#computed.get(key);
        try {
            return this.#evaluator.evaluateExpression(expression, this.#createScope(), this.state);
        } catch (error) {
            console.warn(formatMessage(AvenxErrorCodes.COMPUTED_EVALUTION_FAILED, key, expression, error));
            return undefined;
        } finally {
            this.#evaluating.delete(key);
        }
    }

    /**
     * Resolves an expression within the template.
     * @param {string} expression - The expression to evaluate.
     * @returns {any} The result of the evaluation.
     * @private
     */
    #resolveTemplateExpression(expression) {
        return this.#evaluator.evaluateExpression(expression, this.#createScope(), this.state);
    }

    /**
     * Runs an event handler.
     * @param {string} source - The source code of the handler.
     * @param {Event} event - The event object.
     * @returns {any} The result of the execution.
     * @private
     */
    #runEventHandler(source, event) {
        try {
            return this.#evaluator.executeStatement(source, this.#createScope(this.#methods, { event }), this.state);
        } catch (error) {
            console.error(formatMessage(AvenxErrorCodes.EVENT_HANDLER_ERROR, source, error));
            return undefined;
        }
    }

    /**
     * Renders the component template with current state.
     * @returns {string} The rendered HTML string.
     */
    render() {
        return this.#renderer.render(this.#template, expression => this.#resolveTemplateExpression(expression));
    }

    update() {
        if (!currentMicrotaskPromise) {
            currentMicrotaskPromise = Promise.resolve();
            Promise.resolve().then(() => {
                currentMicrotaskPromise = null;
            });
        }
        if (this.#lastUpdatedPromise === currentMicrotaskPromise) {
            return;
        }
        this.#lastUpdatedPromise = currentMicrotaskPromise;

        if (!this.#element) return;
        this.#patcher.patch(this.#element, this.render());

        // Fill slots with transcluded content
        this.#fillSlots();

        this.#listManager.process(this.#element, this.#createScope(), this.state);
        this.#eventBinder.bind(this.#element, this.#eventExecutor);

        if (this.#isMounted && this.#element?.dispatchEvent) {
            this.#element.dispatchEvent(
                new CustomEvent('avenx:update')
            );
        }

        if (this.#isMounted && this.#methods.onUpdate) {
            this.#methods.onUpdate();
        }
    }

    /**
     * Schedules an update to run asynchronously in a microtask.
     * Deduplicates multiple calls to schedule.
     */
    scheduleUpdate() {
        if (this.#updateQueued) return;
        this.#updateQueued = true;
        queueJob(this.#updateJob);
    }

    /**
     * Internal method to set the mount target.
     * @param {Element} target - The target element.
     * @private
     */
    __setMountTarget(target) {
        this.#element = target;
        if (target) {
            target.__avenx_comp_instance = this;

            // Extract transcluded children
            const children = Array.from(target.childNodes);
            this.#transcludedGroups = {
                default: [],
                named: {}
            };
            children.forEach(child => {
                if (child.nodeType === 1 && child.hasAttribute('slot')) {
                    const name = child.getAttribute('slot');
                    if (!this.#transcludedGroups.named[name]) {
                        this.#transcludedGroups.named[name] = [];
                    }
                    this.#transcludedGroups.named[name].push(child);
                } else {
                    this.#transcludedGroups.default.push(child);
                }
            });
            // Clear the mount target's inner content
            target.innerHTML = '';
        }
    }

    /**
     * Fills <slot> elements with transcluded child nodes.
     * @private
     */
    #fillSlots() {
        if (!this.#element || !this.#transcludedGroups) return;
        const slots = this.#getOwnSlots();
        slots.forEach(slotEl => {
            const name = slotEl.getAttribute('name');
            const nodes = name ? (this.#transcludedGroups.named[name] || []) : (this.#transcludedGroups.default || []);
            const hasContent = nodes.some(node => {
                if (node.nodeType === 1 && node.nodeName !== '!--' && node.nodeName !== '#comment') return true;
                if (node.nodeType === 3 && node.textContent.trim().length > 0) return true;
                return false;
            });
            if (hasContent) {
                // Clear default content of the slot
                slotEl.innerHTML = '';
                // Append the nodes
                nodes.forEach(node => {
                    slotEl.appendChild(node);
                });
                slotEl.setAttribute('data-avenx-transcluded', 'true');
            } else {
                slotEl.removeAttribute('data-avenx-transcluded');
            }
        });
    }

    /**
     * Retrieves slot elements belonging to this component (not nested inside child components).
     * @returns {Element[]}
     * @private
     */
    #getOwnSlots() {
        if (!this.#element) return [];
        const slots = this.#element.querySelectorAll('slot');
        const root = this.#element;
        return Array.from(slots).filter(slot => {
            let parent = slot.parentNode;
            while (parent && parent !== root) {
                if (parent.hasAttribute && parent.hasAttribute('data-avenx-comp')) {
                    return false;
                }
                parent = parent.parentNode;
            }
            return true;
        });
    }

    /**
     * Updates the transcluded content when the parent template updates.
     * @param {NodeList|Array} virtualChildNodes - The new virtual transcluded nodes from parent.
     * @private
     */
    __updateTranscludedContent(virtualChildNodes) {
        const grouped = {
            default: [],
            named: {}
        };
        Array.from(virtualChildNodes || []).forEach(node => {
            if (node.nodeType === 1 && node.hasAttribute('slot')) {
                const name = node.getAttribute('slot');
                if (!grouped.named[name]) {
                    grouped.named[name] = [];
                }
                grouped.named[name].push(node);
            } else {
                grouped.default.push(node);
            }
        });

        this.#transcludedGroups = grouped;

        // Patch the slots in the DOM recursively with new virtual transcluded children
        if (this.#element) {
            const slots = this.#getOwnSlots();
            slots.forEach(slotEl => {
                const name = slotEl.getAttribute('name');
                const newChildren = name ? (grouped.named[name] || []) : (grouped.default || []);

                const newSlotWrapper = slotEl.cloneNode(false);
                newChildren.forEach(child => {
                    newSlotWrapper.appendChild(child.cloneNode(true));
                });

                const hasContent = newChildren.some(node => {
                    if (node.nodeType === 1 && node.nodeName !== '!--' && node.nodeName !== '#comment') return true;
                    if (node.nodeType === 3 && node.textContent.trim().length > 0) return true;
                    return false;
                });

                if (hasContent) {
                    newSlotWrapper.setAttribute('data-avenx-transcluded', 'true');
                } else {
                    newSlotWrapper.removeAttribute('data-avenx-transcluded');
                    // Restore default content from the template
                    try {
                        const parser = new DOMParser();
                        const doc = parser.parseFromString(this.render(), 'text/html');
                        const rootDoc = doc.body || doc;
                        const defaultSlot = Array.from(rootDoc.querySelectorAll('slot')).find(s => {
                            const sName = s.getAttribute('name');
                            return name ? (sName === name) : !sName;
                        });
                        if (defaultSlot) {
                            Array.from(defaultSlot.childNodes).forEach(child => {
                                newSlotWrapper.appendChild(child.cloneNode(true));
                            });
                        }
                    } catch (e) {
                        console.warn('[AvenxComponent] Failed to restore default slot content', e);
                    }
                }

                // Patch the slot element in-place
                this.#patcher.patchElement(slotEl, newSlotWrapper);
            });
        }
    }

    /**
     * Internal method called after the component is mounted to the DOM.
     * @private
     */
    __afterMount() {
        this.#isMounted = true;

        if (this.#element?.dispatchEvent) {
            this.#element.dispatchEvent(
                new CustomEvent('avenx:mount')
            );
        }

        if (this.#methods.onMount) {
            this.#methods.onMount();
        }
    }

    /**
     * Unmounts the component and triggers cleanup.
     */
    unmount() {
        this.#eventBinder.unbind(this.#element);
        if (this.#element?.dispatchEvent) {
            this.#element.dispatchEvent(
                new CustomEvent('avenx:unmount')
            );
        }

        if (this.#methods.onUnmount) {
            this.#methods.onUnmount();
        }

        if (this.#element) {
            this.#element.innerHTML = '';
        }

        this.#isMounted = false;
    }

    /**
     * Updates the component's props and triggers an update if they changed.
     * @param {Object} newProps - The new props to apply.
     */
    setProps(newProps) {
        let changed = false;
        const currentProps = this.props;
        
        for (const key of Object.keys(newProps)) {
            if (currentProps[key] !== newProps[key]) {
                currentProps[key] = newProps[key];
                changed = true;
            }
        }
        
        for (const key of Object.keys(currentProps)) {
            if (!(key in newProps)) {
                delete currentProps[key];
                changed = true;
            }
        }
    }

    /**
     * Evaluates an expression in the component's scope.
     * @param {string} expression - The expression to evaluate.
     * @param {Object} [extraScope={}] - Additional scope variables.
     * @returns {any} The result of the evaluation.
     * @protected
     */
    _evaluate(expression, extraScope = {}) {
        return this.#evaluator.evaluateExpression(expression, this.#createScope(this.#methods, extraScope), this.state);
    }

    /**
     * @returns {Element|null} The component's root element.
     * @protected
     */
    _getElement() {
        return this.#element;
    }

    /**
     * @returns {Object} The bridges accessible to the component.
     * @protected
     */
    _getBridges() {
        return this.#bridges;
    }

    /**
     * Mounts the component to a target element.
     * @param {Element|string} target - The target element or selector.
     */
    mount(target) {
        this.#lifecycle.mount(this, target);
    }
}