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
JavaScript
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 };