Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
51 changes: 45 additions & 6 deletions packages/spacebars-compiler/codegen.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,22 +67,51 @@ 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: `<div {{attrs}}>...`
// only `tag.type === 'DOUBLE'` allowed (by earlier validation)
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})`;
}
if (tag.position !== HTMLTools.TEMPLATE_TAG_POSITION.IN_ATTRIBUTE) {
// 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') {
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
276 changes: 276 additions & 0 deletions packages/spacebars-compiler/spacebars_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,280 @@ Tinytest.add("spacebars-compiler - parse", function (test) {
test.equal(BlazeTools.toJS(SpacebarsCompiler.parse('<input selected={{!--foo--}}{{!--bar--}}>')),
'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);

});
Loading