UNPKG

infuse.host

Version:

Infuse your HTML with dynamic content.

364 lines (327 loc) 16.6 kB
import configs from './configs.js'; /** * Takes an array of tag function names and returns a settings object that can be used to find tag * functions in strings with template literals. The returned object is meant to be used by the * `splitFragments` function. * * @function createTagSettings * @param {(string[]|Object)} tags A array of tag function names of the tags object (where keys * are the tag function names and the values are the tag functions). * @returns {Object} A "tag settings" object or null if the provided `tags` is empty. The "tag * settings" object contains the following attributes: * * `longestTagLength`: The length of the longest tag. * * `shortestTagLength`: The length of the shortest tag. * * `tagsExp`: A regular expression to test if a string ends with one of the given tags * prefixed with an optional "await". */ export function createTagSettings(tags) { if (!Array.isArray(tags) && typeof tags !== 'object') { throw new TypeError('The option `tags` must be an array of strings or an object.'); } const sorted = []; const tagNames = tags && !Array.isArray(tags) ? Object.keys(tags) : tags; if (tagNames.length === 0) { return null; } // Iterate over `tagNames` to sort them, in descending order, by their length. for (const tag of tagNames) { // Find the index of a tag that is shorter than `tag`. const i = sorted.findIndex(it => (it.length < tag.length)); // If found, insert `tag` before the shorter tag. Otherwise, add it to the end of `sorted`. if (i !== -1) { sorted.splice(i, 0, tag); } else { sorted.push(tag); } } return { longestTagLength: sorted[0].length, shortestTagLength: sorted[sorted.length - 1].length, tagsExp: new RegExp(`(await )?(${ sorted.join('|') })$`), }; } /** * Splits a string into an array of fragments. A fragment represents a portion of the `input` * string that is an expression, a template literal, or a string. Returns `null` if `input` * doesn't contain any expressions or template literals. For instance: * * ╔═════════════════════════════════════════╦═════════════════════════════════════════════════╗ * ║ Input string ║ Result ║ * ╠═════════════════════════════════════════╬═════════════════════════════════════════════════╣ * ║ 'String with no fragments' ║ null ║ * ╠═════════════════════════════════════════╬═════════════════════════════════════════════════╣ * ║ '${ host.price }' ║ [{ hasAwait: false, expression: 'host.price' }] ║ * ╠═════════════════════════════════════════╬═════════════════════════════════════════════════╣ * ║ ║ [ ║ * ║ ║ 'btn btn-', ║ * ║ ║ { ║ * ║ 'btn btn-${ host.btnType }' ║ hasAwait: false, ║ * ║ ║ expression: 'host.btnType' ║ * ║ ║ } ║ * ║ ║ ] ║ * ╠═════════════════════════════════════════╬═════════════════════════════════════════════════╣ * ║ ║ [{ ║ * ║ ║ tag: undefined, ║ * ║ '`btn btn-${ host.btnType }`' ║ hasAwait: false, ║ * ║ ║ template: '`btn btn-${ host.btnType }`' ║ * ║ ║ }] ║ * ╠═════════════════════════════════════════╬═════════════════════════════════════════════════╣ * ║ ║ [ ║ * ║ ║ { ║ * ║ ║ tag: 'i18n', ║ * ║ ║ hasAwait: false, ║ * ║ ║ template: '`price`' ║ * ║ 'i18n`price`: $${ this.dataset.price }' ║ }, ║ * ║ ║ '": $"', ║ * ║ ║ { ║ * ║ ║ hasAwait: false, ║ * ║ ║ expression: 'this.dataset.price' ║ * ║ ║ } ║ * ║ ║ ] ║ * ╚═════════════════════════════════════════╩═════════════════════════════════════════════════╝ * * The last example contains a tagged template literal. In order to identify tags, the "tags" * configuration object must be set first. For instance: * * import { setConfigs } from 'path/to/configs.js'; * import splitFragments, { createTagSettings } from 'path/to/splitFragments.js'; * // Pass all tag names that could be found in the string. * setConfigs({ tags: ['i18n', 'currency', 'date'] }); * // Template literals can be tagged using any tag name in the "tags" configuration. * const input = 'i18n`price`: $${ this.dataset.price }'; * const result = splitFragments(input); * console.log(result); // Logs the result array shown in the last example above. * * @function splitFragments * @param {string} input A string that may or may not contain expressions and/or template literals. * @returns {Array} An array of fragments or `null` if the input string doesn't contain any * expressions or template literals. */ export default function splitFragments(input) { // Container for all fragments. const fragments = []; const tags = configs.get('tags'); const settings = createTagSettings(tags); /** * These variables are used in the for loop below to iterate over each character in `input`. */ // `a` is the previous character, the one before position `i`. let a = ''; // `b` is the current character, the one at position `i`. let b = input.charAt(0); // `c` is the next character, the one after position `i`. let c = input.charAt(1); /** * Index position, within `input`, where a template literal starts. The value -1 indicates * that the start of template literal has not been found during the iteration. */ let templateStart = -1; /** * Index position, within `input`, where an expression starts. The value -1 indicates that * the start of an expression has not been found during the iteration. */ let expressionStart = -1; /** * Number of opened curly braces. This is used during the iteration to make sure * expressions are well balanced. */ let openedBraces = 0; /** * Iterate over each character in `input`. * The variable `h` is the index position after the last parsed "fragment" and `i` is the index * position of the current character. After each iteration, `i` is incremented and the values * for `a`, `b`, and `c` are updated accordingly. */ for (let h = 0, i = 0; i < input.length; a = b, b = c, c = input.charAt(++i + 1)) { /** * If the current character is a backtick and there's not an expression currently * started ("${") prior to the current index position `i`. Backticks within expressions * are ignored. */ if (b === '`' && expressionStart === -1) { /** * If there's backtick prior to the current index position `i` that started a template * literal, then the current character (a backtick) closes the template literal, which * must be extracted from `input` and added to `fragments`. */ if (templateStart !== -1) { let match, hasAwait, tag; /** * Do not proceed (continue to the next iteration) if the backtick is escaped. A * backtick within a template literal is "escaped" if it's preceded by a backslash. */ if (a === '\\') { continue; } /** * If the template literal starts after `h`, we need to extract the string prior * to the start of the template (from position `h` to `templateStart`, but not * including `templateStart`) and add it as a "string fragment" to `fragments`. */ if (h < templateStart) { let j = templateStart; /** * If the string "fragment" is as long or longer than the shortest registered * tag, the template literal could be tagged. If the template literal is * tagged, the name of the tag function, preceded by an optional "await ", * would be at the end of the string fragment. */ if (settings && (j - h) >= settings.shortestTagLength) { /** * Subtract the length of the longest registered tag and the length of * "await " from `j`. If the template literal is tagged, the name of the * tag function and the optional "await " will be contained within `j` and * the `templateStart`. */ j -= settings.longestTagLength + 6; /** * Subtract the piece of string from `j` (or from `h`, if `j` is less * than `h`) to the `templateStart` to search for a tag function and an * optional "await ". */ const pre = input.substring(j < h ? h : j, templateStart); // Perform the search. const result = pre.match(settings.tagsExp) || []; /** * If a `result` was found, the first position will be the full string * that matches the regular expression, second position will indicate if * an "await " was found, and third position will be the name of the tag * function. */ [match, hasAwait, tag] = result; /** * Assign `templateStart` to `j` minus the length of the `match` if a * a match was found. */ j = templateStart - (match ? match.length : 0); } // If `h < j`, subtract the "string fragment" and add it to `fragments`. if (h < j) { fragments.push(input.substring(h, j)); } } // Extract the template literal and add a "template fragment" object to `fragments`. fragments.push({ tag, hasAwait: !!hasAwait, template: input.substring(templateStart, i + 1), }); /** * Now that the "template fragment" was extracted, `h` needs to be updated to the * next index position in the iteration and `templateStart` is changed back to -1 * indicating that there's no template currently started/opened. */ h = i + 1; templateStart = -1; } else if (c !== '`') { /** * If the next character is not a backtick, then the current backtick is the start * of a template literal. */ templateStart = i; } } else if (b === '{') { if (expressionStart !== -1) { // Keep track of the number of opened braces within an expression. openedBraces++; } else if (a === '$') { /** * The prior index position is the start of an expression if the prior character * is "$" and the current character is "{". */ expressionStart = i - 1; } } else if (b === '}' && expressionStart !== -1) { // If the current character is "}" and there's an expression currently started/opened. if (openedBraces) { // If there are `openedBraces`, `b` closes the last open bracket. openedBraces--; } else if (templateStart !== -1) { /** * If the expression is within a template literal simply change `expressionStart` * back to -1 indicating that there's no expression currently started/opened. */ expressionStart = -1; } else { /** * If there's no template started/opened, the "expression fragment" must be added * to `fragments`. */ // If there's a "string fragment" before the expression, add it to `fragments`. if (h < expressionStart) { fragments.push(input.substring(h, expressionStart)); } // Extract the expression and check if it has an await. const expression = input.substring(expressionStart + 2, i).trim(); const hasAwait = expression.indexOf('await ') !== -1; // Add the expression to `fragments`. fragments.push({ hasAwait, expression }); /** * Now that the "expression fragment" was extracted, `h` needs to be updated to the * next index position in the iteration and `expressionStart` is changed back to * -1 indicating that there's no expression currently started/opened. */ h = i + 1; expressionStart = -1; } } if ((i + 1) === input.length && h <= i) { /** * If we're at the last iteration, the last "string fragment" (everything after `h`) * needs to be added to `fragments`. */ fragments.push(input.substr(h)); } } if (fragments.length === 0 || (fragments.length === 1 && fragments[0] === input)) { return null; } return fragments; } /** * Generates an expression by joining all the `fragments` together separated by a "+" sign. When * evaluated, the returned expression concatenates together all the resulting values of each * fragment. * * @function joinFragments * @param {string[]} fragments The parsed fragments. * @param {boolean} [isEventCallback=false] If `true`, the returned expression will be an arrow * function that takes an `event` argument. * @returns {string} */ export function joinFragments(fragments, isEventCallback = false) { let isAsync = false; const tagsName = configs.get('tagsName'); const eventName = configs.get('eventName'); let src = fragments.map((fragment) => { if (typeof fragment === 'string') { return JSON.stringify(fragment); } if (fragment.hasAwait) { isAsync = true; } if (fragment.expression) { return `(${ fragment.expression })`; } // The fragment is a template literal. let { template } = fragment; // If the fragment had a tag function, add it before the template literal. if (fragment.tag) { template = `${ tagsName }.${ fragment.tag }${ template }`; } return fragment.hasAwait ? `(await ${ template })` : template; }); /** * If there are multiple fragments and the first one is an expression (which, when evaluated, * might not be string), use implicit coercion (add an empty string at the beginning) to make * sure that the result of evaluating all the fragments together is a string. */ if (fragments.length > 1 && fragments[0].expression) { src.splice(0, 0, '""'); } src = src.join(' + '); if (isEventCallback) { src = `${ isAsync ? 'async ' : '' }(${ eventName }) => ${ src }`; } return src; }