The Block IR

Block reference

Eleven types: three containers that carry the grammar, and eight leaves that carry the content. Each one is a single entry in the definition registry.

Containers

TypeKey stylesNotes
bodycontentWidth (560–640), backgroundColor, fontFamily, paddingThe document root. Content carries subject, preheader, lang, dir and colorScheme. Defaults to a 600px content width.
sectionpadding, backgroundColor, backgroundImage, borders, hideOnA full-width band whose children stack vertically.
framedirection (row | stack), gap, padding, align, valign, stackOnMobileAuto-layout. Nests up to 3 deep; a row frame holds at most 4 children, each sized by styles.size.

Leaves

Every leaf below is legal directly inside a section or a frame (see Document & grammar). Inside a row frame, any of them may carry styles.size: { fixed: px } or { fill: pct }.

TypecontentNotes
heading{ runs: Run[]; level: 1 | 2 | 3 }One heading type. There are no header1/header2/header3 variants: the level is data.
text{ runs: Run[] }A paragraph. Inline marks, links and variables are runs, never HTML.
image{ src: string; alt: string; decorative?: true; href?: string }alt may be empty only with decorative: true. Display width and height are px numbers in styles.
button{ runs: Run[]; url: string }Compiled as a bulletproof VML button, clickable edge to edge, not an <a> with padding.
divider{}Purely visual. The rule itself is styles.borderTop.
spacer{}Purely visual: styles.height.
list{ items: Run[][]; ordered: boolean }A boolean, not a 'ordered' | 'unordered' string.
table{ header?: Run[][]; rows: Run[][][]; rowRepeat?: RowRepeat }Dimensions are derived from the matrix. rowRepeat renders line items between the header and the static rows.
Why table is here

Earlier drafts of the plan listed ten types and left table out. Every transactional template in the migration set is built around one, and dropping them during migration would have been precisely the silent-drop failure this project exists to eliminate. It was added through the normal expansion path: one registry entry, no grammar change.

Styles are strict, per type

Each type has its own styles vocabulary, and it is a .strict() schema rather than an open bag. A spacer has height and backgroundColor; it does not have fontSize. This is deliberate:

ts
// ✗ fails to parse: box-shadow does not render in most email clients,
//   and 'borderCollapse' is the compiler's decision, not the document's
{ type: 'table', styles: { boxShadow: '0 2px 4px rgba(0,0,0,.05)',
                           borderCollapse: 'collapse' } }

Drift gets caught at the boundary instead of riding along to somebody’s inbox. When migrate() meets a key like that in an older document, it drops it and reports a warning, never silently.

Safety tiers

Every style key has a tier, exported as styleTiers from @repo/block-ir:

  • safe renders everywhere: colour, padding, borders, textAlign, lineHeight, letterSpacing, textTransform.
  • progressive degrades acceptably: borderRadius, darkColor/darkBackgroundColor, styles.mobile overrides, hideOn, reverseOnMobile.
  • restricted loses content in some client: only backgroundImage, which needs a fallback colour.

The canonical schema accepts every tier; lint reports progressive keys as info and restricted keys as a warning. The AI schema omits restricted keys entirely. Dark twins and mobile overrides are derived per type, so a block can only override what it already styles. margin, maxWidth and boxShadow do not exist at all.

Defaults

Defaults come from registry[type].defaults() and nowhere else. There is no standalone getDefaultContent helper to fall out of sync with the schema, and a test builds a schema-valid block from every entry’s defaults alone.

ts
import { createBlock, createDocument, runsFromText } from '@repo/block-ir'

createBlock('heading')       // registry defaults, fresh id
createBlock('heading', { content: { runs: runsFromText('Welcome') } })
createDocument([createBlock('section')])

Adding a type to this page means adding it to the registry. See Adding a block type.