dom.js

// Author: Alberto González Palomo <http://sentido-labs.com>
// ©2016-2020 Alberto González Palomo <http://sentido-labs.com>
//
// License: MIT, https://opensource.org/licenses/MIT
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

var dom =
/** DOM utilities for creating XML/HTML with JSONML, CSS selectors, XPath.
    @author Alberto González Palomo <http://sentido-labs.com>
    @copyright ©2016-2019 Alberto González Palomo <http://sentido-labs.com>
*/
(function (document) {
    'use strict';

    /** Build XML/HTML from Javascript objects based on
     * [JsonML](http://www.jsonml.org/):
     * ```
     * dom(["html", {"lang":"en"},
     *       ["head",
     *         ["title", "Page title"]
     *       ],
     *       ["body.page",
     *         ["h1", "Page title"],
     *         ["button", { "onclick":showTextContent }, "Hello, world!"]
     *       ]
     *     ]);
     * function showTextContent(event) { alert(event.target.textContent); }
     * ```
     * ```
     * <html lang="en">
     *   <head>
     *     <title>Page title</title>
     *   </head>
     *   <body class="page">
     *     <h1>Page title</h1>
     *     <button onclick="alert(this.textContent)">Hello, world!</button>
     *   </body>
     * </html>
     * ```
     * Extended with the following features:
     * - allow CSS syntax for class name and id:
     *   + `["tag.class#id"]` →<br/>
     *     `<tag class="class" id="id"/>`
     *   + `["tag.class1.class2"]` / `["tag class1 class2"]` →<br/>
     *     `<tag class="class1 class2"/>`
     * - support namespaces using a prefix map
     * `['svg:path',...]` vs. `['path',...]` with<br/>
     * `ns = { "svg":"http://www.w3.org/2000/svg" };`
     * <br/>There is no namespace scoping, but you can specify the default
     * namespace in the namespace map with an empty string:<br/>
     * `ns = { "":"http://www.w3.org/2000/svg" };`
     * - create a
     * {@link http://devdocs.io/dom/documentfragment `DocumentFragment`}
     * if the tag name is the empty string ""
     * - accept `Node` objects as first child in array: then the node
     * is used instead of created new, and the rest of the array,
     * attributes and children, are added to it
     * (brilliant idea taken from {@link https://github.com/adius/shaven} )
     * - accept as text content apart from strings also numbers,
     * booleans and other objects that are not arrays or nodes
     * - accept as node children DOM Nodes using
     * {@link http://devdocs.io/dom/document/adoptnode `document.adoptNode()`}
     * if needed
     * - if an attribute has as name the empty string, its value must be
     * a function that will be called as `function (element)` after the
     * element has been built
     *
     * It does not include a **templating mechanism** but you can build your
     * own using the `preProcess` parameter in
     * {@link dom.dom dom(jsonml, ns, doc, preProcess)}.

     @namespace dom */

    /** Create DOM tree/node from extended JsonML input.
     *
     @param {} jsonml - JsonML expression.
     @param {Object} [ns] - namespace map: { prefix: url, ... },
     can be `null` if you do not need XML namespaces,
     and then you can also omit it if there is no `doc` argument.
     @param {Document} [doc] - context document if not the current.
     @param {Function} [preProcess] - pre-processor for child nodes,
     a function that receives the child and returns a valid child item.
     If not specified, it is functionally equivalent to
     `function preProcess(child) { return child; }`
     but more efficient.
     This is most useful for building your own templating mechanism.
     @memberof dom
    */
    function dom(jsonml, ns, doc, preProcess)
    {
        if (!ns)  ns  = {};
        if (!doc) doc = document;
        var node;
        if (Array.isArray(jsonml)) {
            var i = 0;
            node = jsonml[i++];
            if ('string' === typeof node) {
                node.replace
                (/(?:(\w*):)?([^. #]*)((?:[. ][^. #]*)*)(?:#(.*))?/,
                 function parse(m, prefix, name, classes, id) {
                     var namespaceURI = ns[prefix||''];
                     node = name? (
                         namespaceURI?
                             doc.createElementNS(namespaceURI, name):
                             prefix?
                             error('Undefined prefix ' + prefix):
                             doc.createElement(name)
                     ): doc.createDocumentFragment();
                     classes = classes.replace(/\./g, ' ').trim();
                     if (classes) node.setAttribute('class', classes);
                     if (id)      node.setAttribute('id', id);
                 });
            }
            if (!(node instanceof Node)) {
                error('Node head is ' + node + ' in ' + JSON.stringify(jsonml));
                return dom('ERROR');
            }
            var end = jsonml.length;
            if (i < end) {
                var attributes = jsonml[i];
                if (attributes && attributes.constructor &&
                    'Object' === attributes.constructor.name) {
                    Object.keys(attributes).forEach(function (name) {
                        var value = attributes[name];
                        if ('function' === typeof value) {
                            node.addEventListener(name.replace(/^on/, ''),
                                                  value);
                        } else if (null !== value && false !== value) {
                            node.setAttribute(name, value);
                        }
                    });
                    ++i;
                }
            }
            if (preProcess) {
                while (i < end) {
                    node.appendChild(dom(preProcess(jsonml[i++]), ns, doc,
                                         preProcess));
                }
            } else {
                while (i < end) {
                    node.appendChild(dom(jsonml[i++], ns, doc));
                }
            }
        } else if (jsonml instanceof Node) {
            node = jsonml;
        } else {
            node = doc.createTextNode(jsonml);
        }

        return node;
    }

    /** Clear the element, removing all children. Attributes are not affected.
     *
     @function dom~clear
     @param {Element} element - element whose children will be removed.
     @returns {Element} element
    */
    dom.clear = function clear(element) {
        element.textContent = '';
        return element;
    };

    /** Get element by ID.
     *
     @function dom~id
     @param {string} id - identifier to look for.
     @param {Document} [doc] - in which document to look if not the current.
    */
    dom.id = function id(id, doc) {
        return (doc||document).getElementById(id);
    };
    /** Get element by CSS selector.
     *
     @function dom~$
     @param {string} selector - CSS selector to look for.
     @param {Document} [root] - in which node to look if not the current document.
     @returns {Node}
    */
    dom.$ = function $(selector, root) {
        return (root||document).querySelector(selector);
    };
    /** Get element array by CSS selector.
     *
     @function dom~$$
     @param {string} selector - CSS selector to look for.
     @param {Document} [root] - in which node to look if not the current document.
     @returns {Node[]}
    */
    dom.$$ = function $$(selector, root)
    {
        return Array.prototype
            .slice.call((root||document).querySelectorAll(selector));
    }
    /** Get element iterator by CSS selector.
     *
     @function dom~iter
     @param {string} selector - CSS selector to look for.
     @param {Document} [root] - in which node to look if not the current document.
     @returns node iterator with result.iterateNext() → Element
     like {@link dom.XPath~iter dom.XPath.iter()}.
    */
    dom.iter = function iter(selector, root) {
        return {
            i: 0,
            result: (root||document).querySelectorAll(selector),
            iterateNext: iterateNext
        };
    };
    function iterateNext() {
        var a = this.result;
        return (this.i < a.length? a.item(this.i++): null);
    }

    /** Build iterator from Array or array-like object.
     *
     @function dom~iterFromArray
     @param {Array} array - the array on which to iterate.
     @returns item iterator with result.iterateNext()
     like {@link dom.XPath~iter dom.XPath.iter()}.
    */
    dom.iterFromArray = function iterFromArray(array) {
        return {
            i: 0,
            array: array,
            iterateNext: iterateNextArray
        };
    };
    function iterateNextArray() {
        var a = this.array;
        return (this.i < a.length? a[this.i++]: null);
    }

    var targetTime = 1000/*ms*/;
    /** Split iteration asynchronously in batches,
     * returning control to the browser between them.
     * This helps to keep the browser/app responsive while iterating
     * over many items.
     * The batch size is adapted after each batch
     * to approximate 1 second processing time.
     @function dom~iterateAsync
     @param {iter} iter - the iterator to use; if you have an Array
     you can convert it with {@link dom~iterFromArray iterFromArray(array)}.
     @param {Function} forEach - function called for each item: forEach(item)
     @param {Function} [batchFinished] - callback for each finished batch,
     can be `null` if you do not need it.
     It receives the batch size as parameter.
     If you want to stop the loop, return `false` from this function.
     @param {Function} [finished] - callback when the iterator is finished,
     receives the last batch size as parameter.
    */
    dom.iterateAsync = function iterateAsync(iter,
                                             forEach, batchFinished, finished)
    {
        function go(batchSize) {
            var count = batchSize, item;
            var t = Date.now();
            while (count-- && ((item = iter.iterateNext()) !== null)) {
                forEach(item);
            }
            if (batchFinished) {
                if (false === batchFinished(batchSize)) return;
            }
            if (item === null) {
                if (finished) finished(batchSize);
            } else {
                t = Math.max(10000, Math.min(0.0001,
                                             targetTime / (Date.now() - t) ));
                setTimeout(go, 10, 0|(batchSize * t));
            }
        }
        go(0|16);
    }
    dom.batch = function batch(array, processBatch, finished)
    {
        function go(batchSize, start)
        {
            var nextStart = start + batchSize;
            var isLastBatch = (nextStart >= array.length);
            var t = Date.now();
            processBatch(array, start, Math.min(array.length, nextStart));
            if (isLastBatch) {
                console.log('Final batch size:', batchSize);
                if (finished) finished();
            } else {
                t = Math.max(10000, Math.min(0.0001,
                                             targetTime / (Date.now() - t) ));
                setTimeout(go, 10, 0|(batchSize * t), nextStart);
            }
        }
        go(0|16, 0|0);
    }


    /** {@link http://devdocs.io/xslt_xpath/xpath XPath} utility functions.
     *
     @example <caption>Print all entries in a FreeDict dictionary.</caption>
     * var dictXML = <parsed XML document>;// Parsing omitted in this example.
     * var xpath = dom.XPath.new(dictXML, {
     *     "tei": "http://www.tei-c.org/ns/1.0"
     * });
     * var iter = xpath('tei:TEI/tei:text/tei:body/tei:entry').iter(dictXML);
     * var formXPath = xpath('tei:form');
     * var orthXPath = xpath('tei:orth');
     * var pronXPath = xpath('tei:pron');
     * var defXPath  = xpath('tei:sense/tei:cit/tei:quote|tei:sense/tei:def');
     * dom.iterateAsync(iter, function forEach(entryElement) {
     *     var iter = formXPath.iter(entryElement);
     *     while ((element = iter.iterateNext())) {
     *         console.log(orthXPath.str(element),
     *                     pronXPath.str(element), ':');
     *     }
     *     iter = defXPath.iter(entryElement);
     *     while ((element = iter.iterateNext())) {
     *         console.log(element.textContent);
     *     }
     * }, function batchFinished(batchSize) {
     *     console.log('Just finished a batch of ' + batchSize +
     *                 ' iterations.');
     * }, function finished(batchSize) {
     *     console.log('Finished last batch of ' + batchSize +
     *                 ' iterations.');
     * });
     @namespace dom.XPath */

    /** Constructor for an expression compiler function
     * {@link dom.XPath~compile compile(path)}
     * specialized for the given document.
     *
     * When using namespaces, please note that in the XPath API
     * there is no way of specifying the default namespace:
     * if the document has namespaces, even if it does not use prefixes,
     * you must define and use a prefix in your XPath expressions.
     *
     @function dom.XPath~new
     @param {Document} document - context document: xpath expressions can
     only be used inside that document.
     @param {Object} ns - namespace map: { prefix: url, ... }
     @returns {Function} - an expression compiler function.
     @example
     * // Extract the title from a TEI document.
     * var xpath = dom.XPath.new(document, {
     *   "svg": "http://www.w3.org/2000/svg",
     *   "m":   "http://www.w3.org/1998/Math/MathML",
     *   "db":  "http://docbook.org/ns/docbook",
     *   "tei": "http://www.tei-c.org/ns/1.0"
     * });
     * var title = xpath('tei:TEI/tei:teiHeader//tei:title[1]').str(document);
     *
     * @example
     * // To avoid re-compiling the expressions each time
     * // you can either store them in variables or,
     * // if you prefer to leave the paths at the place where they are used,
     * // wrap the compiler function into a caching function like this:
     * var xpath = (function () {
     *     var cache = {};
     *     var xpath = dom.XPath.new(document, {
     *       "svg": "http://www.w3.org/2000/svg",
     *       "m":   "http://www.w3.org/1998/Math/MathML",
     *       "db":  "http://docbook.org/ns/docbook",
     *       "tei": "http://www.tei-c.org/ns/1.0"
     *     });
     *     return function compileAndCache(path) {
     *         return cache[path] || (cache[path] = xpath(path));
     *     };
     * })();
     * var title = xpath('tei:TEI/tei:teiHeader//tei:title[1]').str(document);
    */
    dom.XPath = { new: dom_XPath_new };
    function dom_XPath_new(document, ns)
    {
        if (!ns) ns = {};

        function namespaceURLMapper(prefix) { return ns[prefix]; }

        /** Compile the given XPath string.<br/>
         * This function is the value returned from the
         * {@link dom.XPath~new XPath.new()} constructor.
         *
         * The compiled expression has these additional methods
         * apart from the standard
         * XPathExpression.{@link  https://developer.mozilla.org/en-US/docs/Web/API/XPathExpression evaluate(contextNode, type, result)}:
         * - XPathExpression.{@link dom.XPath~node node(contextNode)}
         * - XPathExpression.{@link dom.XPath~iter iter(contextNode)}
         * - XPathExpression.{@link dom.XPath~bool bool(contextNode)}
         * - XPathExpression.{@link dom.XPath~num num(contextNode)}
         * - XPathExpression.{@link dom.XPath~str str(contextNode)}
         *
         @function dom.XPath~compile
         @returns {XPathExpression} - {@link https://developer.mozilla.org/en-US/docs/Web/API/XPathExpression}
        */
        function compile(path)
        {
            try {
                var exp = document.createExpression(path, namespaceURLMapper);
                exp.node  = node;
                exp.iter  = iter;
                exp.array = array;
                exp.bool  = bool;
                exp.num   = num;
                exp.str   = str;
                return exp;
            } catch (e) {
                error(e.message + '\n' + path, e.fileName, e.lineNumber);
            }
        }

        // The following functions use the numeric values from the standard
        // https://www.w3.org/TR/DOM-Level-3-XPath/xpath.html#XPathResult
        // instead of the constant names FIRST_ORDERED_NODE_TYPE etc.

        /** Find the first node matched by the given XPath string.<br/>
         * Corresponds to the {@link https://www.w3.org/TR/DOM-Level-3-XPath/xpath.html#XPathResult-FIRST-ORDERED-NODE-TYPE FIRST ORDERED NODE TYPE}
         * in XPath.
         *
         @function dom.XPath~node
         @param {Node} contextNode - starting point for the path.
         @returns {Node} - XPathResult.singleNodeValue<br/>{@link https://developer.mozilla.org/en-US/docs/Web/API/XPathResult}
        */
        function node(contextNode) {
            return this.evaluate(contextNode,
                                 9,// FIRST_ORDERED_NODE_TYPE
                                 null).singleNodeValue;
        };

        /** Find all nodes matched by this XPath expression compiled with
         * {@link dom.XPath~compile compile(path)}.<br/>
         * Corresponds to the {@link https://www.w3.org/TR/DOM-Level-3-XPath/xpath.html#XPathResult-UNORDERED-NODE-ITERATOR-TYPE UNORDERED NODE ITERATOR TYPE}
         * in XPath.
         *
         * This is exactly the same as calling
         * `.evaluate(contextNode, UNORDERED_NODE_ITERATOR_TYPE, null)`.
         *
         * Other types of iterators are
         * `ORDERED_NODE_ITERATOR_TYPE`,
         * `UNORDERED_NODE_SNAPSHOT_TYPE`,
         * `ORDERED_NODE_SNAPSHOT_TYPE`.
         *
         @function dom.XPath~iter
         @param {Node} contextNode - starting point for the path.
         @returns {XPathResult} - iterator with `.iterateNext()`<br/>{@link https://developer.mozilla.org/en-US/docs/Web/API/XPathResult} → Node
        */
        function iter(contextNode)
        {
            return this.evaluate(contextNode,
                                 4,// UNORDERED_NODE_ITERATOR_TYPE
                                 null);
        }

        /** Build an array with all items from the iterator.
         *
         @function dom.XPath~array
         @param {Node} contextNode - starting point for the path.
         @returns {Array} - array with the results from the iterator.
        */
        function array(contextNode)
        {
            var array = [];
            var item, iter = this.iter(contextNode);
            while ((item = iter.iterateNext())) array.push(item);
            return array;
        }

        /** Check whether any node matches this XPath expression compiled with
         * {@link dom.XPath~compile compile(path)}.<br/>
         * Corresponds to the {@link https://www.w3.org/TR/DOM-Level-3-XPath/xpath.html#XPathResult-BOOLEAN-TYPE BOOLEAN TYPE}
         * in XPath.
         *
         @function dom.XPath~bool
         @param {Node} contextNode - starting point for the path.
         @returns {boolean} - XPathResult.booleanValue<br/>{@link https://developer.mozilla.org/en-US/docs/Web/API/XPathResult}
        */
        function bool(contextNode) {
            return this.evaluate(contextNode,
                                 3,// XPathResult.BOOLEAN_TYPE
                                 null).booleanValue;
        }

        /** Get the number value of the first node matched by this XPath expression compiled with
          {@link dom.XPath~compile compile(path)}.<br/>
         * Corresponds to the {@link https://www.w3.org/TR/DOM-Level-3-XPath/xpath.html#XPathResult-NUMBER-TYPE NUMBER TYPE}
         * in XPath.
         *
         @function dom.XPath~num
         @param {Node} contextNode - starting point for the path.
         @returns {Number} - XPathResult.numberValue<br/>{@link https://developer.mozilla.org/en-US/docs/Web/API/XPathResult}
        */
        function num(contextNode) {
            return this.evaluate(contextNode,
                                 1,// XPathResult.NUMBER_TYPE
                                 null).numberValue;
        }

        /** Get the string value of the first node matched by this XPath expression compiled with
          {@link dom.XPath~compile compile(path)}.<br/>
         * Corresponds to the {@link https://www.w3.org/TR/DOM-Level-3-XPath/xpath.html#XPathResult-STRING-TYPE STRING TYPE}
         * in XPath.
         *
         @function dom.XPath~str
         @param {Node} contextNode - starting point for the path.
         @returns {String} - XPathResult.stringValue<br/>{@link https://developer.mozilla.org/en-US/docs/Web/API/XPathResult}
        */
        function str(contextNode) {
            return this.evaluate(contextNode,
                                 2,// XPathResult.STRING_TYPE
                                 null).stringValue;
        }

        return compile;
    }


    /** Bi-directional model ⟷ DOM bindings.
     *
     @property {Object} model - the model passed to the constructor.
     @namespace dom.Nexus */

    /** Constructor.
     *
     @function dom.Nexus~new
     @param {Object} model - a plain object storing your model.
     @memberof dom.Nexus
     @example
     * var nexus = dom.Nexus.new(model);
    */
    dom.Nexus = {
        new: function (model) { return new Nexus(model); }
    };
    function Nexus(model)
    {
        this.model          = model;
        this.bind           = bind;
        this.changed        = changed;
        this.addListener    = addListener;
        this.removeListener = removeListener;

        var bindCache = {};
        var listeners = {};
        var stack = [];

        /** Bind a property in `model` to a component, on the given event name.
         *
         * A component is an object with three fields:
         * - new: function ComponentName(property, nexus, ...)
         *   → `Element`<br/>
         *   Build a new HTML element with any structure you need,
         *   and bind it to the given `property` in the model in `nexus`.<br/>
         *   `property` is the property/field name in the model, and<br/>
         *   `nexus` is a Nexus object built with `new dom.Nexus(model)`<br/>
         *   other parameters depend on the component.
         *   You can define this function with any parameters in any order,
         *   but normally you will need at least `property` and `nexus`
         *   to bind it to the model.<br/>
         *   Useful for creating the whole view in Javascript.
         * - use: function ComponentName(view, property, nexus, ...)
         *   → `Element`<br/>
         *   Take the given HTML
         *   and bind it to the given `property` in the model in `nexus`.<br/>
         *   Useful for activating views built in HTML.
         * - get: function get(element) → `string`
         * - set: function set(element, value)
         *
         * @example <caption>Simplistic HTML editor with preview</caption>
         * <!DOCTYPE "html">
         * <html>
         *   <head>
         *     <meta charset="utf-8"/>
         *     <style type="text/css">
         *       textarea { float:left; margin-right:1em; width:30%; }
         *     </style>
         *   </head>
         *   <body>
         *     <script src="dom.js"></script>
         *     <script type="application/javascript">
         *       var model = { html: '<h1>Hello!</h1>\n<p>How are you?</p>\n' };
         *       var nexus = dom.Nexus.new(model);// nexus.model === model
         *
         *       var TextArea = {
         *           new: function TextArea(property, nexus, attributes) {
         *               var view = dom(['textarea', attributes]);
         *               view.setAttribute('name', property);
         *               return this.use(view, nexus);
         *           },
         *           use: function TextArea(view, nexus) {
         *               return nexus.bind(this, view.name, 'input', view);
         *           },
         *           get: function get(view) { return view.value; },
         *           set: function set(view, value) { view.value = value; }
         *       };
         *
         *       // This adds the component to the document's body element:
         *       dom([document.body, TextArea.new('html', nexus, {
         *         'autofocus': true,
         *         'placeholder': 'Some text',
         *         'rows': 40
         *       })]);
         *
         *       var htmlPreview = dom(['div.preview']);
         *       dom([document.body, htmlPreview]);
         *       // Watch changes in model.currentText and parse it as HTML:
         *       nexus.addListener('html', function (model, name) {
         *           htmlPreview.innerHTML = model[name];
         *       });
         *     </script>
         *   </body>
         * </html>
         @function dom.Nexus~bind
         @param {Component} component - object with new, use, get, set members.
         @param {string} name - property name in the model.
         @param {string} [eventName] - event fired when the component changes,
         can be `null` to have only unidirectional binding
         from property to view, useful for instance in progress bars.
         @param {Element} view - HTML element that is the view of the component.
        */
        function bind(component, name, eventName, view)
        {
            var key = name + '.' + (eventName||'') + '.' + component.new.name;
            return (bindCache[key] || (bindCache[key] = function binder(view) {
                if (eventName) {
                    view.addEventListener(eventName, function (event) {
                        model[name] = component.get(event.target);
                        changed(name, view);
                    });
                }
                addListener(name, function (model, name, origin) {
                    if (origin !== view) component.set(view, model[name]);
                });
                return view;
            }))(view);
        }

        /** Notify this Nexus of external changes in the model.
         *
         * This can be nested: one Nexus for the main model,
         * others for sub-models inside.
         * @example <caption>Nested models</caption>
         * var model = { someProperty: "someValue", submodel: { "a": 1 } };
         * var parentNexus = dom.Nexus.new(model);
         * var childNexus = dom.Nexus.new(model.submodel);
         * // Notify parent when properties change here:
         * childNexus.addListener("*", function (model, property, origin) {
         *     parentNexus.changed('submodel', origin);
         * });
         *
         @function dom.Nexus~changed
         @param {string} name - field name in the model,
         or '\*' to indicate changes in unspecified fields
         so that all listeners will be notified.
         *Several names* can be given at once as an array of strings:
         in that case, the listeners
         ({@link dom.Nexus~addListener dom.Nexus.addListener(name, listener)})
         will receive the first name their `property` parameter.
         @param {Element} [origin] - view that caused the change, or null.
         You might use this to avoid infinite loops:
         the origin element will not recieve this event.
        */
        function changed(name, origin)
        {
            var ll = [];
            if ('string' === typeof name) name = [name];
            name.forEach(function (name) {
                if ('*' === name) {
                    Object.keys(listeners).forEach(function (name) {
                        if ('*' !== name) ll.concat(listeners[name]);
                    });
                } else if (listeners.hasOwnProperty(name)) {
                    ll = ll.concat(listeners[name]);
                }
            });
            if (listeners.hasOwnProperty('*')) {
                ll = ll.concat(listeners['*']);
            }
            for (var i = 0; i < ll.length; ++i) {
                var l = ll[i];
                if (ll.indexOf(l) !== i || l.f === origin) continue;
                if (stack.indexOf(l) !== -1) {
                    return console.error(
                        'Domo listener loop',
                        stack.concat([l]).map(function (l) {return l.f.name;})
                    );
                }
                stack.push(l);
                l.f(model, name[0], origin);
                stack.pop();
            }
        }

        /** Add a listener for changes in a field in the model.
         * The listener will be called immediately as a way of triggering
         * initialization. In this first call, the `origin` parameter
         * will be a literal `false` to distinguish it from later changes.
         *
         @function dom.Nexus~addListener
         @param {string} name - field name in the model,
         or '\*' to listen for any changes.
         @param {Listener} listener - `function (model, name, origin)`
         that will be notified of changes, where
         `model` is the model,
         `name` is the name of the model property that changed, and
         `origin` is the component that caused the change
         (which can be `undefined`).
         @returns {Object} - reference that can be used later to remove this
         listener with {@link dom.Nexus~removeListener dom.Nexus.removeListener(listenerReference)}.
        */
        function addListener(name, listener) {
            var l = { f:listener, c:[] };
            (listeners[name] || (listeners[name] = [])).push(l);
            stack.forEach(function (p) { p.c.push(l); });
            stack.push(l);
            listener(model, name, false);
            stack.pop();
            return l;
        }

        /** Remove a listener and all its children.
         *  A listener’s children are all listeners added by
         * its listener function.
         @param {Object} listenerReference - an object reference returned by
         {@link dom.Nexus~addListener dom.Nexus.addListener(name, listener)}.
         */
        function removeListener(listenerReference) {
            var toRemove = listenerReference.c.slice();
            toRemove.push(listenerReference);
            // https://caniuse.com/#search=object.values
            // var allLists = Object.values(listeners);
            var allLists = Object.keys(listeners).map(function (name) {
                return listeners[name];
            });
            allLists.forEach(function (ll) {
                var i = ll.length;
                while (i) if (toRemove.indexOf(ll[--i]) !== -1) ll.splice(i, 1);
            });
        }
    }

    function error(message, file, line)
    {
        throw new Error(message, file, line);
    }

    return dom;
})(document);