Extending

Adding a block type

Two entries, one per registry. If any other file needed an edit to make your block work, something is wired wrong and the fix belongs there, not in another entry.

1 · The definition

In packages/block-ir/src/registry.ts. This is the only file in the IR package allowed to name individual types. A leaf needs a content schema, a styles schema and a registry entry; all three maps are typed Record<BlockType, …>, so TypeScript refuses to compile until each has the new key.

ts
// contentSchemas: semantic only. Text is runs, never HTML.
quote: z.object({ runs: runsSchema }).strict(),

// stylesSchemas: visual only, every key in a safety tier.
quote: stylesObject({ ...paddingStyles, ...backgroundStyles, ...borderStyles,
                      ...textTypography, ...sizedStyles }),

// registry
quote: {
  type: 'quote',
  kind: 'leaf',
  allowedChildren: null,
  singleton: false,
  layout: null,
  bindable: WHEN_ONLY,           // text variables are runs, so no fields
  contentSchema: contentSchemas.quote,
  stylesSchema: stylesSchemas.quote,
  defaults: () => ({
    content: { runs: runsFromText('Something worth quoting.') },
    styles:  { fontSize: '18px', lineHeight: '1.6',
               borderLeft: '3px solid #645FCF', padding: '8px 16px' },
  }),
  editorMeta: {
    label: 'Quote', icon: 'Quote',
    sections: ['content', 'variables', 'when', 'typography', 'background', 'spacing'],
    paletteVisible: true,
  },
},

Add 'quote' to LEAF_TYPES in types.ts and the grammar picks it up: sections and frames derive their allowedChildren from that list, so nothing else needs touching.

Two things the schema will hold you to

contentSchema must be .strict() and must contain nothing style-like: no align, no color (invariant 6). And defaults() must emit px, never rem or em: lint’s units rule flags relative units, and a registry test builds a schema-valid block from every entry’s defaults.

2 · The compiler entry

In packages/email-compiler/src/registry.ts: one emitter per target: html, mjml and react-email. Each emitter map is typed over every block type, and a parity test checks every target is implemented for every type.

ts
// htmlEmitters
quote: {
  html: (block, ctx) =>
    box(block, ctx, `<blockquote${ctx.classes(block, TEXT_OWNS)} style="${css({ margin: '0', ...splitBox(S(block)).content })}">${ctx.runs(C(block).runs)}</blockquote>`),
},

// mjmlEmitters
quote: (block, ctx) => mjText(block, ctx, ctx.runs(C(block).runs)),

// reactEmailEmitters
quote: (block, ctx) => {
  const { box: boxStyles, content } = splitBox(S(block));
  return jsxBox(ctx, boxStyles,
    `<${ctx.component('Text')}${jsxStyle({ margin: '0', ...content })}>${ctx.runs(C(block).runs)}</Text>`);
},
Never return an empty string

If a target genuinely cannot render your block, throw a CompileError that says why. A blank string is how content disappears between the canvas and the inbox.

No canvas component

The editor canvas is compile(doc, { annotate: true }) in an iframe, so it shows the new type the moment the HTML emitter exists. There is no per-type React component to write.

What you get for free

Nothing below needs an edit. If one of them does, that is the bug. Fix the derivation, not the symptom.

  • blockSchema and documentSchema accept the new type
  • createBlock('quote') works with correct defaults
  • The palette shows it; the inspector renders the sections you listed
  • The canvas renders it, and drag-and-drop validates it against the grammar
  • documentSchemaForAI includes it (minus its restricted style keys), and the MCP describe_blocks tool lists it
  • cloneSubtree, hydrate and every tree utility handle it

Verify

bash
pnpm -F @repo/block-ir test        # registry + invariants
pnpm -F @repo/email-compiler test  # parity + snapshots + the no-switch scan
pnpm test                          # everything

Until both entries exist, the parity tests fail and name exactly which one is missing. Then add the type to the block reference in the same PR.