Search pages, headings and code across the docs, examples and tutorials.

to navigateto open
Database

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.

collections/Posts.ts
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:

ts
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, heading and quote hold runs in content. A heading has a level from 2 to 6.
  • list holds items, each with its own content and an optional nested list. ordered makes it numbered.
  • marks holds any of strong, em, del and code. A \n in text is a line break.
  • [] is the empty value, rejected unless allowEmpty is 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

OptionDefaultWhat it does
inlinefalseHolds 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.
linkstruefalse allows no links, true allows addresses, and a list of collections also allows their records.
lineBreakstrueWith false, each \n becomes a space.
allowEmptyfalseAccepts [].
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.

ts
fields: {
  summary: field('richText', { inline: true, marks: ['em'], links: false, max: 280 }),
}

A run's link has one of two shapes:

ts
{ 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 /path or a #fragment. Anything a browser could run, like javascript:, fails with invalidLink.
  • A record link names a collection and a record's UUID. It follows the record wherever its page moves, and pins no locale.
  • newTab opens the link in a new tab, and hash adds a fragment to the target's page.
  • href is 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:

ts
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.

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:

ts
fields: {
  cta: field('link', { collections: ['Pages'], nullable: true }),
}

The value type is Link<'Pages'>, and a bad address fails at cta.url.

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 invalidReference at the link's path, like body[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:

api/posts/[id].get.ts
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 \n as <br>.
  • An address link renders as <a href>. newTab adds target="_blank" rel="noopener noreferrer".
  • A record link renders as <a> only when it carries an href. Without one, its text renders plain.
  • Pass { inline: true } for an inline field, 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:

ShortcutWhat it does
Cmd+BBold
Cmd+IItalic
Cmd+ECode
Cmd+Shift+XStrikethrough
Cmd+KInserts or edits a link
Cmd+\\Clears formatting, keeping links
Cmd+Option+0Paragraph
Cmd+Option+2 to 6Heading 2 to 6
Cmd+Shift+7Numbered list
Cmd+Shift+8Bulleted list
Cmd+Shift+9Quote
Tab or Cmd+]Nests a list item
Shift+Tab or Cmd+[Lifts a list item
Cmd+Shift+VPastes as plain text
Option+F10Moves focus into the toolbar
  • Enter starts a new block, and Shift+Enter a line break unless lineBreaks is false.
  • 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 ###### give h2 to h6.
  • -, * or + gives a bulleted list, and 1. or 1) 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.

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.