@bettercms-ai/richtext
The one Portable Text contract for BetterCMS: schema, converters, and rendering. Shared by the backend, the dashboard, and every delivery SDK.
BetterCMS shipped four hand-rolled "Portable Text" trees before this package, and all four
diverged from the spec in the same expensive way: no markDefs.
Without markDefs a link has nowhere to live, so every one of them could render bold text and
none of them could render a hyperlink. This package is the spec, so the official
@portabletext/* packages can read and write our content without a shim.
Install
npm install @bettercms-ai/richtext
Reading a stored value
toPortableText reads every shape BetterCMS has ever stored and never throws — a throw
here would be a blank field, and a blank field is one autosave away from being permanent.
Unrecognised input returns [] and is reported through onUnreadable.
import { toPortableText, portableTextToPlainText } from "@bettercms-ai/richtext";
const value = toPortableText(entry.data.body);
const preview = portableTextToPlainText(value);
It handles a bare PortableText[], the {format, value, html} envelope, a Lexical state
(object or JSON string), the bettercms-notion-doc document format, a pre-envelope
{html} seed, bare HTML, and plain text — which stays literal, because running an
author's copy through a Markdown parser is how a line beginning # silently became a heading.
Rendering
import { portableTextToHtml } from "@bettercms-ai/richtext/html";
const html = portableTextToHtml(value);
const inline = portableTextToHtml(value, { inline: true }); // unwraps a lone <p>
Storage is structured data and HTML is only ever an output, so author text can never be
markup: @portabletext/to-html escapes text nodes by construction. The only attributes this
writes are an href through safeHref and a class token matched against a pattern.
Ingesting HTML
import { htmlToPortableText } from "@bettercms-ai/richtext/ingest";
import { ptSchemaFor } from "@bettercms-ai/richtext";
htmlToPortableText(html, {
parseHtml: (h) => new JSDOM(h).window.document, // required outside a browser
schema: ptSchemaFor(["p", "h2", "link", "table"]),
});
The filter IS the schema. A tag with no place in the schema produces no node — <script>
is not "sanitised away", it fails to parse into anything. One ptSchemaFor(accept) object
drives the editor's toolbar, the server's write validation, and this filter at once, so they
cannot drift.
Tables
A table is a block object of its own — {_type:"table", rows:[{_key, cells:[{_key, content}]}]} —
and a cell holds blocks, not a string. So a link, a bolded word or a second paragraph inside a
cell is ordinary rich text, and the ingest keeps it by ingesting each cell through the same
converter as the document around it.
htmlToPortableText('<table><tbody><tr><td>Pro</td></tr></tbody></table>', { parseHtml, schema })
// → [{ _type: "table", _key: "…", rows: [{ _key: "…", cells: [{ _key: "…", content: [/* blocks */] }] }] }]
header sits on the cell (<th>), head on the row (<thead>), and span records a colspan
without collapsing the grid. All three are recorded rather than inferred so that rendering a table
reproduces the markup it came from — the property the corpus audit's --tables pass measures over
every stored value, and the reason a table may be converted at all.
Tables stored before this — a generic bcmsBlock keyed builtin:table, holding a string matrix —
are migrated on read by migrateTable. Nothing rewrites the database; the reader, the renderer
and the plain-text pass all recognise the old shape and return the new one.
Subpath exports
| import | pulls in | use it for |
|---|---|---|
@bettercms-ai/richtext | nothing heavy | types, schema, reading, plain text |
@bettercms-ai/richtext/html | the HTML serialiser | rendering |
@bettercms-ai/richtext/ingest | a DOM parser | HTML → Portable Text |
The split is deliberate: ingest needs a DOM and html needs the serialiser, and neither
should reach a dashboard bundle just because someone imported a type.
Ships as source
main points at ./src/index.ts, like @bettercms-ai/db and @bettercms-ai/content. The
BetterCMS API runs Bun and consumes this directly; a dist-pointing export once crash-looped
the container. Consumers that need compiled output should bundle it as they would any
TypeScript source dependency.
Licence
Proprietary — part of BetterCMS.