The Block IR

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

ts
// 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.

Syntax is a compile option, never stored

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.

BindingValid onRationale
fieldsimage (src, href), button (url)Text binds through runs, so only non-text fields need a field binding.
repeatsection and frame; table rows through content.rowRepeatRepeated bands, cards and line items are the loops email layouts actually need.
whenAnything except bodyConditionally rendering the document itself is meaningless.

What each dialect emits

HandlebarsJinja2LiquidThymeleaf
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 |e and | escape. A run with raw: true opts 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.
  • eq and neq throw 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, repeat into .map() and when into 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 repeat on a child of a row frame (a grid). Repeat the row frame instead.
  • eq/neq under 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-in FileTemplateEngine of @nestjs/mail (tested with 0.0.1) does not support them and refuses the whole template, with block parameters (as |item|) aren't supported. Everything else in the Handlebars output (escaped and raw variables, fallbacks, when and its negate, bound src and href) renders the same text there as in real Handlebars. With @nestjs/mail, render Heddlemail templates through a custom MailTemplateEngine backed by the handlebars package, and leave its layout option unset: the compile is already a complete HTML document.
Only Handlebars is ever executed

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 and raw.
  • Repeat, When and bound fields are inspector panels, shown only when the type’s bindable capabilities 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 sampleCount copies, 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.