Rich Text
Rich text is stored as a structured AST (close to ProseMirror's JSON shape). Delivery responses surface three projections:
raw— the AST itselfhtml— sanitized HTML rendered from the ASTtext— flat extracted text, useful for search snippets
Block nodes
| Type | Notes |
|---|---|
paragraph | content array of inline nodes. |
heading | level: 1..6, content array. |
list | ordered: true for <ol>, false / omitted for <ul>. content is listItem[]. |
listItem | content is block nodes (paragraphs etc.). |
blockquote | content is block nodes. |
codeBlock | text: string + optional language. |
hr | No content. |
table | content is tableRow[]. |
tableRow | content is tableCell[]. |
tableCell | header: true for <th>. content is block nodes. |
embeddedEntry | entryId + optional contentType apiName. |
embeddedAsset | assetId + 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
rawvia @krios/react'sKriosRichTextfor full control over component dispatch. - Static / non-React frontends — drop
htmlinto 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 callhtmlToAst()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
contentTypeon embeddedEntry. Renderers can still resolve viaentryId, but the contentType saves a fetch on every render. Always include it when known.