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.
// @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:
import { assertRegistryParity } from '@repo/block-ir/testing'
it('is in parity with the definition registry', () => {
assertRegistryParity('compilerRegistry', Object.keys(compilerRegistry))
})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.
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.
blockSchemais a discriminated union assembled by iterating the registry, so there is no hand-written union to forget. - Defaults.
registry[type].defaults(). There is no standalonegetDefaultContent. - 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 howbodystays out of it. - The AI schema.
documentSchemaForAIis generated from the same registry (no ids, frames unrolled asframe_1/2/3because strict-mode providers reject recursive schemas,restrictedkeys 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:
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 →