Skip to main content

Rich text is typed data, never markup

core exports richText(), a Standard Schema over an ordered array of blocks, each an ordered array of spans. A span is { text, marks?, href? } and a block is { kind, spans }. Both marks and kind are closed sets. The consumer renders the tree with a component from their own design system; core ships no renderer and no CSS, and a mark names what a run is rather than how it looks. Nothing in the value is parsed and nothing is evaluated at render, so artifacts contain data, never code holds by construction rather than by promise. A block declares body: richText(), and that call is what makes every rich-text field in a registry findable by a scan.

The schema is hand-written against @standard-schema/spec, so core depends on nothing still holds.

What forced it

Every text field a block declares is one opaque string, and a sentence that links a word inside itself has nowhere to put the link. An author writing that repairs to the harbour wall begin on Monday, with those five words linking to the piece about them, cannot express it as a string prop: they are made to choose between the link and the sentence. A page builder whose own site cannot be composed from its blocks has answered the question already.

What it beat

A marked opaque string holding HTML or a serialized rich-text document. Markup inside a string is a styling channel the schema cannot see, and content that has any use for the channel will use it. Rendering the string needs either dangerouslySetInnerHTML or a parser, and both reopen sanitization — a question this model declines rather than answers.

Refusing inline content, treating inline structure as a block boundary and accepting the loss. Splitting a sentence across two blocks so that a word inside it can be a link produces two sentences, neither of which is the one that was written.

Widening a paragraph type per inline feature{ text, href? } first, and whatever the next page needs after that. It carries the /about anchor and still drops the /security emphasis, so it is not one decision but a schema decision remade for every construct real content turns out to contain. A closed mark set makes that decision once.

What it forecloses, and the argument against it

An inline construct outside the mark set is unavailable until someone adds it as a member. An author who wants a superscript opens an issue, and the enum is where the answer lands.

The cost is that core now holds an opinion about what a kind of content is, where every opinion it held before was about how content is described. That is a real widening of its remit and the strongest argument against this record. It is defensible on two grounds. The schema is opt-in: a block that declares no richText() field is unaffected by any of it. And refusing the opinion does not avoid it — it moves it, leaving every consumer to invent a rich-text shape of their own, which puts the same decision in every codebase and agreement between none of them.