UNPKG

infuse.host

Version:

Infuse your HTML with dynamic content.

335 lines (286 loc) 12.5 kB
import configs from './configs.js'; import { camelCase } from './utils.js'; import splitFragments, { joinFragments } from './splitFragments.js'; /** * Obtain a reference to the `AsyncFunction` constructor since it's not a global variable. * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncFunction */ const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor; /** * Searches for, and returns, a variable or event name within a string as indicated by the given * regular expression or string prefix. Returns `null` if the string doesn't match the regular * expression or prefix. This function is used to extract variable names or event names from * certain type of HTML attributes (for instance: "const-foo", "onclick", and "watch-host"). * The following examples will return the same value "foo": * * searchName('c-foo', 'c-'); * searchName('const-foo', 'const-'); * searchName('const-foo', /^const-(\w+)$/); * searchName('foo-const', /^(\w+)-const$/); * searchName('var-foo-const', /^var-(\w+)-const$/); * * @function searchName * @param {string} str The string in which to search for the variable or event name. * @param {(string|RegExp)} exp If it's a string, it's used as a prefix to check if `str` starts * with `exp`. If it's a regular expression, it's used to extract the variable or event name * from `str` (it must contain parentheses indicating the location of the name). * @returns {string} The variable or event name if one was found. Returns `null` otherwise. */ export function searchName(str, exp) { let result; if (exp instanceof RegExp && (result = exp.exec(str)) !== null) { return result[1] || null; } if (typeof exp === 'string' && str.length > exp.length && str.startsWith(exp)) { return str.substr(exp.length); } return null; } /** * Searches the attributes and child nodes of an element to parse: * * * Constants: Declaration of constants using "const-[variable-name]" attributes. * * Events: Events in which the element, or parts of the element, will be re-infused. * * Iteration constants: Variable names, as defined by the "for" attribute of a template element, * to use when iterating over a collection of values as defined by the template's "each", "of", * or "in" attributes. * * Parts: Parts of the element that have expressions or template literals. * * Attributes. * * Boolean attributes. * * Properties. * * Text child nodes. * * @function parseParts * @param {Element} element The element to parse. Must be an instance of * [Element](https://developer.mozilla.org/en-US/docs/Web/API/element). * @param {Window} window The window object to use during the parsing process. * @returns {Object} A "parse result" object (which can be used to create a context function using * `createContextFunction`) with the following properties: * * `constants`: An object with constant variable names and the expressions (strings) to * define them when a new context is created. Hyphenated variable names are turned into * camelCase. * * `eventListeners`: A `Map` of event listeners to add to each new instance of the element. * * `forVariableNames`: If the `element` is a <template> element and it has the "for" * attribute, this array will contain the name of the constants to use in each iteration. * The order of the variable names follow this format: [value, key, collection]. * * `isAsync`: Indicates if one of the `constants` or `watches` use "await". * * `parts`: A `Map` of parts and their corresponding callback expressions (strings). * * `parsedAttributeNames`: An array of all the parsed attribute names. * * `parsedChildNodes`: An array of all the parsed child node (text nodes) indexes. * * `watches`: A `Map` of events in which the element, or parts of it, must be re-infused. */ export default function parseParts(element, window) { const constants = {}; const parts = new Map(); const watches = new Map(); const parsedChildNodes = []; const forVariableNames = []; const parsedAttributeNames = []; const eventListeners = new Map(); const watchExp = configs.get('watchExp'); const constantExp = configs.get('constantExp'); const eventHandlerExp = configs.get('eventHandlerExp'); const camelCaseEvents = configs.get('camelCaseEvents'); const { HTMLTemplateElement, Node } = window; let isAsync = false; for (let i = 0; i < element.attributes.length; i++) { let { name, value } = element.attributes.item(i); const constantName = searchName(name, constantExp); let eventName = searchName(name, eventHandlerExp); const watchName = searchName(name, watchExp); const isConstant = constantName !== null; const isEventHandler = eventName !== null; const isFor = name === 'for' && element instanceof HTMLTemplateElement; const isWatch = watchName !== null; /** * Trim white space if it's an event handler. Throw an exception if it starts with ${ and * ends with }. */ if (isEventHandler) { value = value.trim(); if (value.startsWith('${') && value.endsWith('}')) { throw new SyntaxError(`Event handlers should not start with "\${" and end with "}": ${ name }="${ value }".`); } } const fragments = isEventHandler ? null : splitFragments(value); const hasFragments = fragments !== null; /** * Ignore attribute and continue to the next one if there are no fragments and it's not: * a constant, event listener, "for" attribute, or watch. */ if (!hasFragments && !isConstant && !isEventHandler && !isFor && !isWatch) { continue; } parsedAttributeNames.push(name); // If it's a constant or watch, determine if any of the `fragments` use await. if (hasFragments && (isConstant || isWatch)) { const hasAwait = fragments.find(fragment => fragment.hasAwait) !== undefined; if (hasAwait) { isAsync = true; } } // If it's defining a constant, add it to the `constants` object. if (isConstant) { value = hasFragments ? joinFragments(fragments) : JSON.stringify(value); constants[camelCase(constantName)] = value; continue; } // If it's defining a watch, add it to the `watches` object. if (isWatch) { if (hasFragments) { value = joinFragments(fragments); } else { const isArray = value.startsWith('[') && value.endsWith(']'); const isObject = value.startsWith('{') && value.endsWith('}'); if (!isArray && !isObject) { value = JSON.stringify(value); } } watches.set(camelCase(watchName), value); continue; } // If it's defining an event listener, add it to `eventListeners`. if (isEventHandler) { // Join the fragments and add it to `eventListeners`. const callbackCode = `(${ configs.get('eventName') }) => {${ value }}`; if (camelCaseEvents) { eventName = camelCase(eventName); } eventListeners.set(eventName, callbackCode); continue; } // If it's defining "for" variable names, parse them and add them to `forVariableNames`. if (isFor) { value = value.trim(); if (value.startsWith('[') && value.endsWith(']')) { value = value.substring(1, value.length - 1); } const variableNames = value.split(',').map(str => str.trim()); forVariableNames.push(...variableNames); continue; } // If it's a property (starts with a dot), turn the hyphenated `name` into camelCase. if (name.startsWith('.')) { name = `.${ camelCase(name.substr(1)) }`; } // Join the fragments and add it to `parts`. const callbackCode = joinFragments(fragments, true); parts.set(name, callbackCode); } if (!(element instanceof HTMLTemplateElement)) { /** * Iterate over the child nodes, using `element.firstChild` and `node.nextSibling` * (https://github.com/fgnass/domino#optimization), to parse text nodes. */ for (let i = 0, node = element.firstChild; node !== null; i++, node = node.nextSibling) { const { data: text, length, nodeType } = node; const isTextNode = nodeType === Node.TEXT_NODE && length > 3; const fragments = isTextNode ? splitFragments(text) : null; if (fragments !== null) { const callbackCode = joinFragments(fragments, true); parts.set(i, callbackCode); // Add the child node's index to `parsedChildNodes`. parsedChildNodes.push(i); } } } return { constants, eventListeners, forVariableNames, isAsync, parts, parsedAttributeNames, parsedChildNodes, watches, }; } /** * Uses a "parse result" object to generate the source code for a context function. * * @function contextSourceCode * @param {Object} parseResult The parse result object returned by the `parseParts` function. * @param {Object} [options={}] Options object. * @param {Set} [options.iterationConstants] Names of constant iteration variables defined by a * parent template element. * @returns {string} The source code of the body of a context function. */ export function contextSourceCode(parseResult, options = {}) { // `context` is the object returned by the context function. const context = {}; const tagsName = configs.get('tagsName'); const { constants, eventListeners, forVariableNames, parts, watches } = parseResult; const constantNames = Object.keys(constants); // Each string in `constLines` declares one or more constant variables. const constLines = [`const [host, data, iterationData, ${ tagsName }] = arguments;`]; /** * If the parsed element was inside a template element that defined "for" variable names, the * name of those constants will be in the `options.iterationConstants` set. At run time, the * `iterationData` (the third argument received by the context function) will be an object * containing the iteration data. */ const iterationConstants = Array.from(options.iterationConstants || []); // If there are iteration constants, add a line to `constLines` to declare them. if (iterationConstants.length > 0) { constLines.push(`const { ${ iterationConstants.join(', ') } } = iterationData || {};`); } // Declare constants as defined by "const-[name]" attributes. for (const name of constantNames) { constLines.push(`const ${ name } = ${ constants[name] };`); } // Add `iterationConstants` at the beginning of `constantNames`. constantNames.unshift(...iterationConstants); // Add constants to `context`. context.constants = `{ ${ [...constantNames, 'host', 'data'].join(', ') } }`; // Add event listeners to `context`. if (eventListeners.size > 0) { context.eventListeners = `new Map([${ Array.from(eventListeners).map(([name, src]) => `["${ name }", ${ src }]`).join(',') }])`; } /** * If the parsed element was a template element and defined "for" variable names, * `forVariableNames` (an array) will contain the names of those variables/constants and their * order will be [value, key, collection]. If it's not empty, add `forVariableNames` to the * `context` object. */ if (forVariableNames.length > 0) { context.forVariableNames = JSON.stringify(forVariableNames); } // Add watches to `context`. if (watches.size > 0) { context.watches = `new Map([${ Array.from(watches).map(([name, src]) => `["${ name }", ${ src }]`).join(',') }])`; } // Add parts to `context`. if (parts.size > 0) { context.parts = `new Map([${ Array.from(parts).map(([key, src]) => `[${ JSON.stringify(key) }, ${ src }]`).join(',') }])`; } /** * Return the generated source code. The declaration of constants go at the top followed by * a return statement for the `context` object. */ return ` ${ constLines.join('\n\t') } return { ${ Object.keys(context).map(key => `${ key }: ${ context[key] }`).join(',\n\t\t') } };`; } /** * Uses a "parse result" object to create a context function. * * @function createContextFunction * @param {Object} parseResult The parse result object returned by the `parseParts` function. * @param {Object} [options={}] Options object. * @param {Set} [options.iterationConstants] Names of constant iteration variables defined by a * parent template element. * @returns {Function|AsyncFunction} The context function. */ export function createContextFunction(parseResult, options = {}) { const { isAsync } = parseResult; const source = contextSourceCode(parseResult, options); // eslint-disable-next-line no-new-func return isAsync ? new AsyncFunction(source) : new Function(source); }