The Block IR

One rooted tree, with a grammar

There is no top-level block array. A document is a single tree whose root is always a body block, and containment is spelled out by the grammar rather than implied by the presence of children.

The document

ts
interface EmailDocument {
  version: 2          // older documents are upgraded by migrate()
  root: BodyBlock     // id is the constant 'body'
}

interface Block {
  id: string          // nanoid(10), unique per document
  type: BlockType
  content: …          // WHAT the email says
  styles:  …          // HOW it looks
  bindings?: …        // variables (see Bindings)
  children: Block[]   // containers ONLY; leaves omit the key entirely
}

Because the root is a real block, every tree operation takes (root, id). There is no special case for “top level” anywhere in the codebase. That sounds minor until you count how many bugs the old store had from handling root blocks differently from nested ones.

The grammar

Email is table-based and MJML’s nesting is strict, so containment is encoded, not guessed:

grammar
body → section → frame (nesting ≤ 3) → leaf
TypeKindAllowed childrenNotes
bodycontainer['section']Singleton root. Never in the palette, never deletable.
sectioncontainerframe and every leafA full-width vertical stack. Background, padding and hideOn live here.
framecontainerframe and every leafdirection: row | stack. Nests at most 3 deep; a row has at most 4 children, each sized { fixed: px } | { fill: pct }.
heading text image button divider spacer list tableleafnullNo children key at all, not an empty array.

A fixed body → section → column → leaf ladder cannot express Figma-like layout, such as a row of cards, each with a nested icon-and-text row. Frames can, and depth stays bounded by the grammar rather than by a runtime cap.

The limits are registry data

The frame limits (layout.maxNesting 3, layout.maxRowChildren 4) live on the frame’s registry entry, are enforced by the schema and unrolled in the AI schema, so the maximum tree depth is six and the AI schema is a finite JSON Schema with no recursion. Nothing else hard-codes them.

Auto-layout maps straight to tables: a row becomes cells in one row, a stack becomes stacked rows, and gap becomes cell padding, never margin. Anything that cannot map to tables (absolute positioning, overlap, wrap, hug width in rows) is not in the vocabulary. v1 documents (the earlier column grammar) enter only through migrate().

Ordering

Order is the children array index. Full stop. There is no position or order field, and a block carrying one fails to parse: the per-type object schemas are .strict(). Two sources of truth for ordering is one too many.

Content versus styles

content is semantic. styles is visual. Nothing style-like ever appears in content, and this is enforced by the schema rather than hoped for by convention:

ts
// ✓ valid
{ type: 'heading', content: { runs: [{ text: 'Welcome' }], level: 1 },
                   styles:  { textAlign: 'center', color: '#18181B' } }

// ✗ fails to parse: 'align' is not a semantic property of a heading
{ type: 'heading', content: { runs: [{ text: 'Welcome' }], level: 1, align: 'center' } }

Every compile target, the variable system and the AI schema are views over this split. If it were ambiguous, all three would inherit the ambiguity.

Runs, not HTML

Text content is runs, never a string of markup: heading, text and button carry runs: Run[], list items and table cells are Run[] too. A run is either literal text with optional bold/italic/underline marks and an href, or an inline variable { var, fallback?, raw? }. The compiler escapes every literal per dialect. body carries document-level content: subject (compiled into the document <title>), preheader, lang, dir and colorScheme.

Identity

RuleWhy
nanoid(10), unique per documentShort enough to read in a diff, long enough not to collide.
Stable across move, edit and undoSelection survives a drag; a future collaboration layer needs stable identity.
Regenerated for every node by cloneSubtree()Duplicate and paste must go through it. Inserting a subtree whose ids already exist throws.
Never generated by the AIid is absent from the AI-facing schema; hydrate() stamps ids after parsing.
Root id is the constant 'body'A literal in the schema, so there is exactly one root and it is findable without a search.

The properties the schema guarantees are listed in full under Invariants.