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