Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Rich text

Content plugin @orthacms/content-server@orthacms/content-admin

A node tree in a jsonb column, and the structural rules the server refuses to store a body without.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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 text node 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.
KindVocabulary
Nodesdoc, text, paragraph, heading (attrs.level 1–6), bulletList, orderedList, listItem, blockquote, codeBlock, horizontalRule, hardBreak, image, table, tableRow, tableHeader, tableCell, tableCaption, container
Markslink (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.

CodeSeverityMessageWCAG
headingLevelSkippederrorhas a heading that skips from h2 to h41.3.1
headingLevelInvertedwarninghas an h2 heading below an h3 — the outline starts at h31.3.1
headingEmptyerrorhas an empty h2 heading2.4.6
tableMissingHeadererrorhas a table with no header cells (a caption also satisfies it)1.3.1
linkTextEmptyerrorhas a link with no text (/install)2.4.4
linkTextNotDescriptivewarninghas a link whose text ("click here") does not say where it goes2.4.4
invalidLanguageTagerrorhas 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 language mark with attrs.lang.
  • A whole field in another language: the field’s lang option — 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 every lang;
  • 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.