Editor Data Model
The editor is a tree of blocks. Everything else is sugar on top.
A consumer who knows these types can read source and write code without guessing. Most of the friction in editor work comes from people guessing at fields that already exist or inventing duplicates of fields that already mean something. Read this page first. Then read the code.
BaseBlock
The shared shape. Defined at packages/ui/src/primitives/types.ts:171.
interface BaseBlock {
id: string;
type: string;
content?: string | InlineContent[];
children?: string[];
parentId?: string;
meta?: Record<string, unknown>;
}
id is a UUID. Never collides with anything in the tree. instantiateBlocks regenerates IDs on insertion so two clones of the same composite never share IDs.
type is a string, not an enum. The editor knows about heading, text, code, quote, divider, image, list, list-item. Consumers can introduce their own types and the editor will treat them as default <p> blocks until rendered explicitly. Composites use namespaced types like composite:login-form.
content accepts a plain string or an InlineContent[] array. The array form preserves bold, italic, code, strikethrough, and link marks across serialization. The string form does not. Both render correctly. Only the array survives a round trip.
children is an array of block IDs. Not nested objects. The block array is flat. Parent and child relationships are reference-only.
parentId is the ID of the containing block. Root blocks have no parentId. The serializer derives input rules from root blocks and output rules from leaf blocks (no children, or empty children array).
meta is type-specific. A heading block stores meta.level (1–6, clamped at render time). An image block stores meta.src and meta.alt. A grid block stores meta.columns. The shape is Record<string, unknown> because each block type defines its own.
EditorBlock
EditorBlock extends BaseBlock with one runtime-only field. Defined at packages/ui/src/components/ui/editor.tsx:37.
type AppliedRule = string | { name: string; config: Record<string, unknown> };
interface EditorBlock extends BaseBlock {
rules?: AppliedRule[];
}
Rules are validators. They attach to blocks and describe what’s required (input rules on root) or guaranteed (output rules on leaves). A bare string is the rule name. An object form lets the rule carry parameters. The runtime validation engine doesn’t exist yet — see Editor Rules for the gap.
CompositeBlock
The serialization-time shape. Same fields as BaseBlock, validated through Zod at packages/composites/src/manifest.ts:28. Composites carry their rules array on every block.
const CompositeBlockSchema = z.object({
id: z.string(),
type: z.string(),
content: z.union([z.string(), z.array(InlineContentSchema)]).optional(),
children: z.array(z.string()).optional(),
parentId: z.string().optional(),
meta: z.record(z.unknown()).optional(),
rules: z.array(AppliedRuleSchema).optional(),
});
EditorBlock and CompositeBlock are structurally identical. The split exists because the editor lives in @rafters/ui and composites lives in @rafters/composites and neither wants to import the other. Same shape, two definitions, validated independently.
InlineContent
Rich text. Defined at packages/ui/src/primitives/types.ts:188.
type InlineMark = 'bold' | 'italic' | 'code' | 'strikethrough' | 'link';
interface InlineContent {
text: string;
marks?: InlineMark[];
href?: string;
}
A run of text with optional marks. href is only present when marks includes 'link'.
A paragraph with mixed formatting becomes an array of segments:
content: [
{ text: 'The ' },
{ text: 'quick', marks: ['bold'] },
{ text: ' brown ' },
{ text: 'fox', marks: ['italic'] },
{ text: ' jumps.' },
]
The MDX serializer at packages/ui/src/primitives/serializer-mdx.ts round-trips this faithfully. The legacy toMdx() in @rafters/composites flattens it to plain text and loses the marks. Use mdxSerializer from @rafters/ui.
Mark application order at render time, defined in editor.tsx:129: code, then link, then strikethrough, then italic, then bold. Bold is the outermost wrapper. This matters when you read the rendered HTML and wonder why your <strong> ended up wrapping everything else.
Command
For the slash command palette. Defined at packages/ui/src/primitives/types.ts:227.
interface Command {
id: string;
label: string;
description?: string;
icon?: string;
category?: string;
keywords?: string[];
shortcut?: string;
action: () => void;
}
action runs synchronously when the command is selected. Async work belongs inside it. The fuzzy search at command-palette.ts indexes label and keywords.
FormatDefinition
For inline formatter presets. Defined at packages/ui/src/primitives/types.ts:241.
interface FormatDefinition {
name: InlineMark;
tag: string;
shortcut?: string;
attributes?: Record<string, string>;
class?: string;
}
The formatter ships presets for bold, italic, code, strikethrough, and link. Custom marks are not supported. The mark types live in the union at InlineMark and adding a new mark means changing the type.
Keyboard Types
type KeyboardKey =
| 'Enter' | 'Space' | 'Escape' | 'Tab'
| 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight'
| 'Home' | 'End' | 'PageUp' | 'PageDown'
| 'Backspace' | 'Delete';
interface KeyboardModifiers {
shift?: boolean;
ctrl?: boolean;
alt?: boolean;
meta?: boolean;
}
The keyboard handler normalizes Space to ' ' internally. That’s the one thing to know.
SelectionRange
For low-level selection work. Defined at packages/ui/src/primitives/types.ts:216.
interface SelectionRange {
startNode: Node;
startOffset: number;
endNode: Node;
endOffset: number;
collapsed: boolean;
}
Most consumers won’t touch this. The cursor-tracker primitive handles selection plumbing for the editor.
Palette Item Shapes
BlockPaletteItem lives in packages/ui/src/primitives/block-palette.ts. RulePaletteItem lives in packages/ui/src/primitives/rule-palette.ts. Both share the same idea: an id, a label, a category, and an optional keywords array. The fuzzy search uses label plus keywords.
@rafters/composites/bridge.ts:56 produces BlockPaletteItem directly from a composite manifest. That import is the only known type-coupling between composites and ui. See Architecture for the violation.
Composite Manifest
The full Zod schema at packages/composites/src/manifest.ts:48.
const CompositeManifestSchema = z.object({
id: z.string().regex(/^[a-z0-9-]+$/),
name: z.string(),
category: z.string(),
description: z.string(),
keywords: z.array(z.string()),
cognitiveLoad: z.number().int().min(1).max(10),
solves: z.string().optional(),
appliesWhen: z.array(z.string()).optional(),
usagePatterns: z.object({
do: z.array(z.string()),
never: z.array(z.string()),
}).optional(),
});
const CompositeFileSchema = z.object({
manifest: CompositeManifestSchema,
input: z.array(z.string()),
output: z.array(z.string()),
blocks: z.array(CompositeBlockSchema),
});
id is kebab-case and derived from name by serializeToComposite. category is a free-form string. It used to be an enum. It is not anymore. Don’t hardcode categories.
cognitiveLoad is clamped 1 through 10 at serialize time. solves, appliesWhen, and usagePatterns are optional and were added later. Composites in the wild may not have them.
What’s Not A Type
Two things people invent that shouldn’t exist:
A BlockTree type. There isn’t one. The block array is the tree. Walk by parentId and children IDs.
A Document wrapper. There isn’t one. The editor takes EditorBlock[]. Frontmatter for serialization is a separate object passed alongside, not embedded.
If a function would take { blocks, frontmatter }, the fields are separate parameters or a structural object literal. Don’t introduce a wrapper type.