Output

One walker, many targets

Cross-client HTML is table stakes; what the compiler adds is that one validated document becomes framework-compliant output for several targets and dialects. It is also the layer where silence is most dangerous, so it is built to fail loudly.

The entry point

ts
compile(doc, {
  target: 'html' | 'mjml' | 'react-email',
  variables?: 'none' | 'handlebars' | 'jinja2' | 'liquid' | 'thymeleaf' | 'jsx',
  annotate?: boolean,          // editor canvas only
})
// → { target, language, code }

lintDocument(doc)              // → LintIssue[], separately

Target and variable syntax are independent axes. The same document compiles to MJML with Jinja2 placeholders, or to HTML with Handlebars, without being edited (see Bindings).

How a target is implemented

Not as a file of branches. As a slot on each registry entry, visited by one generic tree-walker:

ts
type CompileFn = (block: Block, ctx: CompileContext) => string

interface CompileContext {
  options: ResolvedOptions           // target, variables, annotate
  width: number                      // px this block's box has to fill
  renderChildren(width): string[]    // children compiled at one width (stacks)
  renderChildrenAt(widths): string[] // each child at its own width (rows)
  runs(runs): string                 // inline text in the active dialect
  attr(block, key, name): string     // a content field, with any field binding applied
  // …
}

Because the walker is generic, structural bindings work for every type at once: a repeat on a section or frame wraps its compiled output, and a when wraps or annotates any block’s, in whichever dialect was requested.

No silent drops, ever

If compilerRegistry[type][target] is missing, the walker throws a CompileError naming the type and target. It never emits "". This is not defensive style. It is the specific bug that forced the rewrite, where an exporter’s default: case dropped every container and list from the sent email while the canvas rendered them perfectly.

Targets

TargetEmitsNotes
htmlInline-styled, table-based, fluid-hybrid documentThe reference target. Preview and export are this exact string; the canvas is it, annotated.
mjmlMJML sourceEmit-first and always valid (checked with mjml2html at zero errors). mjml.compile() runs in Node tests, and later server-side, never in the browser bundle.
react-emailA TSX module over @react-email/componentsData arrives as props (variables: 'jsx'). Same emit-first rule.

Emit-first, and why

MJML is a Node library with Node-isms; React Email pulls a component runtime. Shipping either to the browser to render a preview would bloat the bundle for no benefit, because what an engineer wants from those targets is source they can commit, not rendered output. So the browser emits source, and compilation happens where a Node runtime already exists.

Layout

HTML is fluid-hybrid: row frames become inline-block columns with exact px max-widths and an MSO ghost table, so layout never depends on a media query; gap becomes cell padding, never margin. Rows that never stack become equal-height table cells. The shell carries the MSO DPI fix, the preheader, lang/dir, role="presentation" and Apple data-detector resets. Buttons get a VML v:roundrect for Outlook (fully clickable, rounded, filled), and web-font documents get an Outlook font fallback.

In MJML, sections become mj-wrapper, a frame directly under a section becomes an mj-section of columns, and anything nested deeper ships as our own HTML tables inside mj-raw. MJML’s padding defaults are zeroed so the two targets agree. JSX cannot carry Outlook’s conditional comments, so React Email rows are equal-height table cells that do not stack, and there is no VML button.

Text compiles from runs, escaped by default, with literal {/[ neutralised so nothing literal can open a template tag.

Dark mode and mobile

Dark mode is generated from darkColor/darkBackgroundColor (documents opt in with colorScheme: "light dark"): color-scheme meta, prefers-color-scheme rules, plus [data-ogsc]/[data-ogsb] for Outlook.com. Mobile overrides (styles.mobile) compile to a media query. Both are HTML-only for now: the MJML and React Email targets refuse them rather than drop them.

An honest non-goal

Gmail’s forced colour inversion cannot be overridden by any sender. We emit the known mitigations and document the limit rather than implying a fix. A tool that quietly fails at dark mode is worse than one that tells you where the edge is.

Package layout

text
@repo/email-compiler
  index.ts         compile(), previewSrcDoc(), exportHtml(), canvasSrcDoc(), stripAnnotations()
  types.ts         CompileTarget, CompileContext, CompilerEntry, CompileError
  walk.ts          the generic tree-walker + the per-node context
  registry.ts      compilerRegistry: html, mjml and react-email for all 11 types
  skeleton.ts      the HTML document frame, responsive + dark-mode CSS
  mjml-skeleton.ts the MJML document frame
  dialects.ts      the dialect emitters: literal, variable, attribute, repeat, when
  layout.ts        row widths, padding and inner-width arithmetic
  lint/            lintDocument() and one file per rule group
    caniemail.ts   vendored support data (no network in the compile path)
  __tests__/       parity, html, mjml, react-email, dialects, engines, annotate, lint

The snapshot suites for the three targets are the highest-value regression net in the repo, and a CI job runs the emitted Jinja2, Liquid and Thymeleaf through the real engines. Every compiler change extends them.

Variables

Each dialect is one emitter answering the same questions: literal text, a variable run, a bound attribute, and how a block’s compiled output is repeated or made conditional. The walker asks them for every block, so no emitter knows block types. none (the default) renders each variable’s fallback as plain text and leaves structural bindings inert. See Bindings for what each dialect emits, and for the cases where the compiler throws rather than emit something wrong.