UNPKG

hyperscript.org

Version:

a small scripting language for the web

1,216 lines (1,111 loc) 238 kB
///========================================================================= /// This module provides the core runtime and grammar for hyperscript ///========================================================================= //AMD insanity /** @var {HyperscriptObject} _hyperscript */ (function (root, factory) { if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. define([], factory); } else { // Browser globals root._hyperscript = factory(); } }(typeof self !== 'undefined' ? self : this, function () { return (function () { 'use strict'; //==================================================================== // Utilities //==================================================================== /** * mergeObjects combines the keys from obj2 into obj2, then returns obj1 * * @param {object} obj1 * @param {object} obj2 * @returns object */ function mergeObjects(obj1, obj2) { for (var key in obj2) { if (obj2.hasOwnProperty(key)) { obj1[key] = obj2[key]; } } return obj1; } /** * parseJSON parses a JSON string into a corresponding value. If the * value passed in is not valid JSON, then it logs an error and returns `null`. * * @param {string} jString * @returns any */ function parseJSON(jString) { try { return JSON.parse(jString); } catch(error) { logError(error); return null; } } /** * logError writes an error message to the Javascript console. It can take any * value, but msg should commonly be a simple string. * @param {*} msg */ function logError(msg) { if(console.error) { console.error(msg); } else if (console.log) { console.log("ERROR: ", msg); } } // TODO: JSDoc description of what's happening here function varargConstructor(Cls, args) { return new (Cls.bind.apply(Cls, [Cls].concat(args))); } var globalScope = (typeof self !== 'undefined') ? self : ((typeof global !== 'undefined') ? global : this); //==================================================================== // Lexer //==================================================================== /** @type LexerObject */ var _lexer = function () { var OP_TABLE = { '+': 'PLUS', '-': 'MINUS', '*': 'MULTIPLY', '/': 'DIVIDE', '.': 'PERIOD', '..': 'ELLIPSIS', '\\': 'BACKSLASH', ':': 'COLON', '%': 'PERCENT', '|': 'PIPE', '!': 'EXCLAMATION', '?': 'QUESTION', '#': 'POUND', '&': 'AMPERSAND', '$': 'DOLLAR', ';': 'SEMI', ',': 'COMMA', '(': 'L_PAREN', ')': 'R_PAREN', '<': 'L_ANG', '>': 'R_ANG', '<=': 'LTE_ANG', '>=': 'GTE_ANG', '==': 'EQ', '===': 'EQQ', '!=': 'NEQ', '!==': 'NEQQ', '{': 'L_BRACE', '}': 'R_BRACE', '[': 'L_BRACKET', ']': 'R_BRACKET', '=': 'EQUALS' }; /** * isValidCSSClassChar returns `true` if the provided character is valid in a CSS class. * @param {string} c * @returns boolean */ function isValidCSSClassChar(c) { return isAlpha(c) || isNumeric(c) || c === "-" || c === "_" || c === ":"; } /** * isValidCSSIDChar returns `true` if the provided character is valid in a CSS ID * @param {string} c * @returns boolean */ function isValidCSSIDChar(c) { return isAlpha(c) || isNumeric(c) || c === "-" || c === "_" || c === ":"; } /** * isWhitespace returns `true` if the provided character is whitespace. * @param {string} c * @returns boolean */ function isWhitespace(c) { return c === " " || c === "\t" || isNewline(c); } /** * positionString returns a string representation of a Token's line and column details. * @param {Token} token * @returns string */ function positionString(token) { return "[Line: " + token.line + ", Column: " + token.col + "]" } /** * isNewline returns `true` if the provided character is a carrage return or newline * @param {string} c * @returns boolean */ function isNewline(c) { return c === '\r' || c === '\n'; } /** * isNumeric returns `true` if the provided character is a number (0-9) * @param {string} c * @returns boolean */ function isNumeric(c) { return c >= '0' && c <= '9'; } /** * isAlpha returns `true` if the provided character is a letter in the alphabet * @param {string} c * @returns boolean */ function isAlpha(c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); } /** * @param {string} c * @param {boolean} [dollarIsOp] * @returns boolean */ function isIdentifierChar(c) { return (c === "_" || c === "$"); } /** * @param {string} c * @returns boolean */ function isReservedChar(c) { return (c === "`" || c === "^"); } /** * @param {Token[]} tokens * @param {Token[]} consumed * @param {string} source * @returns TokensObject */ function makeTokensObject(tokens, consumed, source) { consumeWhitespace(); // consume initial whitespace var _lastConsumed = null; function consumeWhitespace(){ while(token(0, true).type === "WHITESPACE") { consumed.push(tokens.shift()); } } /** * @param {Token[]} tokens * @param {*} error */ function raiseError(tokens, error) { _parser.raiseParseError(tokens, error); } /** * @param {string} value * @returns [Token] */ function requireOpToken(value) { var token = matchOpToken(value); if (token) { return token; } else { raiseError(this, "Expected '" + value + "' but found '" + currentToken().value + "'"); } } /** * @param {string} op1 * @param {string} [op2] * @param {string} [op3] * @returns [Token] */ function matchAnyOpToken(op1, op2, op3) { for (var i = 0; i < arguments.length; i++) { var opToken = arguments[i]; var match = matchOpToken(opToken); if (match) { return match; } } } /** * @param {string} op1 * @param {string} [op2] * @param {string} [op3] * @returns [Token] */ function matchAnyToken(op1, op2, op3) { for (var i = 0; i < arguments.length; i++) { var opToken = arguments[i]; var match = matchToken(opToken); if (match) { return match; } } } /** * @param {string} value * @returns [Token] */ function matchOpToken(value) { if (currentToken() && currentToken().op && currentToken().value === value) { return consumeToken(); } } /** * @param {string} type1 * @param {string} type2 * @param {string} type3 * @param {string} type4 * @returns Token */ function requireTokenType(type1, type2, type3, type4) { var token = matchTokenType(type1, type2, type3, type4); if (token) { return token; } else { raiseError(this, "Expected one of " + JSON.stringify([type1, type2, type3])); } } /** * @param {string} type1 * @param {string} type2 * @param {string} type3 * @param {string} type4 * @returns [Token] */ function matchTokenType(type1, type2, type3, type4) { if (currentToken() && currentToken().type && [type1, type2, type3, type4].indexOf(currentToken().type) >= 0) { return consumeToken(); } } /** * @param {string} value * @param {string} [type] * @returns Token */ function requireToken(value, type) { var token = matchToken(value, type); if (token) { return token; } else { raiseError(this, "Expected '" + value + "' but found '" + currentToken().value + "'"); } } /** * @param {string} value * @param {string} [type] * @returns [Token] */ function matchToken(value, type) { var type = type || "IDENTIFIER"; if (currentToken() && currentToken().value === value && currentToken().type === type) { return consumeToken(); } } /** * @returns Token */ function consumeToken() { var match = tokens.shift(); consumed.push(match); _lastConsumed = match; consumeWhitespace(); // consume any whitespace return match; } /** * @param {string} value * @param {string} [type] * @returns Token[] */ function consumeUntil(value, type) { /** @type Token[] */ var tokenList = []; var currentToken = token(0, true); while ((type == null || currentToken.type !== type) && (value == null || currentToken.value !== value) && currentToken.type !== "EOF") { var match = tokens.shift(); consumed.push(match); tokenList.push(currentToken); currentToken = token(0, true); } consumeWhitespace(); // consume any whitespace return tokenList; } /** * @returns string */ function lastWhitespace() { if (consumed[consumed.length - 1] && consumed[consumed.length - 1].type === "WHITESPACE") { return consumed[consumed.length - 1].value; } else { return ""; } } function consumeUntilWhitespace() { return consumeUntil(null, "WHITESPACE"); } /** * @returns boolean */ function hasMore() { return tokens.length > 0; } /** * @param {number} n * @param {boolean} [dontIgnoreWhitespace] * @returns Token */ function token(n, dontIgnoreWhitespace) { /** @type {Token} */ var token; var i = 0; do { if (!dontIgnoreWhitespace) { while (tokens[i] && tokens[i].type === "WHITESPACE") { i++; } } token = tokens[i]; n--; i++; } while (n > -1) if (token) { return token; } else { return { type:"EOF", value:"<<<EOF>>>" } } } /** * @returns Token */ function currentToken() { return token(0); } function lastMatch() { return _lastConsumed; } function sourceFor() { return source.substring(this.startToken.start, this.endToken.end); } function lineFor() { return source .split("\n")[this.startToken.line - 1]; } return { matchAnyToken: matchAnyToken, matchAnyOpToken: matchAnyOpToken, matchOpToken: matchOpToken, requireOpToken: requireOpToken, matchTokenType: matchTokenType, requireTokenType: requireTokenType, consumeToken: consumeToken, matchToken: matchToken, requireToken: requireToken, list: tokens, consumed: consumed, source: source, hasMore: hasMore, currentToken: currentToken, lastMatch: lastMatch, token: token, consumeUntil: consumeUntil, consumeUntilWhitespace: consumeUntilWhitespace, lastWhitespace: lastWhitespace, sourceFor: sourceFor, lineFor: lineFor } } function isValidSingleQuoteStringStart(tokens) { if (tokens.length > 0) { var previousToken = tokens[tokens.length - 1]; if (previousToken.type === "IDENTIFIER" || previousToken.type === "CLASS_REF" || previousToken.type === "ID_REF") { return false; } if(previousToken.op && (previousToken.value === '>' || previousToken.value === ')')){ return false; } } return true; } /** * @param {string} string * @param {boolean} [template] * @returns TokensObject */ function tokenize(string, template) { /** @type Token[]*/ var tokens = []; var source = string; var position = 0; var column = 0; var line = 1; var lastToken = "<START>"; var templateBraceCount = 0; function inTemplate() { return template && templateBraceCount === 0; } while (position < source.length) { if (currentChar() === "-" && nextChar() === "-") { consumeComment(); } else { if (isWhitespace(currentChar())) { tokens.push(consumeWhitespace()); } else if (!possiblePrecedingSymbol() && currentChar() === "." && isAlpha(nextChar())) { tokens.push(consumeClassReference()); } else if (!possiblePrecedingSymbol() && currentChar() === "#" && isAlpha(nextChar())) { tokens.push(consumeIdReference()); } else if (isAlpha(currentChar()) || (!inTemplate() && isIdentifierChar(currentChar()))) { tokens.push(consumeIdentifier()); } else if (isNumeric(currentChar())) { tokens.push(consumeNumber()); } else if (!inTemplate() && (currentChar() === '"' || currentChar() === "`")) { tokens.push(consumeString()); } else if (!inTemplate() && currentChar() === "'") { if (isValidSingleQuoteStringStart(tokens)) { tokens.push(consumeString()); } else { tokens.push(consumeOp()); } } else if (OP_TABLE[currentChar()]) { if (lastToken === '$' && currentChar() === '{') { templateBraceCount++; } if (currentChar() === '}') { templateBraceCount--; } tokens.push(consumeOp()); } else if (inTemplate() || isReservedChar(currentChar())) { tokens.push(makeToken('RESERVED', consumeChar())) } else { if (position < source.length) { throw Error("Unknown token: " + currentChar() + " "); } } } } return makeTokensObject(tokens, [], source); /** * @param {string} [type] * @param {string} [value] * @returns Token */ function makeOpToken(type, value) { var token = makeToken(type, value); token.op = true; return token; } /** * @param {string} [type] * @param {string} [value] * @returns Token */ function makeToken(type, value) { return { type: type, value: value, start: position, end: position + 1, column: column, line: line }; } function consumeComment() { while (currentChar() && !isNewline(currentChar())) { consumeChar(); } consumeChar(); } /** * @returns Token */ function consumeClassReference() { var classRef = makeToken("CLASS_REF"); var value = consumeChar(); while (isValidCSSClassChar(currentChar())) { value += consumeChar(); } classRef.value = value; classRef.end = position; return classRef; } /** * @returns Token */ function consumeIdReference() { var idRef = makeToken("ID_REF"); var value = consumeChar(); while (isValidCSSIDChar(currentChar())) { value += consumeChar(); } idRef.value = value; idRef.end = position; return idRef; } /** * @returns Token */ function consumeIdentifier() { var identifier = makeToken("IDENTIFIER"); var value = consumeChar(); while (isAlpha(currentChar()) || isIdentifierChar(currentChar())) { value += consumeChar(); } identifier.value = value; identifier.end = position; return identifier; } /** * @returns Token */ function consumeNumber() { var number = makeToken("NUMBER"); var value = consumeChar(); while (isNumeric(currentChar())) { value += consumeChar(); } if ((currentChar() === ".") && isNumeric(nextChar())) { value += consumeChar(); } while (isNumeric(currentChar())) { value += consumeChar(); } number.value = value; number.end = position; return number; } /** * @returns Token */ function consumeOp() { var op = makeOpToken(); var value = consumeChar(); // consume leading char while (currentChar() && OP_TABLE[value + currentChar()]) { value += consumeChar(); } op.type = OP_TABLE[value]; op.value = value; op.end = position; return op; } /** * @returns Token */ function consumeString() { var string = makeToken("STRING"); var startChar = consumeChar(); // consume leading quote var value = ""; while (currentChar() && currentChar() !== startChar) { if (currentChar() === "\\") { consumeChar(); // consume escape char and move on } value += consumeChar(); } if (currentChar() !== startChar) { throw Error("Unterminated string at " + positionString(string)); } else { consumeChar(); // consume final quote } string.value = value; string.end = position; string.template = startChar === "`"; return string; } /** * @returns string */ function currentChar() { return source.charAt(position); } /** * @returns string */ function nextChar() { return source.charAt(position + 1); } /** * @returns string */ function consumeChar() { lastToken = currentChar(); position++; column++; return lastToken; } /** * @returns boolean */ function possiblePrecedingSymbol() { return isAlpha(lastToken) || isNumeric(lastToken) || lastToken === ")" || lastToken === "}" || lastToken === "]" } /** * @returns Token */ function consumeWhitespace() { var whitespace = makeToken("WHITESPACE"); var value = ""; while (currentChar() && isWhitespace(currentChar())) { if (isNewline(currentChar())) { column = 0; line++; } value += consumeChar(); } whitespace.value = value; whitespace.end = position; return whitespace; } } return { tokenize: tokenize, makeTokensObject: makeTokensObject } }(); //==================================================================== // Parser //==================================================================== /** @type ParserObject */ var _parser = function () { /** @type {Object<string,GrammarDefinition>} */ var GRAMMAR = {} /** @type {Object<string,CommandDefinition>} */ var COMMANDS = {} /** @type {Object<string,FeatureDefinition>} */ var FEATURES = {} var LEAF_EXPRESSIONS = []; var INDIRECT_EXPRESSIONS = []; /** * @param {*} parseElement * @param {*} start * @param {TokensObject} tokens */ function initElt(parseElement, start, tokens) { parseElement.startToken = start; parseElement.sourceFor = tokens.sourceFor; parseElement.lineFor = tokens.lineFor; parseElement.programSource = tokens.source; } /** * @param {string} type * @param {TokensObject} tokens * @param {*} root * @returns GrammarElement */ function parseElement(type, tokens, root) { var elementDefinition = GRAMMAR[type]; if (elementDefinition) { var start = tokens.currentToken(); var parseElement = elementDefinition(_parser, _runtime, tokens, root); if (parseElement) { initElt(parseElement, start, tokens); parseElement.endToken = parseElement.endToken || tokens.lastMatch() var root = parseElement.root; while (root != null) { initElt(root, start, tokens); root = root.root; } } return parseElement; } } /** * @param {string} type * @param {TokensObject} tokens * @param {string} [message] * @param {*} [root] * @returns GrammarElement */ function requireElement(type, tokens, message, root) { var result = parseElement(type, tokens, root); return result || raiseParseError(tokens, message || "Expected " + type); } /** * @param {string[]} types * @param {TokensObject} tokens * @returns GrammarElement */ function parseAnyOf(types, tokens) { for (var i = 0; i < types.length; i++) { var type = types[i]; var expression = parseElement(type, tokens); if (expression) { return expression; } } } /** * @param {string} name * @param {GrammarDefinition} definition */ function addGrammarElement(name, definition) { GRAMMAR[name] = definition; } /** * @param {string} keyword * @param {CommandDefinition} definition */ function addCommand(keyword, definition) { var commandGrammarType = keyword + "Command"; var commandDefinitionWrapper = function (parser, runtime, tokens) { var commandElement = definition(parser, runtime, tokens); if (commandElement) { commandElement.type = commandGrammarType; commandElement.execute = function (context) { context.meta.command = commandElement; return runtime.unifiedExec(this, context); } return commandElement; } }; GRAMMAR[commandGrammarType] = commandDefinitionWrapper; COMMANDS[keyword] = commandDefinitionWrapper; } /** * @param {string} keyword * @param {FeatureDefinition} definition */ function addFeature(keyword, definition) { var featureGrammarType = keyword + "Feature"; /** @type FeatureDefinition*/ var featureDefinitionWrapper = function (parser, runtime, tokens) { var featureElement = definition(parser, runtime, tokens); if (featureElement) { featureElement.keyword = keyword; featureElement.type = featureGrammarType; return featureElement; } }; GRAMMAR[featureGrammarType] = featureDefinitionWrapper; FEATURES[keyword] = featureDefinitionWrapper; } /** * @param {string} name * @param {ExpressionDefinition} definition */ function addLeafExpression(name, definition) { LEAF_EXPRESSIONS.push(name); addGrammarElement(name, definition); } /** * @param {string} name * @param {ExpressionDefinition} definition */ function addIndirectExpression(name, definition) { INDIRECT_EXPRESSIONS.push(name); addGrammarElement(name, definition); } /* ============================================================================================ */ /* Core hyperscript Grammar Elements */ /* ============================================================================================ */ addGrammarElement("feature", function(parser, runtime, tokens) { if (tokens.matchOpToken("(")) { var featureDefinition = parser.requireElement("feature", tokens); tokens.requireOpToken(")"); return featureDefinition; } else { var featureDefinition = FEATURES[tokens.currentToken().value]; if (featureDefinition) { return featureDefinition(parser, runtime, tokens); } } }) addGrammarElement("command", function(parser, runtime, tokens) { if (tokens.matchOpToken("(")) { var commandDefinition = parser.requireElement("command", tokens); tokens.requireOpToken(")"); return commandDefinition; } else { var commandDefinition = COMMANDS[tokens.currentToken().value]; if (commandDefinition) { var commandDef = commandDefinition(parser, runtime, tokens); } else if (tokens.currentToken().type === "IDENTIFIER" && tokens.token(1).value === "(") { var commandDef = parser.requireElement("pseudoCommand", tokens); } if (commandDef) { return parser.parseElement("indirectStatement", tokens, commandDef); } return commandDef; } }) addGrammarElement("commandList", function(parser, runtime, tokens) { var cmd = parser.parseElement("command", tokens); if (cmd) { tokens.matchToken("then"); cmd.next = parser.parseElement("commandList", tokens); return cmd; } }) addGrammarElement("leaf", function(parser, runtime, tokens) { var result = parseAnyOf(LEAF_EXPRESSIONS, tokens); // symbol is last so it doesn't consume any constants if (result == null) { return parseElement('symbol', tokens); } else { return result; } }) addGrammarElement("indirectExpression", function(parser, runtime, tokens, root) { for (var i = 0; i < INDIRECT_EXPRESSIONS.length; i++) { var indirect = INDIRECT_EXPRESSIONS[i]; root.endToken = tokens.lastMatch(); var result = parser.parseElement(indirect, tokens, root); if(result){ return result; } } return root; }); addGrammarElement("indirectStatement", function(parser, runtime, tokens, root) { if (tokens.matchToken('unless')) { root.endToken = tokens.lastMatch(); var conditional = parser.requireElement('expression', tokens); var unless = { type: 'unlessStatementModifier', args: [conditional], op: function (context, conditional) { if (conditional) { return this.next; } else { return root; } }, execute: function(context) { return runtime.unifiedExec(this, context); } }; root.parent = unless; return unless } return root; }); addGrammarElement("primaryExpression", function(parser, runtime, tokens) { var leaf = parser.parseElement("leaf", tokens); if (leaf) { return parser.parseElement("indirectExpression", tokens, leaf); } parser.raiseParseError(tokens, "Unexpected value: " + tokens.currentToken().value); }); /* ============================================================================================ */ /* END Core hyperscript Grammar Elements */ /* ============================================================================================ */ /** * * @param {TokensObject} tokens * @returns string */ function createParserContext(tokens) { var currentToken = tokens.currentToken(); var source = tokens.source; var lines = source.split("\n"); var line = currentToken ? currentToken.line - 1 : lines.length - 1; var contextLine = lines[line]; var offset = currentToken ? currentToken.column : contextLine.length - 1; return contextLine + "\n" + " ".repeat(offset) + "^^\n\n"; } /** * @param {*} tokens * @param {string} message */ function raiseParseError(tokens, message) { message = (message || "Unexpected Token : " + tokens.currentToken().value) + "\n\n" + createParserContext(tokens); var error = new Error(message); error['tokens'] = tokens; throw error } /** * @param {TokensObject} tokens * @returns */ function parseHyperScript(tokens) { return parseElement("hyperscript", tokens) } /** * @param {*} elt * @param {*} parent */ function setParent(elt, parent) { if (elt) { elt.parent = parent; setParent(elt.next, parent); } } /** * @param {Token} token * @returns */ function commandStart(token){ return COMMANDS[token.value]; } /** * @param {Token} token * @returns */ function featureStart(token){ return FEATURES[token.value]; } /** * @param {Token} token * @returns */ function commandBoundary(token) { if (token.value == "end" || token.value == "then" || token.value == "else" || token.value == ")" || commandStart(token) || featureStart(token) || token.type == "EOF") { return true; } } /** * @param {TokensObject} tokens * @returns */ function parseStringTemplate(tokens) { var returnArr = [""]; do { returnArr.push(tokens.lastWhitespace()); if (tokens.currentToken().value === "$") { tokens.consumeToken(); var startingBrace = tokens.matchOpToken('{'); returnArr.push(requireElement("expression", tokens)); if(startingBrace){ tokens.requireOpToken("}"); } returnArr.push(""); } else if (tokens.currentToken().value === "\\") { tokens.consumeToken(); // skip next tokens.consumeToken() } else { var token = tokens.consumeToken(); returnArr[returnArr.length - 1] += token.value; } } while (tokens.hasMore()) returnArr.push(tokens.lastWhitespace()); return returnArr; } // parser API return /** @type ParserObject */ { setParent: setParent, requireElement: requireElement, parseElement: parseElement, featureStart: featureStart, commandStart: commandStart, commandBoundary: commandBoundary, parseAnyOf: parseAnyOf, parseHyperScript: parseHyperScript, raiseParseError: raiseParseError, addGrammarElement: addGrammarElement, addCommand: addCommand, addFeature: addFeature, addLeafExpression: addLeafExpression, addIndirectExpression: addIndirectExpression, parseStringTemplate: parseStringTemplate, } }(); //==================================================================== // Runtime //==================================================================== var CONVERSIONS = { dynamicResolvers : [], "String" : function(val){ if(val.toString){ return val.toString(); } else { return "" + val; } }, "Int" : function(val){ return parseInt(val); }, "Float" : function(val){ return parseFloat(val); }, "Number" : function(val){ return Number(val); }, "Date" : function(val){ return Date(val); }, "Array" : function(val){ return Array.from(val); }, "JSON" : function(val){ return JSON.stringify(val); }, "Object" : function(val){ if (val instanceof String) { val = val.toString() } if (typeof val === 'string') { return JSON.parse(val); } else { return mergeObjects({}, val); } } } var _runtime = function () { /** * @param {HTMLElement} elt * @param {string} selector * @returns boolean */ function matchesSelector(elt, selector) { // noinspection JSUnresolvedVariable var matchesFunction = elt.matches || elt.matchesSelector || elt.msMatchesSelector || elt.mozMatchesSelector || elt.webkitMatchesSelector || elt.oMatchesSelector; return matchesFunction && matchesFunction.call(elt, selector); } /** * @param {string} eventName * @param {{}} detail * @returns */ function makeEvent(eventName, detail) { var evt; if (window.CustomEvent && typeof window.CustomEvent === 'function') { evt = new CustomEvent(eventName, {bubbles: true, cancelable: true, detail: detail}); } else { evt = document.createEvent('CustomEvent'); evt.initCustomEvent(eventName, true, true, detail); } return evt; } /** * @param {HTMLElement} elt * @param {string} eventName * @param {{}} [detail] * @returns boolean */ function triggerEvent(elt, eventName, detail) { var detail = detail || {}; detail["sentBy"] = elt; var event = makeEvent(eventName, detail); var eventResult = elt.dispatchEvent(event); return eventResult; } /** * isArrayLike returns `true` if the provided value is an array or * a NodeList (which is close enough to being an array for our purposes). * * @param {any} value * @returns {value is Array | NodeList} */ function isArrayLike(value) { return Array.isArray(value) || value instanceof NodeList; } /** * forEach executes the provided `func` on every item in the `value` array. * if `value` is a single item (and not an array) then `func` is simply called * once. If `value` is null, then no further actions are taken. * * @function * @template T * @param {T | T[]} value * @param {(item:T) => void} func */ function forEach(value, func) { if (value == null) { // do nothing