Extending

Per-type behaviour is data, not branches

The single most important structural rule in the codebase: no file outside a registry may branch on a block's type. Everything that needs to behave differently per type reads it from a registry entry instead.

Why the rule exists

Before the rewrite there were three independent answers to “what does a block look like”: a string-concatenating exporter, a set of React components, and a preview mode that had quietly been switched to render the second one. They drifted. The exporter had no case for containers or lists, so both rendered on the canvas and vanished from the sent email.

The fix is not to keep three implementations in sync by discipline. It is to make the per-type knowledge a single data structure that every consumer folds over.

Registries, one key set

Exactly two registries exist, and they share one key set.

ts
// @repo/block-ir: the definition registry
interface BlockDefinition {
  type: BlockType
  kind: 'container' | 'leaf'
  allowedChildren: readonly BlockType[] | null
  singleton: boolean
  fixedId?: string
  layout: { axis; maxNesting?; maxRowChildren? } | null   // limits are data
  bindable: { fields: readonly string[]; repeat: boolean; when: boolean }
  contentSchema: z.ZodTypeAny
  stylesSchema: z.ZodTypeAny     // every key carries a safety tier
  defaults: () => { content; styles }
  editorMeta: { label; icon; sections; paletteVisible }
}

// @repo/email-compiler
compilerRegistry: Record<BlockType, { html; mjml; 'react-email' }>

They are stitched together by a parity test the compiler runs against its own map:

ts
import { assertRegistryParity } from '@repo/block-ir/testing'

it('is in parity with the definition registry', () => {
  assertRegistryParity('compilerRegistry', Object.keys(compilerRegistry))
})
Parity tests are mandatory

Adding a block type without an entry in both registries fails CI. The failure message names the missing keys and the files that need them, because a parity failure at 5pm on a Friday should not require reading this page.

Why there is no canvas registry

The v1 editor had a third registry of React canvas components. It was retired in Phase E: the canvas is now the compiled HTML in an iframe with an editing overlay. A third registry would only be a second renderer, drifting from the first, which is the exact failure the registry exists to prevent.

What folds over the registry

Everything below is derived. None of it is hand-maintained, and none of it needs an edit when a type is added:

  • The schema. blockSchema is a discriminated union assembled by iterating the registry, so there is no hand-written union to forget.
  • Defaults. registry[type].defaults(). There is no standalone getDefaultContent.
  • Each compile target. One generic tree-walker calling compilerRegistry[type][target] per node.
  • The inspector. It renders editorMeta.sections, with controls generated from each type’s own styles and content schemas.
  • The palette. Filters on editorMeta.paletteVisible, which is how body stays out of it.
  • The AI schema. documentSchemaForAI is generated from the same registry (no ids, frames unrolled as frame_1/2/3 because strict-mode providers reject recursive schemas, restricted keys omitted), with a parity test against the canonical schema.
  • describe_blocks. The MCP tool an agent writes against is one more fold.

Grammar helpers come from it too

Drag-and-drop validation, the tree utilities and the AI repair pass all ask the same question through the same function, rather than each deciding what may nest where:

ts
canContain('frame', 'heading')    // true
canContain('section', 'heading')  // true: sections hold frames and leaves
canContain('body', 'heading')     // false
assertCanContain('body', 'heading')
// throws GrammarError:
//   cannot place "heading" inside "body": "body" accepts only [section]

Ready to add one? Adding a block type →