diff --git a/HISTORY.md b/HISTORY.md index 21652eee4..4dfd855c2 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,8 @@ +## vNEXT + +### Highlights +* Inline expressions in Spacebars: use arithmetic (`{{a + b}}`), comparison (`{{a > b}}`), logical (`{{a && b}}`), and ternary (`{{a ? "yes" : "no"}}`) operators directly in templates without helper functions. Fully backward compatible. + ## v3.0.2, 2025-02-04 ### Highlights diff --git a/packages/spacebars-compiler/codegen.js b/packages/spacebars-compiler/codegen.js index 01ae5d611..0a3c68128 100644 --- a/packages/spacebars-compiler/codegen.js +++ b/packages/spacebars-compiler/codegen.js @@ -67,6 +67,28 @@ const makeObjectLiteral = (obj) => { }; Object.assign(CodeGen.prototype, { + // Generate JavaScript code for an inline expression AST node. + // Each PathExpression is resolved via view.lookup() and unwrapped + // via Spacebars.call() so that reactive helpers are evaluated. + codeGenInlineExpr: function (node) { + switch (node.type) { + case 'BinaryExpression': + return `(${this.codeGenInlineExpr(node.left)} ${node.operator} ${this.codeGenInlineExpr(node.right)})`; + case 'UnaryExpression': + return `(${node.operator}${this.codeGenInlineExpr(node.argument)})`; + case 'ConditionalExpression': + return `(${this.codeGenInlineExpr(node.test)} ? ${this.codeGenInlineExpr(node.consequent)} : ${this.codeGenInlineExpr(node.alternate)})`; + case 'PathExpression': + return `Spacebars.call(${this.codeGenPath(node.path)})`; + case 'LiteralExpression': + return BlazeTools.toJSLiteral(node.value); + case 'SubExpression': + return this.codeGenMustache(node.path, node.args, 'dataMustache'); + default: + throw new Error(`Unknown inline expression node type: ${node.type}`); + } + }, + codeGenTemplateTag: function (tag) { if (tag.position === HTMLTools.TEMPLATE_TAG_POSITION.IN_START_TAG) { // Special dynamic attributes: `
...` @@ -74,7 +96,13 @@ Object.assign(CodeGen.prototype, { return BlazeTools.EmitCode(`function () { return ${this.codeGenMustache(tag.path, tag.args, 'attrMustache')}; }`); } else { if (tag.type === 'DOUBLE' || tag.type === 'TRIPLE') { - let code = this.codeGenMustache(tag.path, tag.args); + let code; + if (tag.expr) { + // Inline expression: {{a + b}}, {{x ? y : z}}, etc. + code = `Spacebars.expr(${this.codeGenInlineExpr(tag.expr)})`; + } else { + code = this.codeGenMustache(tag.path, tag.args); + } if (tag.type === 'TRIPLE') { code = `Spacebars.makeRaw(${code})`; } @@ -82,7 +110,8 @@ Object.assign(CodeGen.prototype, { // Reactive attributes are already wrapped in a function, // and there's no fine-grained reactivity. // Anywhere else, we need to create a View. - code = `Blaze.View(${BlazeTools.toJSLiteral(`lookup:${tag.path.join('.')}`)}, function () { return ${code}; })`; + const label = tag.expr ? '"expr"' : BlazeTools.toJSLiteral(`lookup:${tag.path.join('.')}`); + code = `Blaze.View(${label}, function () { return ${code}; })`; } return BlazeTools.EmitCode(code); } else if (tag.type === 'INCLUSION' || tag.type === 'BLOCKOPEN') { @@ -100,7 +129,7 @@ Object.assign(CodeGen.prototype, { // provide nice line numbers. if (path.length > 1) throw new Error(`Unexpected dotted path beginning with ${path[0]}`); - if (! args.length) + if (! args.length && !tag.expr) throw new Error(`#${path[0]} requires an argument`); let dataCode = null; @@ -141,8 +170,13 @@ Object.assign(CodeGen.prototype, { } if (! dataCode) { - // `args` must exist (tag.args.length > 0) - dataCode = this.codeGenInclusionDataFunc(args) || 'null'; + if (tag.expr) { + // Inline expression in block helper: {{#if a > b}} + dataCode = `function () { return ${this.codeGenInlineExpr(tag.expr)}; }`; + } else { + // `args` must exist (tag.args.length > 0) + dataCode = this.codeGenInclusionDataFunc(args) || 'null'; + } } // `content` must exist @@ -276,7 +310,12 @@ Object.assign(CodeGen.prototype, { break; case 'EXPR': // The format of EXPR is ['EXPR', { type: 'EXPR', path: [...], args: { ... } }] - argCode = this.codeGenMustache(argValue.path, argValue.args, 'dataMustache'); + if (argValue.expr) { + // Inline expression inside sub-expression: {{helper (a + b)}} + argCode = this.codeGenInlineExpr(argValue.expr); + } else { + argCode = this.codeGenMustache(argValue.path, argValue.args, 'dataMustache'); + } break; default: // can't get here diff --git a/packages/spacebars-compiler/spacebars_tests.js b/packages/spacebars-compiler/spacebars_tests.js index 530a47bc3..d4d2a5ba1 100644 --- a/packages/spacebars-compiler/spacebars_tests.js +++ b/packages/spacebars-compiler/spacebars_tests.js @@ -283,4 +283,280 @@ Tinytest.add("spacebars-compiler - parse", function (test) { test.equal(BlazeTools.toJS(SpacebarsCompiler.parse('')), 'HTML.INPUT({selected: ""})'); + // ============================================================ + // Inline expressions + // ============================================================ + + // --- Basic arithmetic --- + let tag; + tag = SpacebarsCompiler.parse('{{a + b}}'); + test.isTrue(tag.expr); + test.equal(tag.expr.type, 'BinaryExpression'); + test.equal(tag.expr.operator, '+'); + test.equal(tag.expr.left.type, 'PathExpression'); + test.equal(tag.expr.left.path, ['a']); + test.equal(tag.expr.right.type, 'PathExpression'); + test.equal(tag.expr.right.path, ['b']); + + tag = SpacebarsCompiler.parse('{{a * b}}'); + test.equal(tag.expr.operator, '*'); + + tag = SpacebarsCompiler.parse('{{a - b}}'); + test.equal(tag.expr.operator, '-'); + + tag = SpacebarsCompiler.parse('{{a / b}}'); + test.equal(tag.expr.operator, '/'); + + tag = SpacebarsCompiler.parse('{{a % b}}'); + test.equal(tag.expr.operator, '%'); + + // --- Operator precedence --- + // a + b * c → a + (b * c) + tag = SpacebarsCompiler.parse('{{a + b * c}}'); + test.equal(tag.expr.operator, '+'); + test.equal(tag.expr.right.operator, '*'); + + // (a + b) * c → (a + b) * c + tag = SpacebarsCompiler.parse('{{(a + b) * c}}'); + test.equal(tag.expr.operator, '*'); + test.equal(tag.expr.left.operator, '+'); + + // --- Left associativity --- + // a - b - c → (a - b) - c + tag = SpacebarsCompiler.parse('{{a - b - c}}'); + test.equal(tag.expr.operator, '-'); + test.equal(tag.expr.left.operator, '-'); + test.equal(tag.expr.left.left.path, ['a']); + + // --- Comparison operators --- + tag = SpacebarsCompiler.parse('{{a === b}}'); + test.equal(tag.expr.operator, '==='); + + tag = SpacebarsCompiler.parse('{{a !== b}}'); + test.equal(tag.expr.operator, '!=='); + + tag = SpacebarsCompiler.parse('{{a > b}}'); + test.equal(tag.expr.operator, '>'); + + tag = SpacebarsCompiler.parse('{{a >= b}}'); + test.equal(tag.expr.operator, '>='); + + tag = SpacebarsCompiler.parse('{{a < b}}'); + test.equal(tag.expr.operator, '<'); + + tag = SpacebarsCompiler.parse('{{a <= b}}'); + test.equal(tag.expr.operator, '<='); + + // --- Logical operators --- + tag = SpacebarsCompiler.parse('{{a && b}}'); + test.equal(tag.expr.operator, '&&'); + + tag = SpacebarsCompiler.parse('{{a || b}}'); + test.equal(tag.expr.operator, '||'); + + // --- Ternary --- + tag = SpacebarsCompiler.parse('{{a ? b : c}}'); + test.equal(tag.expr.type, 'ConditionalExpression'); + test.equal(tag.expr.test.path, ['a']); + test.equal(tag.expr.consequent.path, ['b']); + test.equal(tag.expr.alternate.path, ['c']); + + // Nested ternary (right-associative): a ? b : c ? d : e → a ? b : (c ? d : e) + tag = SpacebarsCompiler.parse('{{a ? b : c ? d : e}}'); + test.equal(tag.expr.type, 'ConditionalExpression'); + test.equal(tag.expr.alternate.type, 'ConditionalExpression'); + + // --- Unary operators --- + tag = SpacebarsCompiler.parse('{{-a}}'); + test.equal(tag.expr.type, 'UnaryExpression'); + test.equal(tag.expr.operator, '-'); + test.equal(tag.expr.argument.path, ['a']); + + // !a inside expression (not at tag start) + tag = SpacebarsCompiler.parse('{{a && !b}}'); + test.equal(tag.expr.operator, '&&'); + test.equal(tag.expr.right.type, 'UnaryExpression'); + test.equal(tag.expr.right.operator, '!'); + + // --- Literals in expressions --- + tag = SpacebarsCompiler.parse('{{a + 1}}'); + test.equal(tag.expr.right.type, 'LiteralExpression'); + test.equal(tag.expr.right.value, 1); + + tag = SpacebarsCompiler.parse('{{a === "hello"}}'); + test.equal(tag.expr.right.type, 'LiteralExpression'); + test.equal(tag.expr.right.value, 'hello'); + + tag = SpacebarsCompiler.parse('{{a === true}}'); + test.equal(tag.expr.right.value, true); + + tag = SpacebarsCompiler.parse('{{a === false}}'); + test.equal(tag.expr.right.value, false); + + tag = SpacebarsCompiler.parse('{{a === null}}'); + test.equal(tag.expr.right.value, null); + + // Literal-only expression + tag = SpacebarsCompiler.parse('{{0 + 1}}'); + test.equal(tag.expr.left.value, 0); + test.equal(tag.expr.right.value, 1); + + tag = SpacebarsCompiler.parse('{{"a" === "b"}}'); + test.equal(tag.expr.left.value, 'a'); + test.equal(tag.expr.right.value, 'b'); + + // --- Dot paths in expressions --- + tag = SpacebarsCompiler.parse('{{foo.bar + baz.qux}}'); + test.equal(tag.expr.left.path, ['foo', 'bar']); + test.equal(tag.expr.right.path, ['baz', 'qux']); + + // --- Whitespace variations --- + tag = SpacebarsCompiler.parse('{{a+b}}'); + test.equal(tag.expr.operator, '+'); + + tag = SpacebarsCompiler.parse('{{ a + b }}'); + test.equal(tag.expr.operator, '+'); + + // --- Backward compatibility (must NOT trigger expression mode) --- + // Simple path + tag = SpacebarsCompiler.parse('{{foo}}'); + test.isFalse(tag.expr); + test.equal(tag.path, ['foo']); + + // Dot path + tag = SpacebarsCompiler.parse('{{foo.bar}}'); + test.isFalse(tag.expr); + test.equal(tag.path, ['foo', 'bar']); + + // Helper call with args + tag = SpacebarsCompiler.parse('{{foo bar baz}}'); + test.isFalse(tag.expr); + test.equal(tag.path, ['foo']); + test.equal(tag.args.length, 2); + + // Helper call with negative number arg (NOT subtraction) + tag = SpacebarsCompiler.parse('{{foo -1}}'); + test.isFalse(tag.expr); + test.equal(tag.path, ['foo']); + test.equal(tag.args[0][0], 'NUMBER'); + test.equal(tag.args[0][1], -1); + + // Sub-expression + tag = SpacebarsCompiler.parse('{{foo (bar baz)}}'); + test.isFalse(tag.expr); + test.equal(tag.path, ['foo']); + + // Keyword args + tag = SpacebarsCompiler.parse('{{foo x=1}}'); + test.isFalse(tag.expr); + + // --- Forbidden operators produce errors --- + test.throws(function () { + SpacebarsCompiler.parse('{{a = b}}'); + }, 'Assignment'); + + test.throws(function () { + SpacebarsCompiler.parse('{{a += 1}}'); + }, 'Compound assignment'); + + test.throws(function () { + SpacebarsCompiler.parse('{{a | b}}'); + }, 'pipe'); + + test.throws(function () { + SpacebarsCompiler.parse('{{a & b}}'); + }, 'bitwise AND'); + + test.throws(function () { + SpacebarsCompiler.parse('{{a ? b}}'); + }, 'Expected `:`'); + + test.throws(function () { + SpacebarsCompiler.parse('{{a--}}'); + }, 'decrement'); + + // --- Triple-stache with expression --- + tag = SpacebarsCompiler.parse('{{{a + b}}}'); + test.isTrue(tag.expr); + test.equal(tag.type, 'TRIPLE'); + test.equal(tag.expr.operator, '+'); + + // --- BLOCKOPEN with inline expression --- + tag = SpacebarsCompiler.parse('{{#if a > b}}yes{{/if}}'); + test.isTrue(tag.expr); + test.equal(tag.type, 'BLOCKOPEN'); + test.equal(tag.path, ['if']); + test.equal(tag.expr.operator, '>'); + + tag = SpacebarsCompiler.parse('{{#unless a === b}}no{{/unless}}'); + test.isTrue(tag.expr); + test.equal(tag.path, ['unless']); + test.equal(tag.expr.operator, '==='); + + tag = SpacebarsCompiler.parse('{{#if a + b > 10}}yes{{/if}}'); + test.isTrue(tag.expr); + test.equal(tag.expr.operator, '>'); + test.equal(tag.expr.left.operator, '+'); + + // BLOCKOPEN without expression still works + tag = SpacebarsCompiler.parse('{{#if cond}}yes{{/if}}'); + test.isFalse(tag.expr); + test.equal(tag.path, ['if']); + test.equal(tag.args.length, 1); + + // --- Sub-expression with inline expression --- + tag = SpacebarsCompiler.parse('{{helper (a + b)}}'); + test.isFalse(tag.expr); // outer tag is NOT an expression + test.equal(tag.path, ['helper']); + test.equal(tag.args.length, 1); + test.equal(tag.args[0][0], 'EXPR'); + test.isTrue(tag.args[0][1].expr); // inner sub-expression IS an expression + test.equal(tag.args[0][1].expr.operator, '+'); + + // Classic sub-expression still works + tag = SpacebarsCompiler.parse('{{helper (foo bar)}}'); + test.isFalse(tag.expr); + test.equal(tag.args[0][0], 'EXPR'); + test.isFalse(tag.args[0][1].expr); + test.equal(tag.args[0][1].path, ['foo']); + + // --- Special identifiers in expressions --- + // @index + tag = SpacebarsCompiler.parse('{{@index + 1}}'); + test.isTrue(tag.expr); + test.equal(tag.expr.left.path, ['@index']); + test.equal(tag.expr.right.value, 1); + + // this.x + tag = SpacebarsCompiler.parse('{{this.x + 1}}'); + test.isTrue(tag.expr); + test.equal(tag.expr.left.path, ['.', 'x']); + + // ../parent paths + tag = SpacebarsCompiler.parse('{{../count + 1}}'); + test.isTrue(tag.expr); + test.equal(tag.expr.left.path[0], '..'); + + // --- Complex combined expressions --- + // Comparison then ternary + tag = SpacebarsCompiler.parse('{{a === b ? "yes" : "no"}}'); + test.equal(tag.expr.type, 'ConditionalExpression'); + test.equal(tag.expr.test.operator, '==='); + test.equal(tag.expr.consequent.value, 'yes'); + + // Multiple operators + tag = SpacebarsCompiler.parse('{{a + b * c - d}}'); + test.equal(tag.expr.operator, '-'); + test.equal(tag.expr.left.operator, '+'); + test.equal(tag.expr.left.right.operator, '*'); + + // Path + string literal + tag = SpacebarsCompiler.parse('{{name + " suffix"}}'); + test.equal(tag.expr.right.value, ' suffix'); + + // Two literals + tag = SpacebarsCompiler.parse('{{1 + 2}}'); + test.equal(tag.expr.left.value, 1); + test.equal(tag.expr.right.value, 2); + }); diff --git a/packages/spacebars-compiler/templatetag.js b/packages/spacebars-compiler/templatetag.js index bb2554532..8c6e94fa5 100644 --- a/packages/spacebars-compiler/templatetag.js +++ b/packages/spacebars-compiler/templatetag.js @@ -239,15 +239,399 @@ TemplateTag.parse = function (scannerOrString) { } }; + // ============================================================ + // Inline Expression support (Pratt parser) + // + // Allows expressions like {{a + b}}, {{a > b}}, {{a ? b : c}} + // inside {{ }} tags. Backward-compatible: if no operator is found + // after the first path, falls through to the classic helper-call + // argument loop. + + // Operator precedence table (higher = tighter binding) + const PREC_TERNARY = 1; + const PREC_OR = 2; + const PREC_AND = 3; + const PREC_EQUALITY = 4; + const PREC_COMPARE = 5; + const PREC_ADD = 6; + const PREC_MULTIPLY = 7; + const PREC_UNARY = 8; + + // Binary operators and their precedence + const binaryOps = { + '||': PREC_OR, + '&&': PREC_AND, + '===': PREC_EQUALITY, + '!==': PREC_EQUALITY, + '>': PREC_COMPARE, + '>=': PREC_COMPARE, + '<': PREC_COMPARE, + '<=': PREC_COMPARE, + '+': PREC_ADD, + '-': PREC_ADD, + '*': PREC_MULTIPLY, + '/': PREC_MULTIPLY, + '%': PREC_MULTIPLY, + }; + + // Regex to detect and consume a binary operator at current position. + // Order matters: longer operators first (=== before =, >= before >, etc.) + const operatorRegex = /^(===|!==|&&|\|\||>=|<=|[+\-*\/%]|>(?![}])|<|[?:])/; + + // Peek at the next operator without consuming it. + // Returns the operator string or null. + const peekOperator = function () { + const rest = scanner.rest(); + + // Check for forbidden operators first — these would be partially + // matched by operatorRegex as single +, -, etc. + if (/^\+\+/.test(rest)) { + error("The `++` (increment) operator is not supported in Spacebars expressions"); + } + if (/^--/.test(rest)) { + error("The `--` (decrement) operator is not supported in Spacebars expressions"); + } + if (/^[+\-*\/%]=/.test(rest)) { + const op = rest.slice(0, 2); + error(`Compound assignment (\`${op}\`) is not allowed in Spacebars expressions`); + } + + const match = operatorRegex.exec(rest); + if (!match) return null; + const op = match[1]; + // Reject operators that aren't in our set (`:` is only valid as + // part of ternary, handled specially) + if (op === ':') return ':'; + if (op === '?') return '?'; + if (binaryOps.hasOwnProperty(op)) return op; + return null; + }; + + // Check for forbidden operators and give clear error messages + const checkForbiddenOperator = function () { + const rest = scanner.rest(); + if (/^\|(?!\|)/.test(rest)) { + error("The `|` (pipe/bitwise OR) operator is not supported in Spacebars expressions. " + + "Use `||` for logical OR"); + } + if (/^&(?!&)/.test(rest)) { + error("The `&` (bitwise AND) operator is not supported in Spacebars expressions. " + + "Use `&&` for logical AND"); + } + // Note: ++ and -- are caught earlier in peekOperator() + // Check for assignment operators: =, +=, -=, *=, /=, %= + // But NOT ==, ===, !=, !== + if (/^=(?!=)/.test(rest)) { + error("Assignment (`=`) is not allowed in Spacebars expressions"); + } + if (/^[+\-*\/%]=/.test(rest)) { + const op = rest.slice(0, 2); + error(`Compound assignment (\`${op}\`) is not allowed in Spacebars expressions`); + } + }; + + // Scan a primary expression (the atomic unit of an expression). + // Returns an inline expression AST node. + const scanPrimary = function () { + run(/^\s*/); + + // Unary `!` + if (run(/^!/)) { + const argument = scanPrimary(); + return { type: 'UnaryExpression', operator: '!', argument }; + } + + // Unary `-` (but not `--`) + if (/^-(?!-)/.test(scanner.rest())) { + advance(1); + const argument = scanPrimary(); + return { type: 'UnaryExpression', operator: '-', argument }; + } + + // Parenthesized expression or sub-expression + if (run(/^\(/)) { + // First, try to determine if this is a grouped expression or a + // Handlebars sub-expression like (helper arg1 arg2). + // Strategy: scan the first value, then check what follows. + const savedPos = scanner.pos; + run(/^\s*/); + + // Try to parse as inline expression first + const expr = scanInlineExpr(0); + run(/^\s*/); + + if (run(/^\)/)) { + // Successfully parsed as a grouped expression + return expr; + } + + // If we didn't find `)`, it might be a sub-expression with + // helper args. Backtrack and parse as sub-expression. + scanner.pos = savedPos; + const subExpr = scanExpr('EXPR'); + return { + type: 'SubExpression', + path: subExpr.path, + args: subExpr.args, + }; + } + + // Number literal + let result; + if ((result = BlazeTools.parseNumber(scanner))) { + return { type: 'LiteralExpression', value: result.value }; + } + + // String literal + if ((result = BlazeTools.parseStringLiteral(scanner))) { + return { type: 'LiteralExpression', value: result.value }; + } + + // Dot-leading paths (./foo, ../foo) + if (/^[\.\[]/.test(scanner.peek())) { + return { type: 'PathExpression', path: scanPath() }; + } + + // Identifiers: null, true, false, or path + if ((result = BlazeTools.parseExtendedIdentifierName(scanner))) { + const id = result; + if (id === 'null') { + return { type: 'LiteralExpression', value: null }; + } else if (id === 'true') { + return { type: 'LiteralExpression', value: true }; + } else if (id === 'false') { + return { type: 'LiteralExpression', value: false }; + } else { + // It's a path start — unconsume and use scanPath + scanner.pos -= id.length; + return { type: 'PathExpression', path: scanPath() }; + } + } + + // If nothing matched, check for forbidden operators for better errors + checkForbiddenOperator(); + expected('expression'); + }; + + // Pratt parser: scan an inline expression with minimum precedence `minPrec`. + // If `initialLeft` is provided, use it as the left operand instead of + // scanning a new primary (used when the caller already scanned a path). + const scanInlineExpr = function (minPrec, initialLeft) { + let left = initialLeft || scanPrimary(); + + while (true) { + run(/^\s*/); + const op = peekOperator(); + if (op === null) break; + + // Ternary operator: `?` + if (op === '?') { + if (PREC_TERNARY < minPrec) break; + advance(1); // consume `?` + run(/^\s*/); + const consequent = scanInlineExpr(0); // any expression + run(/^\s*/); + if (!run(/^:/)) { + error("Expected `:` in ternary expression (`a ? b : c`). " + + "If you meant to use `?` differently, ternary expressions require both `?` and `:`"); + } + run(/^\s*/); + const alternate = scanInlineExpr(PREC_TERNARY); // right-associative + left = { type: 'ConditionalExpression', test: left, consequent, alternate }; + continue; + } + + // `:` outside of a ternary — stop (let the caller handle it) + if (op === ':') break; + + const prec = binaryOps[op]; + if (prec === undefined || prec < minPrec) break; + + // Consume the operator + advance(op.length); + + // Check for forbidden patterns after consuming operator + run(/^\s*/); + checkForbiddenOperator(); + + // Right operand: parse with prec+1 for left-associativity + const right = scanInlineExpr(prec + 1); + left = { type: 'BinaryExpression', operator: op, left, right }; + } + + return left; + }; + + // Detect if we should enter inline expression mode. + // Called after scanning the first path in scanExpr. + // Returns true if the next non-whitespace token is a binary operator + // (not a `-` followed by a digit, which is a negative number arg). + const shouldEnterExprMode = function (endType) { + const savedPos = scanner.pos; + run(/^\s*/); + const rest = scanner.rest(); + + // Check for end of tag first + if (ends[endType].test(rest) || /^[})]/.test(scanner.peek())) { + scanner.pos = savedPos; + return false; + } + + // Check for binary operator + const match = operatorRegex.exec(rest); + if (!match) { + // Check for forbidden operators — give a clear error instead of + // falling through to the classic parser's generic error + checkForbiddenOperator(); + scanner.pos = savedPos; + return false; + } + + const op = match[1]; + + // `-` followed by a digit is a negative number argument, not subtraction + // (preserves `{{foo -1}}` as helper call) + if (op === '-' && /^-\d/.test(rest)) { + scanner.pos = savedPos; + return false; + } + + // `+` followed by a digit could be ambiguous but we treat it as addition + // since `{{foo +1}}` is not valid Handlebars anyway + + scanner.pos = savedPos; + return true; + }; + const scanExpr = function (type) { let endType = type; if (type === 'INCLUSION' || type === 'BLOCKOPEN' || type === 'ELSE') endType = 'DOUBLE'; + // Check if the expression starts with a unary operator (`-` or `!`). + // If so, go directly into inline expression mode. + const preCheckPos = scanner.pos; + run(/^\s*/); + const firstChar = scanner.peek(); + scanner.pos = preCheckPos; + + // Unary `-` at start (but not `--`): always an inline expression. + // Note: `!` at start is NOT handled here because `{{!...}}` is comment syntax + // (filtered earlier by the COMMENT regex). If we reach here with `!`, + // it would be inside a sub-expression like `(!foo)`. + // + // We do NOT enter expression mode for `(`, digits, or strings here because: + // - `{{(helper arg)}}` is a valid sub-expression, not a grouped expression + // - `{{0 0}}` should produce the classic error, not an expression parse error + // Instead, those are handled by trying inline expression mode speculatively. + if (firstChar === '-' || firstChar === '!') { + // This must be an inline expression starting with unary op + const expr = scanInlineExpr(0); + const tag = new TemplateTag; + tag.type = type; + tag.expr = expr; + tag.path = ['__expr__']; + tag.args = []; + run(/^\s*/); + if (!run(ends[endType])) { + checkForbiddenOperator(); + expected(`\`${endsString[endType]}\``); + } + return tag; + } + + // For expressions starting with literals ({{0 + 1}}, {{"a" === "b"}}) or + // grouped expressions ({{(a + b) * c}}), try inline expression mode + // speculatively. If it doesn't consume everything up to }}, backtrack + // and fall through to classic parsing. + const isLiteralOrParenStart = (firstChar >= '0' && firstChar <= '9') || + firstChar === '"' || firstChar === "'" || + firstChar === '('; + if (isLiteralOrParenStart) { + const savedPos = scanner.pos; + try { + const expr = scanInlineExpr(0); + run(/^\s*/); + if (run(ends[endType])) { + const tag = new TemplateTag; + tag.type = type; + tag.expr = expr; + tag.path = ['__expr__']; + tag.args = []; + return tag; + } + } catch (e) { + // Fall through to classic parsing + } + scanner.pos = savedPos; + } + const tag = new TemplateTag; tag.type = type; tag.path = scanPath(); tag.args = []; + + // After the first path, check if an operator follows. + // If so, switch to inline expression mode. + if (shouldEnterExprMode(endType)) { + // Re-wrap the already-scanned path as an expression AST node + // and continue parsing as an inline expression. + const leftExpr = { type: 'PathExpression', path: tag.path }; + tag.expr = scanInlineExpr(0, leftExpr); + tag.path = ['__expr__']; + tag.args = []; + + run(/^\s*/); + if (!run(ends[endType])) { + checkForbiddenOperator(); + expected(`\`${endsString[endType]}\``); + } + return tag; + } + + // No operator found directly after the first path. + // For BLOCKOPEN with built-in helpers (if, unless, with, each), + // the arguments themselves may form an expression: + // {{#if score >= 90}} — `score >= 90` is an expression + // Strategy: scan the first arg value, then peek for an operator. + // If found, backtrack and re-parse all args as a single inline expression. + if (type === 'BLOCKOPEN' || type === 'ELSE') { + const argsStartPos = scanner.pos; + run(/^\s*/); + + // Don't try this if we're at the end already + if (!ends[endType].test(scanner.rest()) && !/^[})]/.test(scanner.peek())) { + // Save position, scan one arg value, then check for operator + const beforeFirstArg = scanner.pos; + try { + const firstArgVal = scanArgValue(); + // Check if an operator follows this first arg + if (shouldEnterExprMode(endType)) { + // This is an expression! Backtrack to before first arg and + // parse everything as a single inline expression. + scanner.pos = beforeFirstArg; + tag.expr = scanInlineExpr(0); + tag.args = []; + + run(/^\s*/); + if (!run(ends[endType])) { + checkForbiddenOperator(); + expected(`\`${endsString[endType]}\``); + } + return tag; + } + // No operator — backtrack to re-parse args in classic mode + scanner.pos = argsStartPos; + } catch (e) { + // If scanning failed, backtrack and let classic loop handle it + scanner.pos = argsStartPos; + } + } else { + scanner.pos = argsStartPos; + } + } + + // Classic Handlebars argument loop let foundKwArg = false; while (true) { run(/^\s*/); diff --git a/packages/spacebars/spacebars-runtime.js b/packages/spacebars/spacebars-runtime.js index 86e979849..756ea5d3e 100644 --- a/packages/spacebars/spacebars-runtime.js +++ b/packages/spacebars/spacebars-runtime.js @@ -100,6 +100,18 @@ Spacebars.dataMustache = function (...args) { return Spacebars.mustacheImpl(...args); }; +// Post-process an inline expression result. +// Same as Spacebars.mustache but without the helper-calling semantics. +// Used by the code generated for inline expressions like {{a + b}}. +Spacebars.expr = function (value) { + if (value instanceof Spacebars.SafeString) + return HTML.Raw(value.toString()); + else if (isPromiseLike(value)) + return value; + else + return (value == null || value === false) ? null : String(value); +}; + // Idempotently wrap in `HTML.Raw`. // // Called on the return value from `Spacebars.mustache` in case the diff --git a/site/source/api/spacebars.md b/site/source/api/spacebars.md index e59f322a1..374093189 100644 --- a/site/source/api/spacebars.md +++ b/site/source/api/spacebars.md @@ -132,6 +132,139 @@ all of the arguments will resolve. That is, `{% raw %}{{foo x y z}}{% endraw %}` will evaluate to `Promise.all([x, y, z]).then(args => foo(...args))`. Both pending and rejected states will result in `undefined`. +## Inline Expressions + +Spacebars supports inline expressions with arithmetic, comparison, logical, +and ternary operators directly inside template tags. This eliminates the need +for helper functions in many common cases. + +### Arithmetic + +```html +Total: {{price + tax}} +Discount: {{price * 0.9}} +Remaining: {{total - used}} +``` + +Supported operators: `+`, `-`, `*`, `/`, `%` + +### Comparison + +```html +{{#if score >= 90}} + Passed! +{{/if}} +``` + +Supported operators: `===`, `!==`, `>`, `>=`, `<`, `<=` + +### Logical + +```html +{{#if isLoggedIn && isAdmin}} + Admin panel +{{/if}} + +{{#unless isActive || isAdmin}} + Restricted +{{/unless}} +``` + +Supported operators: `&&`, `||`, `!` (as prefix, see note below) + +### Ternary + +```html +
+ {{status === "open" ? "Open" : "Closed"}} +
+``` + +### Grouping + +Parentheses can be used to control evaluation order: + +```html +{{(price + tax) * quantity}} +``` + +Without parentheses, standard JavaScript operator precedence applies +(`*` and `/` bind tighter than `+` and `-`, etc.). + +### Unary Operators + +```html +{{-amount}} +{{a + -b}} +``` + +### Expressions in Block Helpers + +Inline expressions work as arguments to `#if`, `#unless`, and `#with`: + +```html +{{#if items.length > 0}} + Showing {{items.length}} items +{{/if}} + +{{#if score >= 90 && !disqualified}} + Winner! +{{/if}} +``` + +### Expressions in Attribute Values + +```html +
+ +
+``` + +### Backward Compatibility + +Inline expressions are fully backward compatible. The classic helper call +syntax is unchanged: + +```html +{{! These still work exactly as before }} +{{helper arg1 arg2}} +{{helper arg1 key=value}} +{{helper (subhelper arg)}} +{{helper -1}} {{! negative number argument, NOT subtraction }} +``` + +The parser only switches to expression mode when it detects an operator +after the first path. Since operators like `+`, `*`, `===`, `&&` never +appear in valid Handlebars expressions, there is no ambiguity. + +### Unsupported Operators + +The following operators are intentionally not supported and will produce +clear error messages: + +* `|` — reserved for future pipe/filter syntax. Use `||` for logical OR. +* `&` — not supported. Use `&&` for logical AND. +* `++`, `--` — increment/decrement not allowed. +* `=`, `+=`, `-=` — assignment not allowed in expressions. + +### Limitation: `!` at Start of Tag + +Because `{% raw %}{{!{% endraw %}` is Spacebars comment syntax, you cannot start an expression +with the `!` operator. For example, `{% raw %}{{!isAdmin}}{% endraw %}` is parsed as a comment, +not as a negation. + +**Workaround:** wrap the negation in parentheses: + +```html +{{! ❌ This is a comment, not a negation }} +{{!isAdmin}} + +{{! ✅ These work correctly }} +{{(!isAdmin)}} +{{isActive && !isAdmin}} +{{#if (!isAdmin)}}...{{/if}} +``` + ## Inclusion and Block Arguments Inclusion tags (`{% raw %}{{> foo}}{% endraw %}`) and block tags (`{% raw %}{{#foo}}{% endraw %}`) take a single