A richtext value is a document tree in a jsonb column — not an HTML
string. That makes heading order, table headers, link text and passage language
checkable facts.
body: field.richtext({ required: true, maxLength: 20000 })The shape of a value
What the API returns in values.body, and what you send back:
{
"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" } }] },
{ "type": "text", "text": " — or say " },
{ "type": "text", "text": "bonjour",
"marks": [{ "type": "language", "attrs": { "lang": "fr" } }] }
] }
]
}- A
textnode has characters and no children; every other node has children and no text. - Any node may carry
attrs.lang, a BCP-47 tag for everything inside it.
| Kind | Vocabulary |
|---|---|
| Nodes | doc, text, paragraph, heading (attrs.level 1–6), bulletList, orderedList, listItem, blockquote, codeBlock, horizontalRule, hardBreak, image, table, tableRow, tableHeader, tableCell, tableCaption, container |
| Marks | link (attrs.href), bold, italic, underline, strike, code, highlight, textStyle, language (attrs.lang) |
The vocabulary is open: editor extensions store their own nodes, which pass
through untouched. The stock editor adds video, callout, and
columnBlock / column.
Images are URLs, not library references
An image node holds src plus alt, decorative, width and alignment —
a copy of the asset’s URL and alt text at insert time. Editing the asset
later does not update bodies. src must be http(s) or a same-origin path.
To stay linked to a library asset, use a media field.
Writing a body
curl -X PATCH https://cms.example.com/api/v1/content/article/0b9e… \
-H 'Authorization: Bearer orthacms_…' \
-H 'Content-Type: application/json' \
-d '{
"values": {
"body": {
"type": "doc",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "Hello." }] }
]
}
}
}'Structural rules
Checked wherever values are validated — every save of a non-publishable type, and publish of a publishable one. Only errors fail; the editor shows warnings too.
| Code | Severity | Message | WCAG |
|---|---|---|---|
headingLevelSkipped | error | has a heading that skips from h2 to h4 | 1.3.1 |
headingLevelInverted | warning | has an h2 heading below an h3 — the outline starts at h3 | 1.3.1 |
headingEmpty | error | has an empty h2 heading | 2.4.6 |
tableMissingHeader | error | has a table with no header cells (a caption also satisfies it) | 1.3.1 |
linkTextEmpty | error | has a link with no text (/install) | 2.4.4 |
linkTextNotDescriptive | warning | has a link whose text ("click here") does not say where it goes | 2.4.4 |
invalidLanguageTag | error | has an invalid language tag ("en_US") | 3.1.2 |
A failing body is a 422 like any other field:
{
"message": "Entry validation failed",
"issues": [{ "field": "body", "message": "has a heading that skips from h2 to h4" }]
}Run the same check yourself before sending:
import { inspectRichText } from '@orthacms/content-domain';
const problems = inspectRichText(body); // [{ code, message, severity, wcag }]
const blocking = problems.filter((p) => p.severity === 'error');Turning it off
emailTemplate: field.richtext({ structure: 'off' })Per field, for bodies that are not documents — a hand-maintained fragment, an email template.
Lengths count text, not markup
minLength / maxLength count the body’s text. Bold, links and JSON
overhead cost nothing.
Language of parts
- A passage in another language: a
languagemark withattrs.lang. - A whole field in another language: the field’s
langoption — see fields.
Malformed tags are errors in both.
Rendering
Walk the tree in your front end, or render HTML with the kernel’s serializer:
import { asRichTextDocument, richTextToHtml, richTextPlainText } from '@orthacms/content-domain';
const res = await fetch('https://cms.example.com/api/v1/content/article/0b9e…', {
headers: { Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN }
});
const entry = await res.json();
const html = richTextToHtml(asRichTextDocument(entry.values.body));
const excerpt = richTextPlainText(entry.values.body).slice(0, 160);richTextToHtml:
- keeps heading levels,
<th scope="col">, captions, links and everylang; - escapes text and attributes;
- emits only the vocabulary above — an unknown node (
video,callout) contributes its children, no markup.
In GraphQL a richtext field is the JSON scalar — the same tree.
Legacy HTML values
A body written before rich text became structured is a JSON string in the same column. It still reads, validates and renders, and the editor converts it to a document on the next save.
Code that reads richtext must accept both shapes
A value is a document object or a legacy string. Normalise with
asRichTextDocument (to a document) or richTextPlainText (to text) from
@orthacms/content-domain.
Rich text is not filterable or sortable
richtext is excluded from ?filter= and ?sort=. ?search= still matches
it — the column is cast to text, so it searches the serialised tree.