UNPKG

conditional-tag

Version:

Clean, easily readable conditional statements in template literals/strings for Node.js and browsers. Provides `if` / `elseif` / `else` and `switch` / `case` / `default` syntax options.

640 lines (530 loc) 15.2 kB
const condTrue = Symbol('condTrue'); const condFalse = Symbol('condFalse'); function _if(condition) { return { func: 'if', cond: (condition) ? condTrue : condFalse }; } function _elseif(condition) { return { func: 'elseif', cond: (condition) ? condTrue : condFalse }; } const _else = Symbol('else'); const _endif = Symbol('endif'); /** * Base parser class with common functionality. * * @since 1.2.0 */ class Parser { status = []; depth = -1; getStatus() { return this.status[this.depth]; } setStatus(statusObjOrName, value) { if (typeof statusObjOrName === 'string' && typeof value !== undefined) { this.status[this.depth][statusObjOrName] = value; } else { this.status[this.depth] = statusObjOrName; } return this.status[this.depth]; } pushStatus(statusObj) { this.depth++; this.status.push(statusObj); return this.status.at(-1); } popStatus() { this.depth--; return this.status.pop(); } /* c8 ignore next 7 */ canHandle() { throw new Error('canHandle() not implemented.'); } handle() { throw new Error('handle() not implemented.'); } } class ConditionalTagSyntaxError extends Error {} /** * Parser class for if syntax expressions. * * @since 1.0 */ class IfSyntaxParser extends Parser { static steps = { IGNORED: 0, IF: 1, ELSEIF: 2, ELSE: 3 }; #isIf(expr) { return (expr?.func === 'if' && (this.#isCondTrue(expr?.cond) || this.#isCondFalse(expr?.cond))); } #isElseIf(expr) { return (expr?.func === 'elseif' && (this.#isCondTrue(expr?.cond) || this.#isCondFalse(expr?.cond))); } #isElse(expr) { return (expr === _else); } #isEndIf(expr) { return (expr === _endif); } #isCondTrue(expr) { return (expr === condTrue); } #isCondFalse(expr) { return (expr === condFalse); } /** * Checks if the template literal expression is an _if syntax expression. * * @param {object|string} expr - The template literal expression. * @returns {boolean} True if it is. False otherwise. * @since 1.0 */ canHandle(expr) { return ( this.#isIf(expr) || this.#isElseIf(expr) || this.#isElse(expr) || this.#isEndIf(expr) ); } /** * Handles the _if syntax expression. * * @param {object|string} expr - The _if syntax expression. * @param {object} filterStatus - The status of the filter of the calling tag function. * @returns {boolean} Always false. * @since 1.0 */ handle(expr, filterStatus) { let s = this.getStatus(); // Check if the last conditional-tag was an _always tag and restore // original filter status. // @TODO Move to tag function to avoid repetition? if('beforeAlways' in filterStatus) { filterStatus.filterOut = filterStatus.beforeAlways; delete filterStatus.beforeAlways; } // Nested _if in unrendered block: Whole if-block at this depth gets ignored. if (s !== undefined && s.step === IfSyntaxParser.steps.IGNORED && !this.#isEndIf(expr)) ; else if (this.#isIf(expr)) { if (filterStatus.filterOut === true) { this.pushStatus({ anyCondMet: null, step: IfSyntaxParser.steps.IGNORED, parentFilter: filterStatus.filterOut }); return false; } s = this.pushStatus({ anyCondMet: this.#isCondTrue(expr.cond), step: IfSyntaxParser.steps.IF, parentFilter: filterStatus.filterOut }); filterStatus.filterOut = !s.anyCondMet; } else if (this.#isElseIf(expr)) { if (s === undefined) { throw new ConditionalTagSyntaxError('_elseif() must be inside if-block.'); } if (s.step > IfSyntaxParser.steps.ELSEIF) { throw new ConditionalTagSyntaxError('_elseif() must not occur after _else.'); } s.step = IfSyntaxParser.steps.ELSEIF; if (!s.anyCondMet) { this.setStatus('anyCondMet', this.#isCondTrue(expr.cond)); filterStatus.filterOut = !s.anyCondMet; } else { filterStatus.filterOut = true; } } else if (this.#isElse(expr)) { if (s === undefined) { throw new ConditionalTagSyntaxError('_else must be inside if-block.'); } if (s.step > IfSyntaxParser.steps.ELSEIF) { throw new ConditionalTagSyntaxError('Only one _else permitted per if-block.'); } s.step = IfSyntaxParser.steps.ELSE; // No previous conditions have been met. if (!s.anyCondMet) { this.setStatus('anyCondMet', true); filterStatus.filterOut = false; } // A previous condition has been met, so we filter else out. else { filterStatus.filterOut = true; } } // Reset everything. else if (this.#isEndIf(expr)) { if (s === undefined) { throw new ConditionalTagSyntaxError('_endif must be inside if-block.'); } const oldS = this.popStatus(); filterStatus.filterOut = oldS.parentFilter; } return false; } } const funcSwitch = Symbol('switch'); const funcCase = Symbol('case'); const funcSwitchCase = Symbol('switchCase'); function _switch(switchVar) { return { func: funcSwitch, switchVar, _case: chainedCase(switchVar) }; } function _case(...caseVars) { return { func: funcCase, caseVars }; } const _default = Symbol('default'); const _endswitch = Symbol('endswitch'); function chainedCase(switchVar) { return function(...caseVars) { return { func: funcSwitchCase, switchVar, caseVars } } } /** * Parser class for switch syntax expressions. * * @since 1.0 */ class SwitchSyntaxParser extends Parser { #isSwitch(expr) { return (expr?.func === funcSwitch); } #isCase(expr) { return (expr?.func === funcCase); } #isSwitchCase(expr) { return (expr?.func === funcSwitchCase); } #isDefault(expr) { return (expr === _default); } #isEndSwitch(expr) { return (expr === _endswitch); } /** * Checks if the template literal expression is a _switch syntax expression. * * @param {object|string} expr - The template literal expression. * @returns {boolean} True if it is. False otherwise. * @since 1.0 */ canHandle(expr) { return ( this.#isSwitch(expr) || this.#isCase(expr) || this.#isSwitchCase(expr) || this.#isDefault(expr) || this.#isEndSwitch(expr) ); } /** * Handles the _switch syntax expression. * * @param {object|string} expr - The _switch syntax expression. * @param {object} filterStatus - The status of the filter of the calling tag function. * @returns {boolean} Always false. * @since 1.0 */ handle(expr, filterStatus) { let s = this.getStatus(); // Check if the last conditional-tag was an _always tag and restore // original filter status. // @TODO Move to tag function to avoid repetition? if('beforeAlways' in filterStatus) { filterStatus.filterOut = filterStatus.beforeAlways; delete filterStatus.beforeAlways; } // Nested _switch in unrendered block: Whole switch-block at this depth gets ignored. if (s !== undefined && s.ignored === true && !this.#isEndSwitch(expr)) { return false; } if (this.#isSwitch(expr) || this.#isSwitchCase(expr)) { // No _case has been handled yet. if (s !== undefined && !s?.case) { throw new ConditionalTagSyntaxError('_switch() can only be nested inside other _case () blocks.'); } if (filterStatus.filterOut === true) { this.pushStatus({ ignored: true, parentFilter: filterStatus.filterOut }); return false; } s = this.pushStatus({ switchVar: expr.switchVar, parentFilter: filterStatus.filterOut }); if (this.#isSwitchCase(expr)) { expr.func = funcCase; } } if (this.#isCase(expr)) { if (s === undefined) { throw new ConditionalTagSyntaxError('_case() must be inside switch-block.'); } // At least one _case has been handled at this depth. s.case = true; if (expr.caseVars.some(caseVar => (caseVar === s.switchVar))) { this.setStatus('anyCondMet', true); filterStatus.filterOut = false; } else { filterStatus.filterOut = true; } } else if (this.#isDefault(expr)) { if (s === undefined) { throw new ConditionalTagSyntaxError('_default must be inside switch-block.'); } if (!s?.case) { throw new ConditionalTagSyntaxError('_default must be preceded by at least one _case.'); } filterStatus.filterOut = s.anyCondMet; } else if (this.#isEndSwitch(expr)) { if (s === undefined) { throw new ConditionalTagSyntaxError('_endswitch must be inside switch-block'); } // filterStatus.filterOut = false; // this.#reset(); const oldS = this.popStatus(); filterStatus.filterOut = oldS.parentFilter; } return false; } } const _always = Symbol('always'); /** * Parser class for the _always expression. * * @since 1.0 */ class AlwaysSyntaxParser { canHandle(expr) { return (expr === _always); } handle(expr, status) { status.beforeAlways = status.filterOut; status.filterOut = false; return false; } } /** * Checks if func is an arrow function. * This is a simple test to discern the most common cases. * I'm sure it produces false positives if one puts their mind to it. * * @param {function} func - The function to test. * @returns {boolean} true if func is an arrow function. false otherwise. */ function isArrowFunction(func) { return (typeof func === 'function' && typeof func.prototype === 'undefined' && func.name === ''); } /** * Parser class for arrow function expressions. * This mechanism is used to prevent unneccesary function * calls in unrendered blocks. * * @since 1.1 */ class FunctionSyntaxParser { canHandle(expr) { return isArrowFunction(expr); } handle(expr, status) { if (!status.filterOut) { // expr.toString = function() { // return this(); // } expr[Symbol.toPrimitive] = function() { return this(); }; } } } const beforeRgx = /(^|\r?\n)[^\S\r\n]*$/, afterRgx = /^[^\S\r\n]*(\r?\n|$)/, nlRgx = /^[\r\n]+$/, isNl = (...arr) => arr.every(str => nlRgx.test(str)); /** * Interleaves strings and expressions of a template literal into one array [str[0], val[0], str[1], val[1], …]. * In template literals, an expression is always surrounded by strings, even if they are empty. * Therefore, strings.length is always expressions.length + 1. * * @param {Array} strings - The strings. * @param {Array} expressions - The expressions. * @returns {Array} The interleaved array of both strings and expressions. * @todo Benchmarks. Maybe there's a faster/more efficient way? * @since 1.1 */ function interleave(strings, expressions) { return strings.flatMap((str, idx) => idx < expressions.length ? [str, expressions[idx]] : str); } /** * Result of the `parse()` function. * * @typedef {Object} ParsedResult * @property {Array} output - Array of parsed and filtered strings. * @property {Array} handled - Array of indices where condition-tag expressions were encountered in the output array. */ /** * Parses an array of interleaved template literal strings and expressions. * Checks for conditional-tag expressions and filters out items where conditions are not met. * * @param {Array} items - The array of interleaved items. * @returns {ParsedResult} The result. * @since 1.1 */ function parse(items) { const ifParser = new IfSyntaxParser(), switchParser = new SwitchSyntaxParser(), alwaysParser = new AlwaysSyntaxParser(), functionParser = new FunctionSyntaxParser(), status = { filterOut: false }, handled = new Set(); let index = 0; const output = items.filter((item, idx) => { if (typeof item !== 'string') { if (ifParser.canHandle(item)) { handled.add(index); return ifParser.handle(item, status); } else if (switchParser.canHandle(item)) { handled.add(index); return switchParser.handle(item, status); } else if (alwaysParser.canHandle(item)) { handled.add(index); return alwaysParser.handle(item, status); } else if (functionParser.canHandle(item)) { functionParser.handle(item, status); } /* c8 ignore next 3*/ else ; } // Filter out. if (status.filterOut) { return false; } // Don't filter out. index++; return true; }); return { output, handled } } /** * Trims lines of a multi-line template literal in the case that a condition-tag expression * was surrounded by only whitespace characters on that line. * * @param {Array} strings - Array of parsed strings. * @param {Array} handled - Array of indices where condition-tag expressions were encountered in the strings array. * @since 1.1 */ function trim(strings, handled) { // Potentially trim whitespace lines. handled.forEach(afterIdx => { const beforeIdx = afterIdx - 1, beforeMatch = beforeRgx.exec(strings[beforeIdx]), afterMatch = afterRgx.exec(strings[afterIdx]); // Only trim if conditions are met on both sides of the conditional-tag expression. // If newlines occur on both sides, collapse them into one newline. if (beforeMatch !== null && afterMatch !== null) { strings[beforeIdx] = strings[beforeIdx].replace(beforeRgx, isNl(beforeMatch[1], afterMatch[1]) ? beforeMatch[1] : ''); strings[afterIdx] = strings[afterIdx].replace(afterRgx, ''); } }); } /** * Tag function for templates literals/strings. Enables conditional logic * within these strings. * * @param {Array} strings - The strings of the template literal. * @param {Array} expressions - The expressions of the template literal. * @returns {string} The rendered string. * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates} * @since 1.0 */ function _(strings, ...expressions) { const items = interleave(strings, expressions); try { const { output, handled } = parse(items); trim(output, handled); return output.join(''); } catch(err) { // Trying to be a bit more helpful. if (err instanceof TypeError) { throw new Error(`${err.message} (Maybe there's an async function in an expression? Use tag function _async with await.)`, { cause: err }); } throw err; } } /** * Asynchronous tag function for templates literals/strings. Enables conditional logic * within these strings. * * @param {Array} strings - The strings of the template literal. * @param {Array} expressions - The expressions of the template literal. * @returns {string} The rendered string. * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates} * @since 1.1 */ async function _async(strings, ...expressions) { const items = interleave(strings, expressions); const { output, handled } = parse(items); const resolved = await Promise.all(output.map(item => { if (isArrowFunction(item)) { // In case of async functions, this will be a Promise. return item[Symbol.toPrimitive](); } else { return item; } })); trim(resolved, handled); return resolved.join(''); } // For convenience: _.if = _if; _.async = _async; _.elseif = _elseif; _.else = _else; _.endif = _endif; _.switch = _switch; _.case = _case; _.default = _default; _.endswitch = _endswitch; _.always = _always; export { ConditionalTagSyntaxError, _, _always, _async, _case, _default, _else, _elseif, _endif, _endswitch, _if, _switch };