A richtext value is the document the editor produces — a node tree, stored
in a jsonb column. It is not an HTML string.
body: field.richtext({ required: true, maxLength: 20000 })That single decision is what makes a body’s heading order, its tables’ header cells, its link text and the language of a quoted passage into facts the system can check, rather than characters in a blob it can only store.
The shape of a value
{
"type": "doc",
"content": [
{ "type": "heading", "attrs": { "level": 2 },
"content": [{ "type": "text", "text": "Getting started" }] },
{ "type": "paragraph",
"content": [
{ "type": "text", "text": "Read the " },
{ "type": "text", "text": "installation guide",
"marks": [{ "type": "link", "attrs": { "href": "/install" } }] }
] }
]
}A text node carries characters and no children. Every other node carries
children and no text of its own.
Nodes
doc, text, paragraph, heading (with attrs.level from 1 to 6),
bulletList, orderedList, listItem, blockquote, codeBlock,
horizontalRule, hardBreak, image, table, tableRow, tableHeader,
tableCell, tableCaption, and container.
Marks
link (with attrs.href), bold, italic, underline, strike, code,
highlight, textStyle, and language (with attrs.lang, a BCP-47 tag).
The vocabulary is open. These are the nodes and marks the platform reasons about; an editor extension may store its own node types alongside them, and they pass through untouched.
Structural rules
A body is checked against a set of structural rules on every save. The rules live in one place and both runtimes apply them: the server refuses a body that trips an error, and the editor shows the whole list — warnings included — while the author is still in a position to fix it.
| Finding | Severity | Why |
|---|---|---|
headingLevelSkipped | error | A heading more than one level below the one before it. |
headingLevelInverted | error | A heading above the body’s own top level — an inverted outline. |
headingEmpty | error | A heading with no text names nothing. |
tableMissingHeader | error | A table with neither header cells nor a caption. |
linkTextEmpty | error | A link with nothing to announce. |
linkTextNotDescriptive | warning | Link text that says nothing about where it goes. |
invalidLanguageTag | error | A lang marker no user agent can parse. |
Only errors block a save. The one warning is a judgement — “click here” is poor link text, but that is not decidable from the string with certainty — so the kernel never fails a write on a heuristic.
Each finding carries the WCAG success criterion it comes from, which is what the editor shows the author when it explains the problem.
Turning it off
emailTemplate: field.richtext({ validation: { structure: 'off' } })For a body that is genuinely not a document — a hand-maintained fragment, an email template — where the rules would be measuring the wrong thing. It is per-field and explicit, and the default is to check.
Lengths count text, not markup
minLength and maxLength on a richtext field count the body’s text.
Bolding a word does not cost the author <strong></strong> out of their budget,
and neither does the JSON overhead of the tree.
Language of parts
Two mechanisms, at two levels:
- A passage inside a body carries a
languagemark withattrs.lang. This is how a quoted sentence in another language is marked. - A whole field in another language uses the field-level
langoption instead — see fields.
Both are checked for well-formedness, and a malformed tag is an error.
Legacy HTML values
A body written before rich text became structured is stored as a JSON string
in the same jsonb column, and it still reads, validates and renders.
The migration was USING to_jsonb(col) — nothing was parsed, so nothing could
be lost. The editor rewrites such a value into a document the next time the
record is saved, so content upgrades as it is edited rather than in a big-bang
conversion.
Anything reading a richtext value must accept both
A richtext value is either a document object or a legacy string. Code that
assumes one shape will break on the other. The helpers richTextPlainText and
asRichTextDocument normalise both, and are what your own code should use.
Rendering
Because the value is a tree, rendering it is your front end’s job and entirely
under your control — walk the nodes and emit whatever your framework wants.
richTextToHtml is available if you want HTML out of it directly.
Rich text is not filterable or sortable
equals or starts with over a document would compare serialisations of a
tree rather than prose, so richtext fields are excluded from the filter
grammar and the sort whitelist entirely. Free-text ?search= does reach them:
the column is cast to text for the match, which searches the serialised tree.
A stored, indexed text projection is the real answer, and it does not exist yet.