Variables & bindings
A variable attaches data to a block. The templating syntax it compiles to is not part of the document. It is an option you pass at compile time, so one document can ship as Handlebars, Jinja2, Liquid or Thymeleaf without being edited.
Variables live in two places. Text variables are runs inside a block’s content; everything structural or non-textual is a binding.
The shape
// inside content: heading, text, button, list items and table cells are Run[]
type Run = { text: string; marks?: Mark[]; href?: string }
| { var: string; fallback?: string; raw?: boolean }
interface Bindings {
fields?: Record<string, FieldBinding> // only the fields a type declares: src, url, href
repeat?: RepeatBinding // section and frame
when?: WhenBinding // anything except body
}
interface FieldBinding { variableName: string; fallback?: string; sampleValue?: string }
interface RepeatBinding { collection: string; itemAlias: string; sampleCount?: number }
interface WhenBinding { path: string; op: 'truthy' | 'eq' | 'neq'; value?: string; negate?: boolean }
// eq/neq require a value; truthy refuses one.
// table.content: rendered header → one row per item → static rows (totals)
rowRepeat?: { collection: string; itemAlias: string; cells: Run[][]; sampleCount?: number }Because a variable is a run, Hi {{user.name}}, order #{{order.id}} is expressible inside one paragraph. Every name is a plain dotted path (user.first_name, order.items.0.sku), so every dialect can address it verbatim.
An earlier draft put syntax: 'handlebars' | 'jinja2' inside each binding. That permits a document with three dialects mixed into one email, which no backend can render. It is a compile argument instead: compile(doc, { target, variables: 'jinja2' }).
Placement rules
Where a binding is legal is registry data, not a special case in the schema builder. Each definition declares what it can bind (bindable), and the bindings schema for that type is assembled from it, so a repeat on a button, a field binding on a heading, or a when on the root, simply fails to parse.
| Binding | Valid on | Rationale |
|---|---|---|
fields | image (src, href), button (url) | Text binds through runs, so only non-text fields need a field binding. |
repeat | section and frame; table rows through content.rowRepeat | Repeated bands, cards and line items are the loops email layouts actually need. |
when | Anything except body | Conditionally rendering the document itself is meaningless. |
What each dialect emits
| Handlebars | Jinja2 | Liquid | Thymeleaf | |
|---|---|---|---|---|
| variable | {{user.name}} | {{ user.name|e }} | {{ user.name | escape }} | <span th:text="${user.name}"> |
| + fallback | {{#if …}}…{{else}}fb{{/if}} | {% if … %}…{% else %}fb{% endif %} | {% if … %}…{% else %}fb{% endif %} | #strings.isEmpty(…) ? 'fb' : … |
| repeat | {{#each products as |product|}} | {% for product in products %} | {% for product in products %} | th:each="product : ${products}" |
| when | {{#if cond}} … {{/if}} | {% if cond %} … {% endif %} | {% if cond %} … {% endif %} | th:if="${cond}" |
- Escaping is on by default in every dialect. Jinja2 and Liquid do not escape natively, so the emitter adds
|eand| escape. A run withraw: trueopts out, with a lint warning. A user’s literal{{is emitted as entities and can never become template code. - Fallbacks fire on null or empty in every dialect; where a dialect’s default filter disagrees, the emitter writes an explicit if/else.
eqandneqthrow under Handlebars. It has no portable equality, and the compiler refuses rather than emit an ESP-specific helper.- React Email binds through props, not a dialect:
variables: "jsx"turns variables into null-safe prop reads,repeatinto.map()andwheninto a conditional. Templating dialects are refused for React Email: React decodes the entities that keep a literal{{inert, so it could become template code.
Handlebars, Jinja2 and Liquid are wrapping emitters: tags go around the compiled block. Thymeleaf is attribute-based, so structural emitters get access to the block’s root element attributes, not just a string-wrap hook. Getting that distinction wrong is how most tools end up unable to support Thymeleaf at all.
A bound attribute (an image src, a button url) is a value in place for the wrapping dialects (src="{{ user.avatar|e }}"). Thymeleaf cannot evaluate inside an attribute value, so it keeps a placeholder and adds the th: twin: src="…" th:src="${user.avatar}". The file still renders as a prototype before your backend fills it in. A table’s rowRepeat puts th:each on the <tr>.
When a block has both, when sits inside repeat: the condition is evaluated per item and may reference the alias, matching Thymeleaf’s own th:each-before-th:if order.
On the MJML target
MJML rejects loose text between components and drops attributes it does not know, so structural tags travel inside <mj-raw>, which mjml.compile() passes through untouched. Thymeleaf uses its element-less <th:block th:each="…"> there. A Thymeleaf attribute binding cannot survive MJML at all, so compiling one to mjml throws. Compile to html instead.
What the compiler refuses
Silent drops are the bug class the compiler exists to prevent, so these are a CompileError naming the block, not a best-effort emission. (A name that is not a plain dotted path, or a binding on a field the type does not declare, never gets this far; the schema rejects it.)
- A field binding on content the block’s emitter never reads. It would otherwise vanish from the output.
- A
repeaton a child of a row frame (a grid). Repeat the row frame instead. eq/nequnder Handlebars, and a templating dialect on the React Email target.
Fallback strings are escaped for each dialect’s string literal and for the markup around it, so a fallback containing quotes or braces cannot break the template.
Scope
The scope model is emit what is written. Inside a section or frame that repeats products as product, you write product.name and the compiler emits exactly that, per dialect. No scope inference, no rewriting.
The safety net is a lint rule rather than magic: if a variable references an alias that no enclosing repeat defines (say the repeat was removed but product.name stayed), the orphan-alias lint flags it.
Known limits of the Handlebars output
Two gaps in what the Handlebars dialect emits for repeat, recorded here rather than hidden. Jinja2, Liquid, Thymeleaf and React Email are not affected.
- Variables from outside a repeat render empty inside it. Inside
{{#each products as |product|}}, Handlebars looks names up on the current item only, so{{user.firstName}}in a repeated card prints nothing. The editor’s sample-data preview shows the same blank. Variables that use the alias (product.name) are fine. - Engines that implement only a subset of Handlebars may reject repeats. The repeat uses block parameters (
as |product|). The built-inFileTemplateEngineof@nestjs/mail(tested with 0.0.1) does not support them and refuses the whole template, withblock parameters (as |item|) aren't supported. Everything else in the Handlebars output (escaped and raw variables, fallbacks,whenand itsnegate, boundsrcandhref) renders the same text there as in real Handlebars. With@nestjs/mail, render Heddlemail templates through a customMailTemplateEnginebacked by thehandlebarspackage, and leave itslayoutoption unset: the compile is already a complete HTML document.
Jinja2, Liquid and Thymeleaf are emit-only, which is why supporting them costs zero runtime dependencies. A CI job runs the real Jinja2, Liquid and Thymeleaf engines over the same data and checks them against the Handlebars output.
In the editor
- Variables are chips. Typing
{{while editing text opens a picker. It suggests paths, including the enclosing repeat’s item fields under its alias, and inserts a variable run. The inspector edits each chip’s path, fallback andraw. - Repeat, When and bound fields are inspector panels, shown only when the type’s
bindablecapabilities allow them. Every write is validated against what the type may bind. - Sample data is editor state, never document state. It is derived from the document’s variables, repeats and conditions (a repeat renders
sampleCountcopies, default 3), and keeps the values you type. “Preview with sample data” executes the Handlebars canvas compile with it, the only place any dialect is ever executed. A repeat then shows its real copies, each selectable.
Django, Blade, Razor and Go dialects are planned for Phase H.