@avocadostudio-ai/richtext
v0.21.1
Published
The Avocado Studio rich-text grammar and converters for Contentful, Sanity Portable Text, Strapi Blocks and Storyblok
Maintainers
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 ──────┘ └─ markdownConverters
| 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
toMarkdownand 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
keeps its alt text. Images belong in anf.imagefield. - Contentful has no code block;
codeis a mark on text, so a code block becomes a code-marked paragraph. - Portable Text and Strapi Blocks have no thematic break, so a
horizontalRuleis 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.
