Documents & formats#

Everything in the family works on one tree. A format turns source text into that tree and back; a schema says what each node type in it is. Renderers and the editor only ever see the tree.

The tree#

A document is an mdast Root: paragraph, heading, blockquote, list / listItem, code, thematicBreak, table / tableRow / tableCell for blocks, and text, emphasis, strong, delete, inlineCode, break, link, image for phrasing content. The types are structurally assignable to @types/mdast, so a tree is valid remark input and vice versa.

Two sigx extensions sit on top of mdast:

  • key — a stable reconciliation key on every block-level node (b-0, b-1, … at the top level, path-based below). Renderers and the editor reconcile by it.
  • Code.open — true only while a fenced code block is unterminated, i.e. the source is still streaming. It is absent once the closing fence arrives.

Every node parsed from source also carries a position; sliceSource(source, node) returns the exact source text of a node.

TypeScript
import { markdownFormat } from '@sigx/richtext-markdown';

const root = markdownFormat.parse('# Hi\n\nSome **markdown**.');
// { type: 'root', children: [
//     { type: 'heading', depth: 1, key: 'b-0', children: [{ type: 'text', value: 'Hi' }], position: … },
//     { type: 'paragraph', key: 'b-1', children: [ … { type: 'strong', … } … ] } ] }

Walk a tree with visit(tree, visitor) or visit(tree, test, visitor) (return SKIP or EXIT to prune or stop) or transform it with map.

Plugin nodes#

A plugin adds node types of its own — a mention is { type: 'mention', id, label }. The package does not widen the mdast unions itself, so a plain tree stays exactly mdast; register a plugin node in your app to type it as phrasing content:

TypeScript
import type { Mention } from '@sigx/richtext';

declare module '@sigx/richtext' {
    interface PhrasingContentMap { mention: Mention }
}

The schema#

One Schema says what every node type is. Key assignment, the editor's block index, steps and transactions, the flat inline model, the render engine and the menus all read it instead of carrying their own lists.

A schema is built from NodeSpecs. createSchema(specs) implies nothing — it contains exactly the specs you pass (a later spec with the same type replaces an earlier one, with a dev warning). standardNodes / standardSchema are the mdast vocabulary the core owns; a format adds what its syntax needs (markdownFormat.nodes: html, definition, linkReference, imageReference), and plugins add theirs through RichTextPlugin.nodes.

Views and the editor derive their schema for you — the standard specs, the format's nodes and every plugin's nodes. Pass schema only to override that.

Roles#

Every spec has a role:

RoleMeaningStandard examples
textblockA block edited as a run of text with marksparagraph, heading, tableCell
containerA block whose children are keyed blocksblockquote, list, listItem, tableRow
tableA container rendered and edited as a gridtable
codeA literal block edited through a code surfacecode, markdown's html
voidA block with no editable content, selectable as a wholethematicBreak, definition
inlineLeaf phrasing content without a spantext, break
markA ranged span over textstrong, emphasis, delete, inlineCode, link
atomPhrasing content that occupies exactly one characterimage, mention

Editing flags#

The generic editor commands never name a node type; they read these flags instead.

FieldMeaning
fillsWithContainers: the block an empty one is filled with so a caret has somewhere to live (list item and blockquote → paragraph)
isolatingCaret edits never cross this block's boundary — it neither splits nor joins with its neighbours (a table, its rows and cells)
collapsesWhenEmptyA container removed along with its last child (list item, list, blockquote)
moveAsUnitMoving this container's only child moves the container (a list item)
allowsHardBreakText blocks: may hold hard breaks (a paragraph yes, a heading no)
splitsToEnter at the end creates a block of this type after it (heading → paragraph)
keyed / editableOverride the role defaults (keyed: every role except inline, mark, atom; editable: textblock and code)
entry, toInline, fromInlineWhere the caret lands when entering a container, and how a block converts to and from phrasing content (used by "turn into")
isSelectableVoid blocks: can be selected as a block (default true)
menuThe slash / block-menu entry: { label, icon?, group?, keywords?, create() }

Rendering hooks#

FieldMeaning
props(node, ctx)Extra props the node's component receives beyond node and children (a heading's depth, a list item's number, a link's sanitised url)
render(node, api, key)Render by hand instead of through a component — the escape hatch references use; return null to fall through
text(node)The plain-text projection a node renders as when no component exists for it
collect(node, env)Fold document-wide facts into the render env before rendering (a definition registers its label)
textOutputThe component may return a plain string (raw HTML rendered as text)
htmlAn HtmlTagHint { tag, aliases? } — the element a mark renders as in the DOM editor, and what an HTML format reads back (strong has aliases: ['b'])

The flat inline model#

The editor edits a text block as InlineFlat — a string plus ranged spans, with each atom occupying one U+FFFC character (ATOM_CHAR). A phrasing spec says how it maps through inline:

TypeScript
interface InlineFlatSpec {
    kind?: 'text' | 'break';          // role `inline` only
    literal?: boolean;                // a mark whose content is its own value (inline code)
    wrapsLiteral?: boolean;           // a mark that may enclose a literal one (a link over inline code)
    priority?: number;                // nesting order when extents tie (lower sits outermost), default 99
    toFlat?(node): Record<string, string>;                // the attrs the node carries
    fromFlat?(span, children): PhrasingContent;           // build the node back
}

The default toFlat keeps every string, number and boolean own property except type, children, value, position, data and key.

Formats#

A format is a codec between source text and the tree:

TypeScript
interface DocumentFormat<O = {}> {
    readonly id: string;                   // 'markdown', 'html', 'text' — also its slot in RichTextPlugin.formats
    readonly mime: readonly string[];      // clipboard types it reads, most specific first
    readonly nodes?: readonly NodeSpec[];  // specs its syntax needs beyond the standard ones
    parse(source: string, options?: FormatParseOptions & O): Root;
    serialize(node: Root | RootContent, options?: FormatSerializeOptions & O): string;
    createIncrementalEngine?(options?: FormatParseOptions & O): IncrementalEngine;
}

Both parse and serialize take plugins, whose formats[id] slice extends the syntax. A format that lists text/plain in mime claims plain-text pastes in an editor.

FormatPackageidmimeIncremental engine
markdownFormat@sigx/richtext-markdownmarkdowntext/markdown, text/x-markdown, text/plainYes — streaming-stable
htmlFormat@sigx/richtext-htmlhtmltext/htmlNo — re-parses
plainTextFormat@sigx/richtexttexttext/plainRe-parses

plainTextFormat is the smallest format: blank-line-separated paragraphs, where a newline inside a paragraph is a hard break. It is what an editor falls back to for text/plain when no other format claims it.

Converting between formats#

Every format parses into the same tree, so converting is parse then serialize:

TypeScript
import { markdownFormat } from '@sigx/richtext-markdown';
import { htmlFormat } from '@sigx/richtext-html';

htmlFormat.serialize(markdownFormat.parse('# Hi\n\nSome **markdown**.'));
// '<h1>Hi</h1>\n<p>Some <strong>markdown</strong>.</p>\n'

markdownFormat.serialize(htmlFormat.parse('<h1>Hi</h1><p>Some <b>html</b>.</p>'));
// '# Hi\n\nSome **html**.\n'

See Markdown and HTML for each format's options and syntax slice.

Saving documents#

toJSON(root, options?) returns the save format: a deep clone of the tree, without the transient parts (reconciliation keys, the streaming open flag and — unless position: true — positions), with data.version set to CURRENT_VERSION (1). Pass format to record the source format's id in data.format; it is informational, since the tree is the same whatever the format.

TypeScript
import { fromJSON, toJSON } from '@sigx/richtext';

const saved = JSON.stringify(toJSON(root, { format: 'markdown' }));
// { "type": "root", "children": [ … ], "data": { "version": 1, "format": "markdown" } }

const restored = fromJSON(saved);   // accepts the object or the JSON string

fromJSON(input, { schema? }) validates the shape and version and re-assigns keys, so the tree is ready to render or edit. It throws a DocumentFormatError whose code is 'invalid-shape' or 'unsupported-version' (a document written by a newer version).