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

@avocadostudio-ai/richtext

v0.21.1

Published

The Avocado Studio rich-text grammar and converters for Contentful, Sanity Portable Text, Strapi Blocks and Storyblok

Readme

@avocadostudio-ai/richtext

The Avocado Studio rich-text grammar, and converters between it and the shapes three CMSes actually store.

No runtime dependencies, and no knowledge of React, the orchestrator or any CMS client — the CMS types are structural, so a one-off migration script can convert a dataset without installing anything else.

Two layers

The grammar (parse.ts) is the markdown subset an Avocado richtext string is written in — sized to exactly what the property panel's editor can produce, so anything a person can type comes back rendered rather than as punctuation. It is a parser only: it returns tokens, and what a heading or a link looks like on screen belongs to the calling surface.

The pivot (doc.ts) is a ProseMirror document — the shape the property panel edits natively. Every format converts to and from it rather than to and from each other, so a fourth CMS costs one converter pair instead of three.

Contentful ─┐                        ┌─ Contentful
Portable Text ├─→ RichTextDoc ─→ ─┤─ Portable Text
Strapi Blocks ─┤    (the pivot)     ├─ Strapi Blocks
markdown ──────┘                     └─ markdown

Converters

| CMS | in | out | | --- | --- | --- | | Avocado markdown | fromMarkdown | toMarkdown | | Sanity Portable Text | fromPortableText | toPortableText | | Contentful Rich Text | fromContentful | toContentful | | Strapi 5 Blocks | fromStrapiBlocks | toStrapiBlocks |

import { fromPortableText, toPortableText } from "@avocadostudio-ai/richtext"

// read
const doc = fromPortableText(sanityBlock.body)

// write — `previous` is what makes this an update rather than a replacement
const body = toPortableText(doc, { previous: storedBlock.body })

What survives

Node and mark type are open strings, not a union of the names we know. A Portable Text block with a custom _type, a Contentful embedded entry, a decorator some space enabled last week: none of them are in the vocabulary, and a closed type would force each converter to throw them away. They ride through the pivot as avocadoUnknownBlock carrying the source object under attrs.data and come back out unchanged.

Passing previous to toPortableText goes further: an unchanged block is re-emitted as the stored object itself, so keys, annotations and fields no converter here understands are preserved byte-for-byte, and the CMS sees a diff of what actually changed rather than "every block replaced".

mergeRichTextDoc(next, previous) is the same idea between two documents. It carries stored keys onto a rebuilt document and puts back the blocks the rebuild could not have contained — which is what makes it safe for the ops engine to parse a planner's plain-string rewrite into a document without deleting the embedded entry sitting in the middle of it.

What does not

Every format is missing something another one has, and the gaps are documented at each conversion rather than papered over:

  • markdown has no underline and no way to name a CMS's own decorator. Those marks drop off in toMarkdown and the text stays. A value that needs them should be stored as a document, not flattened.
  • markdown has no image in this grammar — the editor has no image node and deletes them — so an ![alt](src) keeps its alt text. Images belong in an f.image field.
  • Contentful has no code block; code is a mark on text, so a code block becomes a code-marked paragraph.
  • Portable Text and Strapi Blocks have no thematic break, so a horizontalRule is dropped rather than faked as a paragraph of dashes.
  • Heading levels clamp to h2–h6 when flattening to markdown, because a page body never owns the page's h1. The document path does not clamp — that is the integration's outline, not ours.