Rich text
Formatted text with links to addresses and records.
A richText field holds formatted text: paragraphs, headings, lists and quotes, with marks on the text and links to addresses or to records. The value is a JSON tree, never HTML, so a write checks it like any other value and a website renders it without trusting markup.
Reach for it when an author writes prose: an article body, a product description, a caption with a link. A text field holds a plain string.
import { defineCollection, field } from 'ohnejs';
export default defineCollection({
fields: {
title: field('text'),
body: field('richText'),
},
});body reads and writes as a RichText value, the type ohnejs/utils exports.
The value
A value is a list of blocks. Each block holds runs, and a run is a stretch of text with its marks and at most one link:
await query('Posts').create({
title: 'Hello',
body: [
{ kind: 'heading', level: 2, content: [{ text: 'Welcome' }] },
{
kind: 'paragraph',
content: [
{ text: 'Read the ' },
{ text: 'guide', marks: ['strong'], link: { url: 'https://example.com/guide' } },
{ text: '.' },
],
},
{ kind: 'list', ordered: false, items: [{ content: [{ text: 'One' }] }] },
],
});paragraph,headingandquotehold runs incontent. A heading has alevelfrom 2 to 6.listholdsitems, each with its owncontentand an optional nestedlist.orderedmakes it numbered.marksholds any ofstrong,em,delandcode. A\nintextis a line break.[]is the empty value, rejected unlessallowEmptyis set.
A write normalizes the value before it checks it: neighbouring runs with the same marks and link merge, empty runs drop, and empty blocks at the end of the document drop. A read returns that canonical form. A value that breaks the shape fails at the exact path, like body[1].content[1].link.url.
Options
| Option | Default | What it does |
|---|---|---|
inline | false | Holds at most one paragraph, rendered without a <p>. elements does not apply. |
elements | ['h2', 'h3', 'ul', 'ol', 'blockquote'] | The block elements allowed besides paragraphs: h2 to h6, ul, ol and blockquote. |
marks | ['strong', 'em', 'code'] | The marks allowed on text. del is strikethrough. |
links | true | false allows no links, true allows addresses, and a list of collections also allows their records. |
lineBreaks | true | With false, each \n becomes a space. |
allowEmpty | false | Accepts []. |
min, max | - | The fewest and most characters across all run text, counted like String#length. |
The options every field takes, like nullable, default, translatable and when, apply as they do for text. A block, level or mark outside the options fails with invalidChoice at its path.
fields: {
summary: field('richText', { inline: true, marks: ['em'], links: false, max: 280 }),
}Links
A run's link has one of two shapes:
{ url: 'https://example.com/pricing', newTab: true }
{ collection: 'Pages', record: '019f3c1a-8b2d-7f4e-9a6b-1c2d3e4f5a6b', hash: 'pricing' }- An address is a web, email or phone address, a local
/pathor a#fragment. Anything a browser could run, likejavascript:, fails withinvalidLink. - A record link names a collection and a record's
UUID. It follows the record wherever its page moves, and pins no locale. newTabopens the link in a new tab, andhashadds a fragment to the target's page.hrefis never stored: a write drops it, and a layer that serves the record's pages, like the CMS, sets it on read.
Record links need the links option to name their collections:
fields: {
body: field('richText', { links: ['Pages', 'Posts'] }),
}The value type narrows to RichText<'Pages' | 'Posts'>, so a link into any other collection is a compile error, and an invalidChoice at link.collection over HTTP.
The link field
A link field holds one link on its own, in the same two shapes. collections names the collections it may point into, and without it only addresses are allowed:
fields: {
cta: field('link', { collections: ['Pages'], nullable: true }),
}The value type is Link<'Pages'>, and a bad address fails at cta.url.
Checking record links
A record link is a weak reference. No foreign key holds it, so the rules differ from a record relation:
- A link the write provides is checked like a relation: the target must exist, and through the collections API the caller must be able to read it. A failure is an
invalidReferenceat the link's path, likebody[1].content[1].link. - A link that every matched record already holds is not checked, so a target deleted since never blocks the next save.
- Deleting a target neither cascades nor is blocked. The link stays in the value, and renders as plain text until the target comes back.
- Copying a translation checks the copied links as new.
Rendering
richTextToHTML from ohnejs/utils renders a value as HTML, with every text and attribute escaped:
import { defineHandler, notFound, query } from 'ohnejs';
import { richTextToHTML } from 'ohnejs/utils';
export default defineHandler(async ({ params }) => {
const post = await query('Posts').where('UUID', params.id).findFirst();
if (!post) throw notFound();
return `<article>${richTextToHTML(post.body)}</article>`;
});- A paragraph is a
<p>, a heading an<h2>to<h6>, a quote a<blockquote><p>, and a list a<ul>or<ol>of<li>. - Marks render as their elements, and a
\nas<br>. - An address link renders as
<a href>.newTabaddstarget="_blank" rel="noopener noreferrer". - A record link renders as
<a>only when it carries anhref. Without one, its text renders plain. - Pass
{ inline: true }for aninlinefield, so the block tags are dropped.
richTextToText gives the plain text instead, blocks joined by a blank line.
Editing in the dashboard
The dashboard edits a richText field in its own editor. A field of blocks gets a toolbar above the text, and an inline field gets one that floats over a selection. The toolbar and the shortcuts offer only what the field's options allow.
Shortcuts use Cmd and Option on a Mac, and Ctrl and Alt on Windows and Linux:
| Shortcut | What it does |
|---|---|
| Cmd+B | Bold |
| Cmd+I | Italic |
| Cmd+E | Code |
| Cmd+Shift+X | Strikethrough |
| Cmd+K | Inserts or edits a link |
| Cmd+\\ | Clears formatting, keeping links |
| Cmd+Option+0 | Paragraph |
| Cmd+Option+2 to 6 | Heading 2 to 6 |
| Cmd+Shift+7 | Numbered list |
| Cmd+Shift+8 | Bulleted list |
| Cmd+Shift+9 | Quote |
| Tab or Cmd+] | Nests a list item |
| Shift+Tab or Cmd+[ | Lifts a list item |
| Cmd+Shift+V | Pastes as plain text |
| Option+F10 | Moves focus into the toolbar |
- Enter starts a new block, and Shift+Enter a line break unless
lineBreaksisfalse. - Where no list item can nest, Tab moves focus on as usual.
- Cmd+U does nothing. There is no underline, since on a website underlined text reads as a link.
Markdown shortcuts
At the start of a paragraph, type a marker and a space to change the block:
#gives the highest allowed heading, and##to######giveh2toh6.-,*or+gives a bulleted list, and1.or1)a numbered one.>gives a quote.
Backspace right after brings the marker back as text. Inline markdown, like **bold**, stays text.
Pasting
- Text copied from another rich text editor in the dashboard pastes with everything it holds.
- HTML from a web page, Google Docs or Word keeps its headings, lists, quotes, marks and safe links.
- Plain text is never read as markdown. A blank line ends a paragraph, and a single line break stays one.
- A lone
https://address pasted over a selection links it, and at the caret inserts it as a link. A record's dashboard address becomes a link to that record, when its collection is allowed.
A paste always fits the field's options: a heading moves to the nearest allowed level, a disallowed mark is dropped, and a disallowed link keeps its text. Dropped text pastes the same way, and text cannot be dragged inside the editor.
Editing links
Cmd+K, or the link button, opens the link popup over the selection:
- "Link to" searches the allowed records, or takes an address you type.
- At a caret outside a link, "Text" sets what is inserted. Left empty, the record's label or the address is inserted.
- A record link takes an anchor, the
hash, and any link can open in a new tab. - Remove drops the link and keeps its text.
A link to a missing record is drawn dashed, and its popup says so. Hover a link to see its target, and Cmd+click to open it.
Limits
- Lists nest at most 4 levels deep.
- There is no
h1, since the page title is the heading, and no underline, color or font. - Neither field is searchable or filterable. Search never matches the value, and a nullable field takes only
isNull.