Skip to main content

Rich Text

Rich text is stored as a structured AST (close to ProseMirror's JSON shape). Delivery responses surface three projections:

  • raw — the AST itself
  • html — sanitized HTML rendered from the AST
  • text — flat extracted text, useful for search snippets

Block nodes

TypeNotes
paragraphcontent array of inline nodes.
headinglevel: 1..6, content array.
listordered: true for <ol>, false / omitted for <ul>. content is listItem[].
listItemcontent is block nodes (paragraphs etc.).
blockquotecontent is block nodes.
codeBlocktext: string + optional language.
hrNo content.
tablecontent is tableRow[].
tableRowcontent is tableCell[].
tableCellheader: true for <th>. content is block nodes.
embeddedEntryentryId + optional contentType apiName.
embeddedAssetassetId + optional alt.

Marks (inline)

bold, italic, underline, strikethrough, code, superscript, subscript, link. Formatting marks are stored as marks: [{ type }, …] on text nodes; the link mark adds an href (required; must be an absolute http/https/mailto/tel or relative URL — unsafe schemes like javascript: are rejected on write) and an optional title: { "type": "link", "href": "/about", "title": "About us" }. The renderer wraps marks in array order, so the first mark is inner-most: [bold, italic] becomes <em><strong>…</strong></em>. The visual result is identical, but the literal HTML is deterministic. The link renderer emits <a href> with rel="noopener noreferrer" on external links.

Per-field configuration

{
allowedNodes?: NodeType[];
allowedMarks?: MarkType[];
allowedHeadingLevels?: number[]; // subset of [1,2,3,4,5,6]
allowedEmbeddedTypes?: string[]; // CT apiNames the embedded picker filters by
}

Omitted = full vocabulary. The CT editor exposes these as toggles; the entry editor's TipTap toolbar shows only allowed buttons.

Storage shape

{
"type": "doc",
"content": [
{
"type": "heading",
"level": 2,
"content": [{ "type": "text", "text": "Section" }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Read " },
{ "type": "text", "text": "more", "marks": [{ "type": "bold" }] }
]
}
]
}

Embedded content

{
"type": "embeddedEntry",
"entryId": "ckl_…",
"contentType": "ctaBlock"
}

Renderers look up contentType and dispatch to the right component template. The CMS preview attribute data-krios-entry-id lets the preview overlay surface inline editing.

embeddedAsset nodes (assetId + optional alt) are resolved server-side: the delivery html renders them as a real <img src="…" alt="…"> with the CDN URL already filled in, on both GraphQL and REST. embeddedEntry nodes instead render as an empty <div data-krios-entry-id="…" data-krios-content-type="…"> placeholder for the client to hydrate — so inline images Just Work, and only embedded entries need a custom component.

Delivery API formats

REST and GraphQL both expand richtext fields into:

{
"raw": { "type": "doc", "content": [...] },
"html": "<h2>Section</h2><p>Read <strong>more</strong></p>",
"text": "Section Read more"
}

html is generated by lib/richtext/to-html.ts (a pure, deterministic, server-sanitised function); inline embeddedAsset images are pre-resolved to a real CDN src before rendering. Consumers choose:

  • React frontends — render raw via @krios/react's KriosRichText for full control over component dispatch.
  • Static / non-React frontends — drop html into the page.
  • Search indexes — text.

Authoring

The admin entry editor is a TipTap WYSIWYG that converts to / from the Krios AST on every keystroke. Toolbar buttons are gated by the field's richTextConfig; paste from Word / Google Docs is run through the existing htmlToAst cleaner before insertion.

A "View JSON" toggle drops to the raw AST for developer inspection.

Common pitfalls

  • Storing <p>foo</p>-style HTML in a richtext field. The server validates the AST shape and rejects invalid documents at save time. Use the WYSIWYG or call htmlToAst() from @/lib/richtext (server) or your own converter.
  • Heading levels outside the allowed list. The toolbar hides disallowed levels; direct API calls that include them get rejected on save.
  • Forgetting contentType on embeddedEntry. Renderers can still resolve via entryId, but the contentType saves a fetch on every render. Always include it when known.