npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bettercms-ai/richtext

v0.19.0

Published

The one Portable Text contract for BetterCMS: schema, converters, and rendering. Shared by the backend, the dashboard and every delivery SDK.

Readme

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