Source: dom99create.js

// @ts-check
export {
    create,
    scopeFromArray,
    scopeFromEvent,
    parentScope,
    leafName,
    leafIndex,
    createElement2,
    FIRST_VARIABLE_FROM_HTML,
    FIRST_VARIABLE_FROM_USER_AGENT,
};

import { createElement2 } from "./createElement2.js";
import { isObjectOrArray } from "./isObjectOrArray.js";
import { firstAncestorValue } from "./parentIdFromEvent.js";
import { pushOrCreateArrayAt } from "./pushOrCreateArrayAt.js";



const scopes = new WeakMap();
const listItems = new WeakMap();
const customElements = new WeakMap();
const listChildren = new WeakMap();
const INSIDE_SYMBOL = `>`;

const removeNode = (node) => {
    node.remove();
};

const elementsDeepForEach = (startElement, callBack) => {
    let currentNode = startElement;
    const treeWalker = document.createTreeWalker(startElement, NodeFilter.SHOW_ELEMENT)
    
    do {
        callBack(currentNode)
        currentNode = treeWalker.nextNode();
    } while( currentNode);
};

const customElementNameFromElement = (element) => {
    const isAttributeValue = element.getAttribute(`is`);
    if (isAttributeValue) {
        return isAttributeValue;
    }
    return element.tagName.toLowerCase();
};

const cloneTemplate = (template, options) => {
    if (!template) {
        console.error(
            `Template missing <template ${options.directives.template}="d-name">
                Template Content
            </template>`,
        );
    }
    if (!template.content) {
        console.error(
            `template.content is undefined, this can happen if a template is inside another template. Use only top level templates, also use recommended polyfills`,
        );
    }
    return document.importNode(template.content, true);
};


/**
 @param {Element} element

 @return {string | undefined} scope
 */
const scopeFromElement = (element) => {
    return scopes.get(element);
};

/**
 scopeFromEvent gets the starting path for an event issued inside a component

 in combination with scopeFromArray it allows to access sibling elements and variables

 d.functions.clickedButton = (event) => {
    d.elements[d.scopeFromArray([scopeFromEvent(event), `other`])]
        .classList.add(`active`);
};

 @param {Event} event

 @return {string | undefined} scope
 */
const scopeFromEvent = (event) => {
    if (event?.target) {
        return firstAncestorValue(event.target, scopeFromElement) || ``;
    }
    return;
};

/**
 scopeFromArray joins paths to create a valid path to use with

 d.variables[path]
 d.elements[path]

 @param {array} scopeIn

 @return {string} path
 */
const scopeFromArray = (scopeIn) => {
    return scopeIn.filter(function (v) {
        return v !== undefined && v !== ``;
    }).join(INSIDE_SYMBOL);
};

/**
 parentScope

 @param {string} scope

 @return {string} parentScope
 */
const parentScope = (scope) => {
    const split = scope.split(INSIDE_SYMBOL);
    split.pop();
    return split.join(INSIDE_SYMBOL);
};

/**

 @param {string} scope

 @return {string} leafName
 */
const leafName = (scope) => {
    const split = scope.split(INSIDE_SYMBOL);
    return split[split.length - 1];
};
/**

 @param {string} scope

 @return {number} leafIndex
 */
const leafIndex = (scope) => {
    return Number(leafName(scope));
};

const scopeFromArrayWith = (scopeIn, withWhat) => {
    if (scopeIn.length === 0) {
        return withWhat;
    }
    return `${scopeFromArray(scopeIn)}${INSIDE_SYMBOL}${withWhat}`;
};

const normalizeStartPath = (startScope) => {
    /* this is because `a>b>c` is irregular
    `a>b>c>` or `>a>b>c` would not need such normalization */
    if (startScope) {
        return `${startScope}${INSIDE_SYMBOL}`;
    }
    return startScope;
};

const deleteAllStartsWith = (object, prefix = ``) => {
    Object.keys(object).forEach((key) => {
        if (key.startsWith(prefix)) {
            delete object[key];
        }
    });
};

// good candidates for firstVariableValueStrategy :
const FIRST_VARIABLE_FROM_HTML = (element) => {
    if (element.defaultValue !== undefined) {
        return element.defaultValue;
    }
    if (element.open !== undefined) { // <details>
        return element.open;
    }
    return element.textContent;
};

const FIRST_VARIABLE_FROM_USER_AGENT = (element) => {
    return element.value || FIRST_VARIABLE_FROM_HTML(element);
};

const prepareGet = (input, ...toJoin) => {
    let array;
    if (Array.isArray(input)) {
        array = input.concat(toJoin);
    } else {
        array = [input, ...toJoin];
    }
    return scopeFromArray(array);
};

const enterObject = (scopeIn, key) => {
    scopeIn.push(key);
};

const leaveObject = (scopeIn) => {
    scopeIn.pop();
};

const notifyOneVariableSubscriber = (options, variableSubscriber, value) => {
    variableSubscriber[options.propertyFromElement(variableSubscriber)] = value;
};

const notifyVariableSubscribers = (options, subscribers, value) => {
    if (value === undefined) {
        /* undefined can be used to use the default value
        without explicit if else */
        return;
    }
    subscribers.forEach((variableSubscriber) => {
        notifyOneVariableSubscriber(options, variableSubscriber, value);
    });
};

const notifyOneListSubscriber = (listContainer, startScope, data, templateFromName, notifyCustomListSubscriber, options) => {
    if (customElements.has(listContainer)) {
        if (!Object.hasOwn(templateFromName, customElements.get(listContainer))) {
            console.error(`<${customElements.get(listContainer)}> not found, make sure its <template> is defined before`);
            return;
        }
        notifyCustomListSubscriber(listContainer, startScope, data);
        return;
    }
    notifyRawListSubscriber(listContainer, data, options);
};

const notifyListSubscribers = (subscribers, startScope, data, templateFromName, notifyCustomListSubscriber, options) => {
    subscribers.forEach((listContainer) => {
        notifyOneListSubscriber(listContainer, startScope, data, templateFromName, notifyCustomListSubscriber, options);
    });
};

const notifyRawListSubscriber = (listContainer, data, options) => {
    const fragment = document.createDocumentFragment();
    listContainer.innerHTML = ``;
    const listItemTagName = listItems.get(listContainer);
    const listItemProperty = options.propertyFromElement(
        listItemTagName.toUpperCase(),
    );
    data.forEach((value) => {
        const listItem = document.createElement(listItemTagName);
        if (isObjectOrArray(value)) {
            Object.assign(listItem, value);
        } else {
            listItem[listItemProperty] = value;
        }
        fragment.appendChild(listItem);
    });
    listContainer.appendChild(fragment);
};

const create = (options) => {
    const variableSubscribers = {};
    const listSubscribers = {};

    /**
     Retrieve variable values that have been modified by d.feed or
     2 way data bound element with data-variable attribute (Read only)
  
     @param {string} path
  
     @return {any}
     */
    const variables = {};

    /**
     Retrieve elements that have data-element attribute (Read only)
  
     @param {string} path
  
     @return {Element}
     */
    const elements = {};
    const templateFromName = {};

    /**
     Set event listener that are going to be attached to elements
     with data-function
  
     @param {string} name
  
     @return {function}
     */
    const functions = {};

    let scopeIn = [];

    const functionPlugins = [];
    let alreadyHooked = false;
    const feedPlugins = [];
    const clonePlugins = [];


    /**
     removes a path and all its child from the dom99 singleton memory
  
     Removing a DOM element with .remove() or .innerHTML = `` will NOT delete
     all the element references if you used the underlying nodes in dom99
     A removed element will continue receive invisible automatic updates
     it also takes space in the memory.
  
     And all of this doesn't matter for 1-100 elements, but it does matter,
     for an infinitely growing list
  
     @param {string} path
     */
    const forgetScope = (path) => {
        deleteAllStartsWith(variableSubscribers, path);
        deleteAllStartsWith(listSubscribers, path);
        deleteAllStartsWith(variables, path);
        deleteAllStartsWith(elements, path);
    };

    const notifyCustomListSubscriber = (listContainer, startScope, data) => {
        const fragment = document.createDocumentFragment();
        const template = templateFromName[customElements.get(listContainer)];
        const previous = Array.from(scopeIn);
        scopeIn = startScope.split(INSIDE_SYMBOL);
        // enterObject(scopeIn, key);
        // leaveObject(scopeIn);
        const normalizedScope = normalizeStartPath(startScope);
        const newLength = data.length;
        let oldLength;
        let scopeInside;
        if (listChildren.has(listContainer)) {
            // remove nodes and variable subscribers that are not used
            oldLength = listChildren.get(listContainer).length;
            if (oldLength > newLength) {
                for (let i = newLength; i < oldLength; i += 1) {
                    scopeInside = `${normalizedScope}${i}`;
                    listChildren.get(listContainer)[i].forEach(removeNode);
                    forgetScope(scopeInside);
                }
                listChildren.get(listContainer).length = newLength;
            }
        } else {
            listChildren.set(listContainer, []);
            oldLength = 0;
        }

        data.forEach((dataInside, i) => {
            scopeInside = `${normalizedScope}${i}`;
            feed(scopeInside, dataInside);
            if (i < oldLength) {
                // reusing, feed updated with new data the old nodes
                return;
            }
            /* cannot remove document fragment after insert because they empty themselves
            have to freeze the children to still have a reference */
            const activatedClone = activateCloneTemplate(
                template,
                String(i),
            );
            listChildren.get(listContainer).push(
                Array.from(activatedClone.childNodes),
            );
            fragment.appendChild(activatedClone);
        });
        scopeIn = previous;
        listContainer.appendChild(fragment);
    };


    /**
     Feed data, for element with corresponding data-variable and data-list
  
     @param {string} startScope
     @param {any} data
  
     @or
  
     @param {any} data
     */
    const feed = (startScope, data) => {
        if (data === undefined) {
            data = startScope;
            startScope = ``;
        }
        if (isObjectOrArray(startScope)) {
            console.error(
                `Incorrect types passed to d.feed,
                d.feed(string, object) or d.feed(object)`,
            );
        }
        if (!alreadyHooked) {
            feedHook(startScope, data);
            alreadyHooked = true;
        }
        if (!isObjectOrArray(data)) {
            variables[startScope] = data;
            if (Object.hasOwn(variableSubscribers, startScope)) {
                notifyVariableSubscribers(options, variableSubscribers[startScope], data);
            }
        } else if (Array.isArray(data)) {
            variables[startScope] = data;
            if (Object.hasOwn(listSubscribers, startScope)) {
                notifyListSubscribers(listSubscribers[startScope], startScope, data, templateFromName, notifyCustomListSubscriber, options);
            }
        } else {
            const normalizedScope = normalizeStartPath(startScope);
            /* sort arrays to be last fix 
            list first as otherwise setting the value of a select has no effect */
            const dataEntries = Object.entries(data);
            dataEntries.sort(([, value], [, valueb]) => {
                return Number(Array.isArray(valueb)) - Number(Array.isArray(value));
            });
            dataEntries.forEach(([key, value]) => {
                const scope = `${normalizedScope}${key}`;
                feed(scope, value);
            });
        }
        alreadyHooked = false;
    };

    const get = (input, ...toJoin) => {
        return variables[prepareGet(input, ...toJoin)];
    };

    const getElement = (input, ...toJoin) => {
        return elements[prepareGet(input, ...toJoin)];
    };

    const applyFunctionOriginal = (element, eventName, functionName) => {
        if (!functions[functionName]) {
            console.error(`Event listener ${functionName} not found.`);
        }
        element.addEventListener(eventName, functions[functionName], false);
    };

    let applyFunction = applyFunctionOriginal;

    const applyFunctions = (element, attributeValue) => {
        if (scopeIn.length) {
            scopes.set(element, scopeFromArray(scopeIn));
        }
        attributeValue.split(options.listSeparator).forEach(
            (attributeValueSplit) => {
                const tokens = attributeValueSplit.split(options.tokenSeparator);
                let functionName;
                let eventName;
                if (tokens.length === 1) {
                    functionName = tokens[0];
                    eventName = options.eventNameFromElement(element);
                } else {
                    [eventName, functionName] = tokens;
                }
                applyFunction(element, eventName, functionName);
            },
        );
    };

    const applyList = (element, variableName) => {
        if (!variableName) {
            console.error(
                element,
                `Use ${options.directives.list}="variableName-tagName" format!`,
            );
        }
        
        const use = element.getAttribute(options.directives.use);
        if (!use) {
            const tagName = element.tagName.toLowerCase();
            console.error(
                `<${tagName} ${options.directives.list}="${variableName}"></${tagName}> is missing ${options.directives.use}="name"`,
            );
            return;
        }
        if (use.includes(`-`)) {
            customElements.set(element, use);
        } else {
            listItems.set(element, use);
        }
        
        /* could send scope as array directly but have to change
        notifyOneListSubscriber to take in scope as Array or String before */
        const arrayScope = scopeFromArrayWith(scopeIn, variableName);

        pushOrCreateArrayAt(listSubscribers, arrayScope, element);

        if (element.childNodes.length > 0) {
            enterObject(scopeIn, variableName);
            const childElements = Array.from(element.childNodes).filter(childNode => {
                // document.body.ELEMENT_NODE === 1
                return childNode.nodeType === 1;
            });

            const scope = scopeFromArray(scopeIn);
            const currentValue = variables[scope];
            if (currentValue === undefined) {
                variables[scope] = Array.from({ length: childElements.length }, () => {
                    return {};
                });
            }

            listChildren.set(element, []);
            childElements.forEach((childElement, i) => {
                enterObject(scopeIn, String(i));
                activate(childElement);
                listChildren.get(element).push(
                    [childElement, ...childElement.childNodes],
                );
                leaveObject(scopeIn);
            });
            leaveObject(scopeIn);
        }

        if (Object.hasOwn(variables, arrayScope)) {
            notifyOneListSubscriber(element, arrayScope, variables[arrayScope], templateFromName, notifyCustomListSubscriber, options);
        }
    };

    const applyVariable = (element, variableName) => {
        /* two-way bind
        example : called for <input data-variable="a">
        in this example the variableName = `a`
        we push the <input data-variable="a" > element in the array
        that holds all elements which share this same `a` variable
        undefined assignment are ignored, instead use empty string*/

        if (!variableName) {
            console.error(
                element,
                `Use ${options.directives.variable}="variableName" format!`,
            );
        }

        const scope = scopeFromArrayWith(scopeIn, variableName);
        pushOrCreateArrayAt(variableSubscribers, scope, element);

        let currentValue = variables[scope];
        if (currentValue === undefined && options.firstVariableValueStrategy !== undefined) {
            currentValue = options.firstVariableValueStrategy(element);
            variables[scope] = currentValue;
        }
        if (currentValue !== undefined) {
            notifyOneVariableSubscriber(options, element, currentValue);
        }

        if (!options.tagNamesForUserInput.includes(element.tagName)) {
            return;
        }
        const broadcastValue = (/*event*/) => {
            //wil call setter to broadcast the value
            const value = element[options.propertyFromElement(element)];
            variables[scope] = value;
            feedHook(scope, value);
            /* would notify everything including itself
            notifyVariableSubscribers(options, variableSubscribers[scope], value); */
            variableSubscribers[scope].forEach((variableSubscriber) => {
                if (variableSubscriber !== element) {
                    notifyOneVariableSubscriber(options, variableSubscriber, value);
                }
            });
        };
        element.addEventListener(
            options.eventNameFromElement(element),
            broadcastValue,
            false,
        );

    };

    const applyElement = (element, attributeValue) => {
        /* stores element for direct access !*/
        const elementName = attributeValue;

        if (!elementName) {
            console.error(
                element,
                `Use ${options.directives.element}="elementName" format!`,
            );
        }
        const scope = scopeFromArrayWith(scopeIn, elementName);
        elements[scope] = element;
    };

    const applyTemplate = (element, attributeValue) => {
        /* stores a template element for later reuse !*/
        if (!attributeValue) {
            console.error(
                element,
                `Use ${options.directives.template}="d-name" format!`,
            );
        }

        if (element.tagName !== `TEMPLATE`) {
            /* allow use on non template elements
            useful for server side rendering */
            const { innerHTML } = element;
            element = document.createElement(`template`);
            element.innerHTML = innerHTML;
        }
        templateFromName[attributeValue] = element;
    };

    const activateCloneTemplate = (template, key) => {
        /* clones a template and activates it
        */
        enterObject(scopeIn, key);
        const activatedClone = cloneTemplate(template, options);
        activate(activatedClone);
        cloneHook();
        leaveObject(scopeIn);
        return activatedClone;
    };

    const applyScope = (element, key) => {
        /* looks for an html template to render
        also calls applyElement with key!*/
        if (!key) {
            return;
        }

        const template = templateFromName[
            customElementNameFromElement(element)
        ];

        if (template) {
            const activatedClone = activateCloneTemplate(template, key);
            element.appendChild(activatedClone);
        } else {
            // avoid infinite loop
            element.setAttribute(
                options.directives.scope,
                `${options.doneSymbol}${key}`,
            );
            // parse children under scope
            enterObject(scopeIn, key);
            activate(element);
            leaveObject(scopeIn);
        }
    };

    const directivePairs = [
        /*order is relevant applyVariable being before applyFunction,
        we can use the just changed live variable in the bind function*/
        [options.directives.element, applyElement],
        [options.directives.template, applyTemplate],
        [options.directives.list, applyList],
        [options.directives.variable, applyVariable],
        [options.directives.function, applyFunctions],
        [options.directives.scope, applyScope],
    ];

    const tryApplyDirectives = (element) => {
        /* looks if the element has dom99 specific attributes and tries to handle it*/
        if (!element.hasAttribute) {
            // can this if be removed eventually ? --> no
            return;
        }

        // spell check attributes
        // const directives = Object.values(options.directives);
        // Array.prototype.slice.call(element.attributes).forEach((attribute) => {
        //     if (attribute.nodeName.startsWith(`data`)) {
        //         if (!directives.includes(attribute.nodeName)) {
        //             console.warn(`dom99 does not recognize ${attribute.nodeName}`);
        //         }
        //     }
        // });

        directivePairs.forEach(([directiveName, applyDirective]) => {
            if (!element.hasAttribute(directiveName)) {
                return;
            }
            const attributeValue = element.getAttribute(directiveName);
            if (attributeValue[0] === options.doneSymbol) {
                return;
            }
            applyDirective(element, attributeValue);
            // ensure the directive is only applied once
            element.setAttribute(
                directiveName,
                `${options.doneSymbol}${attributeValue}`,
            );
        });
        if (
            element.hasAttribute(options.directives.scope) ||
            element.hasAttribute(options.directives.list)
        ) {
            return;
        }
        /* using a custom element without data-scope */
        const customName = customElementNameFromElement(element);
        if (Object.hasOwn(templateFromName, customName)) {
            element.appendChild(
                cloneTemplate(templateFromName[customName], options),
            );
        }
    };

    /**
     Activates the DOM by reading data- attributes, starting from startElement
     and walking inside its tree
  
     @param {Element} startElement
  
     @return {Element} startElement
     */
    const activate = (startElement = document.body) => {
        elementsDeepForEach(startElement, tryApplyDirectives);
        return startElement;
    };

    /**
     @param {object} options
        - {object} dataFunctions
        - {object} initialFeed
        - {Element} startElement
     */
    const start = ({
        startElement = document.body,
        initialFeed = {},
        dataFunctions = {},
    } = {}) => {
        if (startElement.nodeType !== 1) {
            console.error(`start takes undefined or a node as first argument`);
        }

        Object.assign(functions, dataFunctions);
        feed(``, initialFeed);
        activate(startElement);
    };

    const cloneHook = function () {
        const scope = scopeFromArray(scopeIn);
        clonePlugins.forEach((clonePlugin) => {
            clonePlugin(scope);
        });
    };

    const feedHook = (startScope, data) => {
        feedPlugins.forEach((feedPlugin) => {
            feedPlugin(startScope, data);
        });
    };

    /**
     Plug in a plugin (hook) into the core functionality
  
     @param {object} featureToPlugIn
  
     */
    const plugin = (featureToPlugIn) => {
        if (!isObjectOrArray(featureToPlugIn)) {
            console.error(`plugin({
                type,
                plugin
            });`);
        }
        if (featureToPlugIn.type === `function`) {
            functionPlugins.push(featureToPlugIn.plugin);
            applyFunction = (element, eventName, functionName) => {
                let defaultPrevented = false;
                const preventDefault = () => {
                    defaultPrevented = true;
                };
                

                functionPlugins.forEach((pluginFunction) => {
                    pluginFunction(element, eventName, functionName, functions, preventDefault);
                });
                if (defaultPrevented) {
                    return;
                }
                applyFunctionOriginal(element, eventName, functionName);
            };
        } else if (featureToPlugIn.type === `variable`) {
            feedPlugins.push(featureToPlugIn.plugin);
        } else if (featureToPlugIn.type === `cloned`) {
            clonePlugins.push(featureToPlugIn.plugin);
        } else {
            console.warn(`plugin type ${featureToPlugIn.type} not yet implemented`);
        }
    };

    return {
        start,
        elements,
        functions,
        variables,
        get,
        element: getElement,
        feed,
        plugin,
    };
};