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
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[], separatelyTarget 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:
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.
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
| Target | Emits | Notes |
|---|---|---|
html | Inline-styled, table-based, fluid-hybrid document | The reference target. Preview and export are this exact string; the canvas is it, annotated. |
mjml | MJML source | Emit-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-email | A TSX module over @react-email/components | Data 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.
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
@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, lintThe 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.