Features Apograph CMS on GitHub

Rich text

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

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

Documents 0.5.2 Updated Edit this page Report a problem

On this page

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.

FindingSeverityWhy
headingLevelSkippederrorA heading more than one level below the one before it.
headingLevelInvertederrorA heading above the body’s own top level — an inverted outline.
headingEmptyerrorA heading with no text names nothing.
tableMissingHeadererrorA table with neither header cells nor a caption.
linkTextEmptyerrorA link with nothing to announce.
linkTextNotDescriptivewarningLink text that says nothing about where it goes.
invalidLanguageTagerrorA 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 language mark with attrs.lang. This is how a quoted sentence in another language is marked.
  • A whole field in another language uses the field-level lang option 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.