@avocadostudio-ai/richtext 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/doc.js ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * The pivot format: a ProseMirror document.
3
+ *
4
+ * Every CMS in this package models rich text differently — Contentful nests a
5
+ * recursive `document` tree, Sanity keeps a flat array of Portable Text blocks,
6
+ * Strapi a flat Slate-shaped array — and Avocado's own `richtext` fields hold a
7
+ * markdown string. Converting each pair directly would need six converters and
8
+ * would still lose whatever the two ends did not have in common. Instead every
9
+ * format converts to and from one shape, and that shape is the ProseMirror
10
+ * document the property panel already edits natively (`RichTextJsonField`).
11
+ *
12
+ * The types are deliberately open — `type` is a `string`, not a union of the
13
+ * names we know. A Portable Text block with a custom `_type`, a Contentful
14
+ * `embedded-entry-block`, a mark some space enabled last week: none of them are
15
+ * in our vocabulary, and a closed union would force each converter to throw the
16
+ * value away. They ride through the pivot as data instead, which is what makes
17
+ * a round trip through Avocado non-destructive for content Avocado cannot edit.
18
+ *
19
+ * Node and mark names are Tiptap's, because the editor is Tiptap: `paragraph`,
20
+ * `heading`, `bulletList`, `orderedList`, `listItem`, `blockquote`, `codeBlock`,
21
+ * `horizontalRule`, `hardBreak`, `text`; marks `bold`, `italic`, `strike`,
22
+ * `code`, `underline`, `link`.
23
+ */
24
+ import { parseRichText, resolveRichTextHeadingLevel } from "./parse.js";
25
+ /** Node names this package produces and understands. */
26
+ export const NODE = {
27
+ doc: "doc",
28
+ paragraph: "paragraph",
29
+ heading: "heading",
30
+ bulletList: "bulletList",
31
+ orderedList: "orderedList",
32
+ listItem: "listItem",
33
+ blockquote: "blockquote",
34
+ codeBlock: "codeBlock",
35
+ horizontalRule: "horizontalRule",
36
+ hardBreak: "hardBreak",
37
+ text: "text",
38
+ /**
39
+ * A block the pivot could not model — a Portable Text `_type` we do not know,
40
+ * a Contentful embedded entry, a Strapi image. It carries the source object
41
+ * untouched under `attrs.data` so the converter back out can re-emit it
42
+ * unchanged. The editor registers a matching read-only node, so it also
43
+ * survives a trip through the property panel in its original position.
44
+ */
45
+ unknown: "avocadoUnknownBlock"
46
+ };
47
+ /** Mark names this package produces and understands. */
48
+ export const MARK = {
49
+ bold: "bold",
50
+ italic: "italic",
51
+ strike: "strike",
52
+ code: "code",
53
+ underline: "underline",
54
+ link: "link"
55
+ };
56
+ /**
57
+ * A richtext value stored as a document rather than a markdown string.
58
+ *
59
+ * The same signature `block-manifest.ts` keys its `isProseMirrorDocSchema` off:
60
+ * an object whose `type` is the literal `"doc"`. Anything else — a markdown
61
+ * string included — is not a document and must not be handed to the converters.
62
+ */
63
+ export function isRichTextDoc(value) {
64
+ if (typeof value !== "object" || value === null)
65
+ return false;
66
+ const candidate = value;
67
+ return candidate.type === "doc" && (candidate.content === undefined || Array.isArray(candidate.content));
68
+ }
69
+ export function emptyDoc() {
70
+ return { type: "doc", content: [] };
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // markdown -> document
74
+ // ---------------------------------------------------------------------------
75
+ function inlineToNodes(tokens) {
76
+ const nodes = [];
77
+ for (const token of tokens) {
78
+ if (token.type === "break") {
79
+ nodes.push({ type: NODE.hardBreak });
80
+ continue;
81
+ }
82
+ if (token.text.length === 0)
83
+ continue;
84
+ switch (token.type) {
85
+ case "text":
86
+ nodes.push({ type: NODE.text, text: token.text });
87
+ break;
88
+ case "strong":
89
+ nodes.push({ type: NODE.text, text: token.text, marks: [{ type: MARK.bold }] });
90
+ break;
91
+ case "em":
92
+ nodes.push({ type: NODE.text, text: token.text, marks: [{ type: MARK.italic }] });
93
+ break;
94
+ case "strike":
95
+ nodes.push({ type: NODE.text, text: token.text, marks: [{ type: MARK.strike }] });
96
+ break;
97
+ case "code":
98
+ nodes.push({ type: NODE.text, text: token.text, marks: [{ type: MARK.code }] });
99
+ break;
100
+ case "link":
101
+ nodes.push({
102
+ type: NODE.text,
103
+ text: token.text,
104
+ marks: [{ type: MARK.link, attrs: { href: token.href } }]
105
+ });
106
+ break;
107
+ }
108
+ }
109
+ return nodes;
110
+ }
111
+ function withContent(node, content) {
112
+ // ProseMirror treats an absent `content` and an empty array the same, but the
113
+ // absent form is what Tiptap emits for an empty paragraph — match it so a
114
+ // doc that went out and came back compares equal.
115
+ return content.length > 0 ? { ...node, content } : node;
116
+ }
117
+ function listItemToNode(item) {
118
+ const content = [
119
+ withContent({ type: NODE.paragraph }, inlineToNodes(item.inline))
120
+ ];
121
+ if (item.children)
122
+ content.push(listToNode(item.children));
123
+ return { type: NODE.listItem, content };
124
+ }
125
+ function listToNode(list) {
126
+ return {
127
+ type: list.type === "ordered-list" ? NODE.orderedList : NODE.bulletList,
128
+ content: list.items.map(listItemToNode)
129
+ };
130
+ }
131
+ function blockToNodes(block) {
132
+ switch (block.type) {
133
+ case "heading": {
134
+ /*
135
+ * The authored hash count is clamped, not copied. A markdown body renders
136
+ * `#` as `<h2>` (see `resolveRichTextHeadingLevel`), so carrying `level:1`
137
+ * into the document would make the same content render a different
138
+ * outline depending on which representation it happened to be in.
139
+ */
140
+ const heading = withContent({ type: NODE.heading, attrs: { level: resolveRichTextHeadingLevel(block.level) } }, inlineToNodes(block.inline));
141
+ if (!block.trailing)
142
+ return [heading];
143
+ return [heading, withContent({ type: NODE.paragraph }, inlineToNodes(block.trailing))];
144
+ }
145
+ case "unordered-list":
146
+ case "ordered-list":
147
+ return [listToNode({ type: block.type, items: block.items })];
148
+ case "blockquote":
149
+ return [withContent({ type: NODE.blockquote }, block.children.flatMap(blockToNodes))];
150
+ case "code":
151
+ return [
152
+ {
153
+ type: NODE.codeBlock,
154
+ attrs: { language: block.language ?? null },
155
+ ...(block.code.length > 0 ? { content: [{ type: NODE.text, text: block.code }] } : {})
156
+ }
157
+ ];
158
+ case "rule":
159
+ return [{ type: NODE.horizontalRule }];
160
+ case "paragraph":
161
+ return [withContent({ type: NODE.paragraph }, inlineToNodes(block.inline))];
162
+ }
163
+ }
164
+ /** Parse an Avocado markdown richtext value into the pivot document. */
165
+ export function fromMarkdown(markdown) {
166
+ if (typeof markdown !== "string" || markdown.trim().length === 0)
167
+ return emptyDoc();
168
+ return { type: "doc", content: parseRichText(markdown).flatMap(blockToNodes) };
169
+ }
170
+ // ---------------------------------------------------------------------------
171
+ // document -> markdown
172
+ // ---------------------------------------------------------------------------
173
+ /**
174
+ * Escape exactly what `prosemirror-markdown`'s `esc()` escapes on a text node.
175
+ *
176
+ * It has to be exactly that set: the parser unescapes the same characters, so
177
+ * escaping more would leave stray backslashes on the page and escaping fewer
178
+ * would let a literal `[` open a link on the next read.
179
+ */
180
+ const TEXT_ESCAPE = /[`*\\~[\]_]/g;
181
+ /** Link hrefs use a different set — see the link mark's serialiser. */
182
+ const HREF_ESCAPE = /[()"]/g;
183
+ export function escapeMarkdownText(text) {
184
+ return text.replace(TEXT_ESCAPE, "\\$&");
185
+ }
186
+ function escapeHref(href) {
187
+ return href.replace(HREF_ESCAPE, "\\$&");
188
+ }
189
+ function hasMark(node, name) {
190
+ return Array.isArray(node.marks) && node.marks.some((m) => m.type === name);
191
+ }
192
+ function linkHref(node) {
193
+ const link = node.marks?.find((m) => m.type === MARK.link);
194
+ const href = link?.attrs?.href;
195
+ return typeof href === "string" ? href : undefined;
196
+ }
197
+ /**
198
+ * Serialise one inline node.
199
+ *
200
+ * Marks the grammar cannot express — `underline`, a CMS's own decorator, an
201
+ * inline colour — drop off here and the text stays. That is the whole reason
202
+ * the document path exists: a value that needs those marks should be stored as
203
+ * a document, not flattened to markdown.
204
+ */
205
+ function inlineToMarkdown(node) {
206
+ if (node.type === NODE.hardBreak)
207
+ return "\\\n";
208
+ if (typeof node.text !== "string") {
209
+ // An unknown inline node that still wraps text (some CMSes model links that
210
+ // way) — keep the words rather than dropping the span.
211
+ return (node.content ?? []).map(inlineToMarkdown).join("");
212
+ }
213
+ let out = hasMark(node, MARK.code) ? "`" + node.text + "`" : escapeMarkdownText(node.text);
214
+ if (hasMark(node, MARK.strike))
215
+ out = `~~${out}~~`;
216
+ if (hasMark(node, MARK.italic))
217
+ out = `*${out}*`;
218
+ if (hasMark(node, MARK.bold))
219
+ out = `**${out}**`;
220
+ const href = linkHref(node);
221
+ if (href !== undefined)
222
+ out = `[${out}](${escapeHref(href)})`;
223
+ return out;
224
+ }
225
+ function inlineListToMarkdown(nodes) {
226
+ return (nodes ?? []).map(inlineToMarkdown).join("");
227
+ }
228
+ function isListNode(node) {
229
+ return node.type === NODE.bulletList || node.type === NODE.orderedList;
230
+ }
231
+ function listToMarkdown(node, depth) {
232
+ const ordered = node.type === NODE.orderedList;
233
+ const indent = " ".repeat(depth);
234
+ const lines = [];
235
+ const items = node.content ?? [];
236
+ for (let index = 0; index < items.length; index++) {
237
+ const children = items[index].content ?? [];
238
+ const paragraphs = children.filter((c) => !isListNode(c));
239
+ const nested = children.filter(isListNode);
240
+ /*
241
+ * A list item may hold several paragraphs. Markdown's lazy-continuation
242
+ * form for that is a blank line plus an indent, which this grammar reads as
243
+ * the end of the list — so the extra paragraphs are joined onto the item
244
+ * line with hard breaks instead. Lossy, and deliberately so: the
245
+ * alternative silently ends the list halfway down.
246
+ */
247
+ const text = paragraphs
248
+ .map((p) => (p.type === NODE.paragraph ? inlineListToMarkdown(p.content) : blockToMarkdown(p, depth)))
249
+ .filter((t) => t.length > 0)
250
+ .join("\\\n");
251
+ lines.push(`${indent}${ordered ? `${index + 1}.` : "-"} ${text}`);
252
+ for (const child of nested)
253
+ lines.push(listToMarkdown(child, depth + 1));
254
+ }
255
+ return lines.join("\n");
256
+ }
257
+ function blockToMarkdown(node, depth) {
258
+ switch (node.type) {
259
+ case NODE.paragraph:
260
+ return inlineListToMarkdown(node.content);
261
+ case NODE.heading: {
262
+ const rawLevel = typeof node.attrs?.level === "number" ? node.attrs.level : 2;
263
+ /*
264
+ * Clamped on the way out too. `# ` would be read back as level 2 by the
265
+ * markdown renderer, so writing it would make `doc -> markdown -> doc`
266
+ * change the value on every trip.
267
+ */
268
+ return `${"#".repeat(resolveRichTextHeadingLevel(rawLevel))} ${inlineListToMarkdown(node.content)}`;
269
+ }
270
+ case NODE.bulletList:
271
+ case NODE.orderedList:
272
+ return listToMarkdown(node, depth);
273
+ case NODE.blockquote:
274
+ return (node.content ?? [])
275
+ .map((child) => blockToMarkdown(child, depth))
276
+ .filter((chunk) => chunk.length > 0)
277
+ .join("\n\n")
278
+ .split("\n")
279
+ .map((line) => (line.length > 0 ? `> ${line}` : ">"))
280
+ .join("\n");
281
+ case NODE.codeBlock: {
282
+ const language = typeof node.attrs?.language === "string" ? node.attrs.language : "";
283
+ const code = (node.content ?? []).map((c) => c.text ?? "").join("");
284
+ return "```" + language + "\n" + code + "\n```";
285
+ }
286
+ case NODE.horizontalRule:
287
+ return "---";
288
+ case NODE.unknown:
289
+ // No markdown can stand in for it, and inventing one would put the
290
+ // placeholder into the published content. Dropped here on purpose; the
291
+ // document path is where these values keep their meaning.
292
+ return "";
293
+ default:
294
+ // An unrecognised block that still has children: keep the prose.
295
+ return (node.content ?? []).map((child) => blockToMarkdown(child, depth)).join("\n\n");
296
+ }
297
+ }
298
+ /** Serialise the pivot document to an Avocado markdown richtext value. */
299
+ export function toMarkdown(input) {
300
+ const nodes = Array.isArray(input) ? input : (input.content ?? []);
301
+ return nodes
302
+ .map((node) => blockToMarkdown(node, 0))
303
+ .filter((chunk) => chunk.length > 0)
304
+ .join("\n\n")
305
+ .trim();
306
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `@avocadostudio-ai/richtext` — the rich-text grammar and its converters.
3
+ *
4
+ * Two layers, and they are worth keeping straight:
5
+ *
6
+ * - **The grammar** (`parse.ts`) is the markdown subset an Avocado `richtext`
7
+ * string is written in. It is a parser only: what a heading or a link looks
8
+ * like on screen is the calling surface's business, and there are three of
9
+ * those (the React renderers, the editor overlay, the Puck adapter).
10
+ * - **The pivot** (`doc.ts`) is a ProseMirror document, the shape the property
11
+ * panel edits natively. Every CMS converts to and from it rather than to and
12
+ * from each other, so adding a fourth CMS costs one converter pair, not three.
13
+ *
14
+ * The package has no runtime dependencies and knows nothing about React, the
15
+ * orchestrator or any CMS client — the CMS types here are structural, so a
16
+ * migration script can convert a dataset without installing anything else.
17
+ */
18
+ export { parseInline, parseRichText, parseRichTextBlocks, normalizeRichTextBody, resolveRichTextHeadingLevel, clampMarkdownHeadings, unescapeMarkdownText, type InlineToken, type RichTextBlock, type RichTextList, type RichTextListItem } from "./parse.ts";
19
+ export { NODE, MARK, isRichTextDoc, emptyDoc, fromMarkdown, toMarkdown, escapeMarkdownText, type RichTextDoc, type RichTextNode, type RichTextMark } from "./doc.ts";
20
+ export { deepEqual, omitDeep, mergeByIdentity, mergeRichTextDoc } from "./merge.ts";
21
+ export { fromPortableText, toPortableText, type PortableTextBlock, type PortableTextSpan, type PortableTextMarkDef, type ToPortableTextOptions } from "./portable-text.ts";
22
+ export { fromContentful, toContentful, type ContentfulDocument, type ContentfulNode, type ContentfulMark } from "./contentful.ts";
23
+ export { fromStrapiBlocks, toStrapiBlocks, type StrapiNode, type StrapiText } from "./strapi.ts";
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `@avocadostudio-ai/richtext` — the rich-text grammar and its converters.
3
+ *
4
+ * Two layers, and they are worth keeping straight:
5
+ *
6
+ * - **The grammar** (`parse.ts`) is the markdown subset an Avocado `richtext`
7
+ * string is written in. It is a parser only: what a heading or a link looks
8
+ * like on screen is the calling surface's business, and there are three of
9
+ * those (the React renderers, the editor overlay, the Puck adapter).
10
+ * - **The pivot** (`doc.ts`) is a ProseMirror document, the shape the property
11
+ * panel edits natively. Every CMS converts to and from it rather than to and
12
+ * from each other, so adding a fourth CMS costs one converter pair, not three.
13
+ *
14
+ * The package has no runtime dependencies and knows nothing about React, the
15
+ * orchestrator or any CMS client — the CMS types here are structural, so a
16
+ * migration script can convert a dataset without installing anything else.
17
+ */
18
+ export { parseInline, parseRichText, parseRichTextBlocks, normalizeRichTextBody, resolveRichTextHeadingLevel, clampMarkdownHeadings, unescapeMarkdownText } from "./parse.js";
19
+ export { NODE, MARK, isRichTextDoc, emptyDoc, fromMarkdown, toMarkdown, escapeMarkdownText } from "./doc.js";
20
+ export { deepEqual, omitDeep, mergeByIdentity, mergeRichTextDoc } from "./merge.js";
21
+ export { fromPortableText, toPortableText } from "./portable-text.js";
22
+ export { fromContentful, toContentful } from "./contentful.js";
23
+ export { fromStrapiBlocks, toStrapiBlocks } from "./strapi.js";
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Identity-preserving merges.
3
+ *
4
+ * Rich text in a CMS is not just its words. A Portable Text block carries a
5
+ * `_key` that annotations and real-time collaboration address it by; a
6
+ * Contentful node carries a `data` object with entry links in it; every format
7
+ * has fields no converter here understands. Re-serialising a document from
8
+ * scratch throws all of that away, and the diff a content editor sees on the
9
+ * next publish is "every block replaced" — which is both wrong and, on a
10
+ * dataset of any size, unreviewable.
11
+ *
12
+ * So a write is always a merge against what was already stored: blocks whose
13
+ * content did not change come back as the *original objects*, untouched, and
14
+ * only genuinely edited blocks are rebuilt.
15
+ */
16
+ import { type RichTextDoc } from "./doc.ts";
17
+ export declare function deepEqual(a: unknown, b: unknown): boolean;
18
+ /** A shallow copy of `value` with the listed keys removed, recursively. */
19
+ export declare function omitDeep<T>(value: T, keys: readonly string[]): T;
20
+ /**
21
+ * Pair each newly built item with the stored item it replaces, and keep the
22
+ * stored one whenever the two say the same thing.
23
+ *
24
+ * Matching runs in two passes because a block that only *moved* must keep its
25
+ * key: content-equality first (so a reordered document reuses every key), then
26
+ * position for whatever is left over (so an edited block keeps the key of the
27
+ * block that was in its slot rather than being issued a new one).
28
+ *
29
+ * `identityKeys` are the fields excluded from the content comparison — the
30
+ * stored key itself, and anything else derived rather than authored.
31
+ */
32
+ export declare function mergeByIdentity<T>(next: T[], previous: T[], options: {
33
+ /**
34
+ * Reduce an item to just the part a human authored, so two items that say
35
+ * the same thing compare equal however their storage keys differ.
36
+ */
37
+ canonical: (item: T) => unknown;
38
+ /** Copy the stored item's identity onto a rebuilt one. */
39
+ adopt: (rebuilt: T, stored: T) => T;
40
+ /** Should this stored item be considered at all? */
41
+ matches?: (rebuilt: T, stored: T) => boolean;
42
+ }): T[];
43
+ /**
44
+ * Fold a freshly built document into the one that was stored.
45
+ *
46
+ * Two jobs, and the second is the one that stops data loss:
47
+ *
48
+ * 1. Carry stored `_key`s onto the rebuilt nodes, so a CMS write updates blocks
49
+ * rather than replacing them.
50
+ * 2. Put back anything the rebuilt document *could not have contained*. When a
51
+ * plan rewrites a rich-text prop it sends a markdown string, and markdown has
52
+ * no way to say "an embedded product card sat here" — so a naive coercion
53
+ * deletes every such block on the first text edit. They are re-inserted at
54
+ * the position they held before. That position is a guess once the
55
+ * surrounding prose has changed length; keeping the block in roughly the
56
+ * right place is the recoverable failure, deleting it is not.
57
+ */
58
+ export declare function mergeRichTextDoc(next: RichTextDoc, previous: RichTextDoc, options?: {
59
+ /**
60
+ * The mark types the format `next` was built from can express. Marks
61
+ * outside this set are treated as invisible: they take no part in deciding
62
+ * whether a node changed, and they are carried over from the stored
63
+ * document. Defaults to markdown's vocabulary, which is what every caller
64
+ * of this function is coercing from. Pass every mark type in play to opt
65
+ * out — appropriate only when `next` came from a lossless source.
66
+ */
67
+ representableMarks?: readonly string[];
68
+ }): RichTextDoc;
package/dist/merge.js ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Identity-preserving merges.
3
+ *
4
+ * Rich text in a CMS is not just its words. A Portable Text block carries a
5
+ * `_key` that annotations and real-time collaboration address it by; a
6
+ * Contentful node carries a `data` object with entry links in it; every format
7
+ * has fields no converter here understands. Re-serialising a document from
8
+ * scratch throws all of that away, and the diff a content editor sees on the
9
+ * next publish is "every block replaced" — which is both wrong and, on a
10
+ * dataset of any size, unreviewable.
11
+ *
12
+ * So a write is always a merge against what was already stored: blocks whose
13
+ * content did not change come back as the *original objects*, untouched, and
14
+ * only genuinely edited blocks are rebuilt.
15
+ */
16
+ import { MARK, NODE } from "./doc.js";
17
+ export function deepEqual(a, b) {
18
+ if (a === b)
19
+ return true;
20
+ if (typeof a !== typeof b)
21
+ return false;
22
+ if (a === null || b === null || typeof a !== "object")
23
+ return false;
24
+ if (Array.isArray(a) || Array.isArray(b)) {
25
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
26
+ return false;
27
+ return a.every((item, i) => deepEqual(item, b[i]));
28
+ }
29
+ const left = a;
30
+ const right = b;
31
+ // `undefined` and absent are the same thing here: a converter that omits a
32
+ // field and one that sets it to undefined produced the same value.
33
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
34
+ for (const key of keys) {
35
+ if (left[key] === undefined && right[key] === undefined)
36
+ continue;
37
+ if (!deepEqual(left[key], right[key]))
38
+ return false;
39
+ }
40
+ return true;
41
+ }
42
+ /** A shallow copy of `value` with the listed keys removed, recursively. */
43
+ export function omitDeep(value, keys) {
44
+ if (Array.isArray(value))
45
+ return value.map((item) => omitDeep(item, keys));
46
+ if (value === null || typeof value !== "object")
47
+ return value;
48
+ const out = {};
49
+ for (const [k, v] of Object.entries(value)) {
50
+ if (keys.includes(k))
51
+ continue;
52
+ out[k] = omitDeep(v, keys);
53
+ }
54
+ return out;
55
+ }
56
+ /**
57
+ * Pair each newly built item with the stored item it replaces, and keep the
58
+ * stored one whenever the two say the same thing.
59
+ *
60
+ * Matching runs in two passes because a block that only *moved* must keep its
61
+ * key: content-equality first (so a reordered document reuses every key), then
62
+ * position for whatever is left over (so an edited block keeps the key of the
63
+ * block that was in its slot rather than being issued a new one).
64
+ *
65
+ * `identityKeys` are the fields excluded from the content comparison — the
66
+ * stored key itself, and anything else derived rather than authored.
67
+ */
68
+ export function mergeByIdentity(next, previous, options) {
69
+ const { canonical, adopt, matches } = options;
70
+ const claimed = new Set();
71
+ const result = new Array(next.length).fill(undefined);
72
+ const eligible = (rebuilt, index) => !claimed.has(index) && (matches ? matches(rebuilt, previous[index]) : true);
73
+ const storedCanonical = previous.map(canonical);
74
+ // Pass 1 — identical content, wherever it sits now.
75
+ for (let i = 0; i < next.length; i++) {
76
+ const bare = canonical(next[i]);
77
+ for (let j = 0; j < previous.length; j++) {
78
+ if (!eligible(next[i], j))
79
+ continue;
80
+ if (!deepEqual(bare, storedCanonical[j]))
81
+ continue;
82
+ claimed.add(j);
83
+ result[i] = previous[j];
84
+ break;
85
+ }
86
+ }
87
+ // Pass 2 — same slot, same shape: an edit in place.
88
+ for (let i = 0; i < next.length; i++) {
89
+ if (result[i] !== undefined)
90
+ continue;
91
+ if (i < previous.length && eligible(next[i], i)) {
92
+ claimed.add(i);
93
+ result[i] = adopt(next[i], previous[i]);
94
+ continue;
95
+ }
96
+ result[i] = next[i];
97
+ }
98
+ return result;
99
+ }
100
+ /**
101
+ * The character styles the markdown grammar can spell. Everything else — an
102
+ * underline, a Portable Text decorator like `accent`, any CMS-defined style —
103
+ * is invisible to a markdown round-trip: it leaves on the way out and cannot
104
+ * come back on the way in.
105
+ */
106
+ const MARKDOWN_MARKS = [MARK.bold, MARK.italic, MARK.strike, MARK.code, MARK.link];
107
+ function canonicalNode(node, representable) {
108
+ const { _key: _dropped, ...attrs } = node.attrs ?? {};
109
+ const out = { type: node.type };
110
+ if (Object.keys(attrs).length > 0)
111
+ out.attrs = attrs;
112
+ if (node.marks !== undefined) {
113
+ const visible = node.marks.filter((mark) => representable.has(mark.type));
114
+ if (visible.length > 0)
115
+ out.marks = visible;
116
+ }
117
+ if (node.text !== undefined)
118
+ out.text = node.text;
119
+ if (node.content !== undefined)
120
+ out.content = canonicalChildren(node.content, representable);
121
+ return out;
122
+ }
123
+ /**
124
+ * Project children, then glue back together the text spans the projection just
125
+ * made indistinguishable.
126
+ *
127
+ * Dropping an invisible mark leaves a seam. A stored paragraph reading
128
+ * `["Untouched ", accent("accented"), " paragraph."]` projects to three spans
129
+ * with identical (empty) marks, where the same sentence parsed from markdown is
130
+ * one span — so a comparison would report a difference that exists only because
131
+ * the styling was removed. Coalescing adjacent same-marked spans closes the
132
+ * seam and makes the two forms compare equal, which is the whole point.
133
+ */
134
+ function canonicalChildren(nodes, representable) {
135
+ const out = [];
136
+ for (const node of nodes) {
137
+ const canonical = canonicalNode(node, representable);
138
+ const previous = out[out.length - 1];
139
+ const bothArePlainText = previous !== undefined &&
140
+ typeof previous.text === "string" &&
141
+ typeof canonical.text === "string" &&
142
+ previous.content === undefined &&
143
+ canonical.content === undefined &&
144
+ previous.type === canonical.type;
145
+ if (bothArePlainText && deepEqual(previous.marks, canonical.marks)) {
146
+ previous.text = `${previous.text}${canonical.text}`;
147
+ continue;
148
+ }
149
+ out.push(canonical);
150
+ }
151
+ return out;
152
+ }
153
+ /** The marks on a node the source format had no way to express. */
154
+ function invisibleMarks(node, representable) {
155
+ return (node.marks ?? []).filter((mark) => !representable.has(mark.type));
156
+ }
157
+ /** Index every text span carrying invisible marks, by its exact text. */
158
+ function collectInvisibleMarks(node, representable, into) {
159
+ if (typeof node.text === "string") {
160
+ const hidden = invisibleMarks(node, representable);
161
+ if (hidden.length > 0 && !into.has(node.text))
162
+ into.set(node.text, hidden);
163
+ }
164
+ for (const child of node.content ?? [])
165
+ collectInvisibleMarks(child, representable, into);
166
+ }
167
+ /**
168
+ * Put invisible marks back onto the text spans they were on, where those spans
169
+ * survived the edit verbatim.
170
+ *
171
+ * This is the consolation prize for a node that genuinely changed. Pass 1 has
172
+ * already handled untouched nodes by keeping the stored object whole; here the
173
+ * prose really was rewritten, so the only honest thing to preserve is a style
174
+ * whose exact run of text is still present. A span whose words changed loses
175
+ * its styling, because nothing in the edit says where the style should now end.
176
+ */
177
+ function reapplyInvisibleMarks(rebuilt, byText, representable) {
178
+ const content = rebuilt.content?.map((child) => reapplyInvisibleMarks(child, byText, representable));
179
+ let marks = rebuilt.marks;
180
+ if (typeof rebuilt.text === "string") {
181
+ const hidden = byText.get(rebuilt.text);
182
+ if (hidden && hidden.length > 0) {
183
+ const already = new Set((marks ?? []).map((mark) => mark.type));
184
+ const additions = hidden.filter((mark) => !already.has(mark.type));
185
+ if (additions.length > 0)
186
+ marks = [...(marks ?? []), ...additions];
187
+ }
188
+ }
189
+ if (marks === rebuilt.marks && content === undefined)
190
+ return rebuilt;
191
+ return {
192
+ ...rebuilt,
193
+ ...(marks !== undefined ? { marks } : {}),
194
+ ...(content !== undefined ? { content } : {})
195
+ };
196
+ }
197
+ function nodeKey(node) {
198
+ const key = node.attrs?._key;
199
+ return typeof key === "string" ? key : undefined;
200
+ }
201
+ function adoptNodeKey(rebuilt, stored) {
202
+ const key = nodeKey(stored);
203
+ if (key === undefined)
204
+ return rebuilt;
205
+ return { ...rebuilt, attrs: { ...(rebuilt.attrs ?? {}), _key: key } };
206
+ }
207
+ /**
208
+ * Fold a freshly built document into the one that was stored.
209
+ *
210
+ * Two jobs, and the second is the one that stops data loss:
211
+ *
212
+ * 1. Carry stored `_key`s onto the rebuilt nodes, so a CMS write updates blocks
213
+ * rather than replacing them.
214
+ * 2. Put back anything the rebuilt document *could not have contained*. When a
215
+ * plan rewrites a rich-text prop it sends a markdown string, and markdown has
216
+ * no way to say "an embedded product card sat here" — so a naive coercion
217
+ * deletes every such block on the first text edit. They are re-inserted at
218
+ * the position they held before. That position is a guess once the
219
+ * surrounding prose has changed length; keeping the block in roughly the
220
+ * right place is the recoverable failure, deleting it is not.
221
+ */
222
+ export function mergeRichTextDoc(next, previous, options) {
223
+ const previousNodes = previous.content ?? [];
224
+ const nextNodes = next.content ?? [];
225
+ const representable = new Set(options?.representableMarks ?? MARKDOWN_MARKS);
226
+ const merged = mergeByIdentity(nextNodes, previousNodes, {
227
+ canonical: (node) => canonicalNode(node, representable),
228
+ adopt: (rebuilt, stored) => {
229
+ // Scoped to the one stored node being replaced, not the whole document:
230
+ // a style must not leak onto an unrelated node that happens to repeat a
231
+ // styled span's wording.
232
+ const hidden = new Map();
233
+ collectInvisibleMarks(stored, representable, hidden);
234
+ return adoptNodeKey(reapplyInvisibleMarks(rebuilt, hidden, representable), stored);
235
+ },
236
+ matches: (rebuilt, stored) => rebuilt.type === stored.type
237
+ });
238
+ const unrepresentable = previousNodes
239
+ .map((node, index) => ({ node, index }))
240
+ .filter(({ node }) => node.type === NODE.unknown);
241
+ if (unrepresentable.length === 0)
242
+ return { type: "doc", content: merged };
243
+ const survivors = new Set(merged.filter((n) => n.type === NODE.unknown).map(nodeKey).filter(Boolean));
244
+ const content = [...merged];
245
+ for (const { node, index } of unrepresentable) {
246
+ const key = nodeKey(node);
247
+ if (key !== undefined && survivors.has(key))
248
+ continue;
249
+ content.splice(Math.min(index, content.length), 0, node);
250
+ }
251
+ return { type: "doc", content };
252
+ }