# @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` |

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