@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.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The rich-text grammar, in one place.
3
+ *
4
+ * A `richtext` field is stored as a markdown string, and until now three
5
+ * separate implementations parsed it: the block renderers (to React elements),
6
+ * the editor overlay (to an HTML string) and the Puck adapter (to HTML, and
7
+ * back). They had already drifted — the Puck adapter honoured heading depth
8
+ * while the page hard-coded `<h3>` — so the same document rendered a different
9
+ * outline depending on which editor you had open.
10
+ *
11
+ * This module owns the grammar; the callers own presentation. Anything a
12
+ * particular surface does differently (HTML-escaping its input, skipping the
13
+ * paragraph wrapper for single-line values, choosing a heading tag) belongs in
14
+ * that surface's serializer, not here.
15
+ *
16
+ * The grammar is a subset of CommonMark, sized to exactly what the property
17
+ * panel's editor can produce — anything it can write must render, or the user
18
+ * sees their own formatting come back as punctuation:
19
+ * inline `**strong**`, `*em*`, `~~strike~~`, `` `code` ``, `[label](href)`,
20
+ * hard breaks (`\` or two trailing spaces)
21
+ * blocks ATX headings, bullet and ordered lists (nested), blockquotes,
22
+ * fenced code, thematic breaks, paragraphs
23
+ * Still outside it: tables, setext headings, reference links, footnotes, and
24
+ * inline images — the editor has no image node and deletes them, so an
25
+ * `![alt](src)` renders its alt text rather than promising an image the stack
26
+ * cannot keep. See the rich-text characterisation tests for each case.
27
+ */
28
+ /** A span of text inside a block, already resolved to its mark. */
29
+ export type InlineToken = {
30
+ type: "text";
31
+ text: string;
32
+ at: number;
33
+ } | {
34
+ type: "strong";
35
+ text: string;
36
+ at: number;
37
+ } | {
38
+ type: "em";
39
+ text: string;
40
+ at: number;
41
+ } | {
42
+ type: "strike";
43
+ text: string;
44
+ at: number;
45
+ } | {
46
+ type: "code";
47
+ text: string;
48
+ at: number;
49
+ } | {
50
+ type: "link";
51
+ text: string;
52
+ href: string;
53
+ at: number;
54
+ }
55
+ /** An explicit line break inside a block — markdown's `\` or two trailing spaces. */
56
+ | {
57
+ type: "break";
58
+ at: number;
59
+ };
60
+ /** One item in a list, with an optional nested list under it. */
61
+ export type RichTextListItem = {
62
+ inline: InlineToken[];
63
+ children?: RichTextList;
64
+ };
65
+ export type RichTextList = {
66
+ type: "unordered-list" | "ordered-list";
67
+ items: RichTextListItem[];
68
+ };
69
+ /**
70
+ * One top-level block.
71
+ *
72
+ * `index` is the block's position in the source document. Serializers use it to
73
+ * key their output; it is stable for a given input.
74
+ */
75
+ export type RichTextBlock = {
76
+ type: "heading";
77
+ index: number;
78
+ /** Hash count, 1–6, as written. Serializers decide which tag that maps to. */
79
+ level: number;
80
+ inline: InlineToken[];
81
+ /** Text that followed the heading inside the same block, if any. */
82
+ trailing?: InlineToken[];
83
+ } | {
84
+ type: "unordered-list";
85
+ index: number;
86
+ items: RichTextListItem[];
87
+ } | {
88
+ type: "ordered-list";
89
+ index: number;
90
+ items: RichTextListItem[];
91
+ } | {
92
+ type: "blockquote";
93
+ index: number;
94
+ children: RichTextBlock[];
95
+ } | {
96
+ type: "code";
97
+ index: number;
98
+ code: string;
99
+ language?: string;
100
+ } | {
101
+ type: "rule";
102
+ index: number;
103
+ } | {
104
+ type: "paragraph";
105
+ index: number;
106
+ inline: InlineToken[];
107
+ raw: string;
108
+ };
109
+ /**
110
+ * Map an authored hash count onto the heading level a body actually renders.
111
+ *
112
+ * Every serializer must use this, or the same document gets a different outline
113
+ * depending on which surface you are looking at — which is exactly the drift
114
+ * that made the page emit `<h3>` while Puck emitted `<h2>`.
115
+ */
116
+ export declare function resolveRichTextHeadingLevel(level: number): number;
117
+ /**
118
+ * Rewrite heading markers so the source agrees with what will be rendered.
119
+ *
120
+ * Only `#` moves (to `##`); 2-6 are already renderable. Use it on a value
121
+ * entering an editor whose schema does not accept `h1`: without it the editor
122
+ * silently degrades the heading to a paragraph and saves that back, so a `#`
123
+ * that the page was rendering as `h2` disappears on the next edit.
124
+ */
125
+ export declare function clampMarkdownHeadings(markdown: string): string;
126
+ /** Undo the serialiser's escaping of a text span. */
127
+ export declare function unescapeMarkdownText(text: string): string;
128
+ /** Split a line of text into marked spans. Unterminated markers stay literal. */
129
+ export declare function parseInline(text: string): InlineToken[];
130
+ /**
131
+ * Whitespace clean-up applied to a body before it is parsed.
132
+ *
133
+ * This used to also apply `/([.!?])([A-Z])/g → "$1 $2"`, a band-aid for
134
+ * planners that emitted `requested.Here's`. It fired on correct prose far more
135
+ * often than on the malformed kind: every `U.S.A.` became `U. S. A.`, and any
136
+ * URL with a capital after a dot (`/API.Reference`) gained a space and stopped
137
+ * resolving. It ran on every render of every page, so the damage was permanent
138
+ * from the reader's point of view even though the stored string was fine.
139
+ *
140
+ * If a planner starts emitting run-on sentences again, fix it in the prompt —
141
+ * not by rewriting everyone's prose at render time.
142
+ */
143
+ export declare function normalizeRichTextBody(input: string): string;
144
+ /**
145
+ * Parse a body that has already been through `normalizeRichTextBody`.
146
+ *
147
+ * Callers that need to inspect or transform the normalized string first (the
148
+ * editor overlay checks it for newlines) use this; everyone else wants
149
+ * `parseRichText`.
150
+ */
151
+ export declare function parseRichTextBlocks(normalizedBody: string): RichTextBlock[];
152
+ /** Parse a rich-text string into its blocks. */
153
+ export declare function parseRichText(input: string): RichTextBlock[];
package/dist/parse.js ADDED
@@ -0,0 +1,363 @@
1
+ /**
2
+ * The rich-text grammar, in one place.
3
+ *
4
+ * A `richtext` field is stored as a markdown string, and until now three
5
+ * separate implementations parsed it: the block renderers (to React elements),
6
+ * the editor overlay (to an HTML string) and the Puck adapter (to HTML, and
7
+ * back). They had already drifted — the Puck adapter honoured heading depth
8
+ * while the page hard-coded `<h3>` — so the same document rendered a different
9
+ * outline depending on which editor you had open.
10
+ *
11
+ * This module owns the grammar; the callers own presentation. Anything a
12
+ * particular surface does differently (HTML-escaping its input, skipping the
13
+ * paragraph wrapper for single-line values, choosing a heading tag) belongs in
14
+ * that surface's serializer, not here.
15
+ *
16
+ * The grammar is a subset of CommonMark, sized to exactly what the property
17
+ * panel's editor can produce — anything it can write must render, or the user
18
+ * sees their own formatting come back as punctuation:
19
+ * inline `**strong**`, `*em*`, `~~strike~~`, `` `code` ``, `[label](href)`,
20
+ * hard breaks (`\` or two trailing spaces)
21
+ * blocks ATX headings, bullet and ordered lists (nested), blockquotes,
22
+ * fenced code, thematic breaks, paragraphs
23
+ * Still outside it: tables, setext headings, reference links, footnotes, and
24
+ * inline images — the editor has no image node and deletes them, so an
25
+ * `![alt](src)` renders its alt text rather than promising an image the stack
26
+ * cannot keep. See the rich-text characterisation tests for each case.
27
+ */
28
+ /*
29
+ * `(?<!\\)` on every delimiter: an escaped `*` or `[` is literal text, not the
30
+ * start or end of a mark. Without it `\*not emphasis\*` would open a mark on a
31
+ * character the author explicitly escaped.
32
+ *
33
+ * Known limit: a genuine escaped backslash immediately before a delimiter
34
+ * (`\\*emphasis*`) reads as an escaped delimiter and the mark is missed. It
35
+ * needs a full character-by-character scanner to get right and has never come
36
+ * up in real content.
37
+ */
38
+ const INLINE_PATTERN = new RegExp([
39
+ // Code spans bind tightest, so they come first: nothing inside them is markup.
40
+ "(?<!\\\\)`(?<code>[^`]+?)`",
41
+ // Before the link alternative, so the `!` is consumed rather than left behind.
42
+ "!\\[(?<imageAlt>.*?)(?<!\\\\)\\]\\((?<imageSrc>.+?)(?<!\\\\)\\)",
43
+ "(?<!\\\\)\\*\\*(?<strong>.+?)(?<!\\\\)\\*\\*",
44
+ "(?<!\\\\)~~(?<strike>.+?)(?<!\\\\)~~",
45
+ "(?<!\\\\)\\*(?<em>.+?)(?<!\\\\)\\*",
46
+ "(?<!\\\\)\\[(?<linkText>.+?)(?<!\\\\)\\]\\((?<linkHref>.+?)(?<!\\\\)\\)"
47
+ ].join("|"), "g");
48
+ /** A trailing `\` (what the editor's Shift+Enter serialises to) or two trailing spaces. */
49
+ const HARD_BREAK_PATTERN = /(\\\n|[ ]{2,}\n)/;
50
+ const HEADING_PATTERN = /^(#{1,6})\s+(.+)$/;
51
+ const UNORDERED_ITEM_PATTERN = /^(\s*)[-*+•]\s+(.+)$/;
52
+ const ORDERED_ITEM_PATTERN = /^(\s*)\d+[.)]\s+(.+)$/;
53
+ /** ``` or ~~~, optionally followed by an info string naming the language. */
54
+ const FENCE_PATTERN = /^(```|~~~)(.*)$/;
55
+ /** A thematic break: three or more of the same marker, alone on its line. */
56
+ const RULE_PATTERN = /^(?:-{3,}|\*{3,}|_{3,})$/;
57
+ const BLOCKQUOTE_PATTERN = /^>\s?(.*)$/;
58
+ /*
59
+ * The characters `prosemirror-markdown`'s `esc()` escapes when it serialises a
60
+ * text node. Everything that comes out of the property panel has been through
61
+ * it, so a bracket the author typed arrives here as `\[` — and used to reach
62
+ * the page with the backslash showing.
63
+ */
64
+ const ESCAPED_TEXT_CHARACTER = /\\([`*\\~[\]_])/g;
65
+ /** Link hrefs use a different set — see the link mark's serialiser. */
66
+ const ESCAPED_HREF_CHARACTER = /\\([()"])/g;
67
+ /**
68
+ * The shallowest heading a rich-text body may render.
69
+ *
70
+ * A body heading is never the page's most important one — the Hero or the
71
+ * block's own title already holds `h1`. Letting `#` through would put a second
72
+ * `h1` on the page, which costs both the document outline and the screen-reader
73
+ * landmark list. So `#` and `##` both land on `h2`.
74
+ */
75
+ const MIN_HEADING_LEVEL = 2;
76
+ const MAX_HEADING_LEVEL = 6;
77
+ /**
78
+ * Map an authored hash count onto the heading level a body actually renders.
79
+ *
80
+ * Every serializer must use this, or the same document gets a different outline
81
+ * depending on which surface you are looking at — which is exactly the drift
82
+ * that made the page emit `<h3>` while Puck emitted `<h2>`.
83
+ */
84
+ export function resolveRichTextHeadingLevel(level) {
85
+ return Math.min(MAX_HEADING_LEVEL, Math.max(MIN_HEADING_LEVEL, level));
86
+ }
87
+ /**
88
+ * Rewrite heading markers so the source agrees with what will be rendered.
89
+ *
90
+ * Only `#` moves (to `##`); 2-6 are already renderable. Use it on a value
91
+ * entering an editor whose schema does not accept `h1`: without it the editor
92
+ * silently degrades the heading to a paragraph and saves that back, so a `#`
93
+ * that the page was rendering as `h2` disappears on the next edit.
94
+ */
95
+ export function clampMarkdownHeadings(markdown) {
96
+ return markdown.replace(/^(#{1,6})(\s)/gm, (_match, hashes, space) => "#".repeat(resolveRichTextHeadingLevel(hashes.length)) + space);
97
+ }
98
+ /** Undo the serialiser's escaping of a text span. */
99
+ export function unescapeMarkdownText(text) {
100
+ return text.replace(ESCAPED_TEXT_CHARACTER, "$1");
101
+ }
102
+ function unescapeHref(href) {
103
+ return href.replace(ESCAPED_HREF_CHARACTER, "$1");
104
+ }
105
+ /** Tokenize a run of text with no hard breaks in it. */
106
+ function tokenizeSegment(text, base) {
107
+ const tokens = [];
108
+ // A fresh regex per call: the module-level one carries `lastIndex` state.
109
+ const regex = new RegExp(INLINE_PATTERN.source, "g");
110
+ let last = 0;
111
+ let match;
112
+ const pushText = (value, at) => {
113
+ if (value.length > 0)
114
+ tokens.push({ type: "text", text: unescapeMarkdownText(value), at });
115
+ };
116
+ while ((match = regex.exec(text)) !== null) {
117
+ const groups = match.groups ?? {};
118
+ pushText(text.slice(last, match.index), base + last);
119
+ const at = base + match.index;
120
+ if (groups.code !== undefined) {
121
+ // Code spans are literal: no unescaping, no nested markup.
122
+ tokens.push({ type: "code", text: groups.code, at });
123
+ }
124
+ else if (groups.imageSrc !== undefined) {
125
+ /*
126
+ * Inline images are not supported, and rendering one would be worse than
127
+ * not: the property panel's editor has no image node, so it deletes an
128
+ * `![alt](src)` outright the moment anyone edits the field. Keeping the
129
+ * alt text at least preserves the words instead of leaving the stray `!`
130
+ * the old link-only regex produced. Images belong in an `f.image` field.
131
+ */
132
+ pushText(groups.imageAlt ?? "", at);
133
+ }
134
+ else if (groups.strong !== undefined) {
135
+ tokens.push({ type: "strong", text: unescapeMarkdownText(groups.strong), at });
136
+ }
137
+ else if (groups.strike !== undefined) {
138
+ tokens.push({ type: "strike", text: unescapeMarkdownText(groups.strike), at });
139
+ }
140
+ else if (groups.em !== undefined) {
141
+ tokens.push({ type: "em", text: unescapeMarkdownText(groups.em), at });
142
+ }
143
+ else if (groups.linkText !== undefined && groups.linkHref !== undefined) {
144
+ tokens.push({
145
+ type: "link",
146
+ text: unescapeMarkdownText(groups.linkText),
147
+ href: unescapeHref(groups.linkHref),
148
+ at
149
+ });
150
+ }
151
+ last = match.index + match[0].length;
152
+ }
153
+ pushText(text.slice(last), base + last);
154
+ return tokens;
155
+ }
156
+ /** Split a line of text into marked spans. Unterminated markers stay literal. */
157
+ export function parseInline(text) {
158
+ const tokens = [];
159
+ // The capture group keeps the separators, so they land on the odd indices.
160
+ const segments = text.split(HARD_BREAK_PATTERN);
161
+ let offset = 0;
162
+ for (let i = 0; i < segments.length; i++) {
163
+ const segment = segments[i];
164
+ if (i % 2 === 1) {
165
+ tokens.push({ type: "break", at: offset });
166
+ }
167
+ else if (segment.length > 0) {
168
+ tokens.push(...tokenizeSegment(segment, offset));
169
+ }
170
+ offset += segment.length;
171
+ }
172
+ return tokens;
173
+ }
174
+ /**
175
+ * Whitespace clean-up applied to a body before it is parsed.
176
+ *
177
+ * This used to also apply `/([.!?])([A-Z])/g → "$1 $2"`, a band-aid for
178
+ * planners that emitted `requested.Here's`. It fired on correct prose far more
179
+ * often than on the malformed kind: every `U.S.A.` became `U. S. A.`, and any
180
+ * URL with a capital after a dot (`/API.Reference`) gained a space and stopped
181
+ * resolving. It ran on every render of every page, so the damage was permanent
182
+ * from the reader's point of view even though the stored string was fine.
183
+ *
184
+ * If a planner starts emitting run-on sentences again, fix it in the prompt —
185
+ * not by rewriting everyone's prose at render time.
186
+ */
187
+ export function normalizeRichTextBody(input) {
188
+ return input
189
+ .replace(/\r\n?/g, "\n")
190
+ .replace(/\n{3,}/g, "\n\n")
191
+ .trim();
192
+ }
193
+ function readListLine(line) {
194
+ const unordered = UNORDERED_ITEM_PATTERN.exec(line);
195
+ if (unordered) {
196
+ const text = unordered[2].trim();
197
+ return text.length > 0 ? { indent: unordered[1].length, ordered: false, text } : null;
198
+ }
199
+ const ordered = ORDERED_ITEM_PATTERN.exec(line);
200
+ if (ordered) {
201
+ const text = ordered[2].trim();
202
+ return text.length > 0 ? { indent: ordered[1].length, ordered: true, text } : null;
203
+ }
204
+ return null;
205
+ }
206
+ /**
207
+ * Build a nested list from indented source lines.
208
+ *
209
+ * Indent width is compared, not counted in fixed units: the editor writes two
210
+ * spaces under a `-` and three under a `1.` so that the text of a nested item
211
+ * lines up under its parent's, and other producers use four. Anything deeper
212
+ * than the enclosing item opens a nested list; anything shallower closes one.
213
+ *
214
+ * A block is a list only when *every* one of its lines is a list item — a
215
+ * continuation line makes the whole block a paragraph, as it always has.
216
+ */
217
+ function parseList(rawLines, index) {
218
+ const lines = rawLines.filter((line) => line.trim().length > 0);
219
+ const parsed = [];
220
+ for (const line of lines) {
221
+ const item = readListLine(line);
222
+ if (!item)
223
+ return null;
224
+ parsed.push(item);
225
+ }
226
+ if (parsed.length === 0)
227
+ return null;
228
+ const root = {
229
+ type: parsed[0].ordered ? "ordered-list" : "unordered-list",
230
+ items: []
231
+ };
232
+ // Each frame is a list that is currently open, with the indent it started at.
233
+ const stack = [
234
+ { indent: parsed[0].indent, list: root }
235
+ ];
236
+ for (const line of parsed) {
237
+ while (stack.length > 1 && line.indent < stack[stack.length - 1].indent)
238
+ stack.pop();
239
+ let top = stack[stack.length - 1];
240
+ if (line.indent > top.indent) {
241
+ const parent = top.list.items[top.list.items.length - 1];
242
+ // An indented first item has no parent to hang from; treat it as a sibling.
243
+ if (parent) {
244
+ const nested = {
245
+ type: line.ordered ? "ordered-list" : "unordered-list",
246
+ items: []
247
+ };
248
+ parent.children = nested;
249
+ stack.push({ indent: line.indent, list: nested });
250
+ top = stack[stack.length - 1];
251
+ }
252
+ }
253
+ top.list.items.push({ inline: parseInline(line.text) });
254
+ }
255
+ return { type: root.type, index, items: root.items };
256
+ }
257
+ function parseBlock(raw, index) {
258
+ const rawLines = raw.split("\n");
259
+ const lines = rawLines.map((line) => line.trim()).filter(Boolean);
260
+ if (lines.length === 0)
261
+ return null;
262
+ // Fenced code. The splitter keeps a fenced run together, blank lines and all.
263
+ const fence = FENCE_PATTERN.exec(lines[0]);
264
+ if (fence) {
265
+ const language = fence[2].trim();
266
+ const openIndex = rawLines.findIndex((line) => FENCE_PATTERN.test(line.trim()));
267
+ const body = rawLines.slice(openIndex + 1);
268
+ const closeIndex = body.findIndex((line) => line.trim().startsWith(fence[1]));
269
+ const code = (closeIndex === -1 ? body : body.slice(0, closeIndex)).join("\n");
270
+ return { type: "code", index, code, ...(language ? { language } : {}) };
271
+ }
272
+ if (lines.length === 1 && RULE_PATTERN.test(lines[0])) {
273
+ return { type: "rule", index };
274
+ }
275
+ const heading = HEADING_PATTERN.exec(lines[0]);
276
+ if (heading) {
277
+ const trailing = lines.slice(1).join(" ").trim();
278
+ return {
279
+ type: "heading",
280
+ index,
281
+ level: heading[1].length,
282
+ inline: parseInline(heading[2].trim()),
283
+ ...(trailing ? { trailing: parseInline(trailing) } : {})
284
+ };
285
+ }
286
+ // A blockquote carries its marker on every line, blank ones included — which
287
+ // is how a multi-paragraph quote survives the blank-line split above. Strip
288
+ // the markers and parse what is left as blocks, so a quote can hold lists and
289
+ // headings the way it does in every other markdown implementation.
290
+ if (lines.every((line) => BLOCKQUOTE_PATTERN.test(line))) {
291
+ const inner = rawLines
292
+ .map((line) => BLOCKQUOTE_PATTERN.exec(line.trim())?.[1] ?? "")
293
+ .join("\n");
294
+ const children = parseRichTextBlocks(inner);
295
+ if (children.length > 0)
296
+ return { type: "blockquote", index, children };
297
+ }
298
+ const list = parseList(rawLines, index);
299
+ if (list)
300
+ return list;
301
+ // Note: the paragraph's inline spans come from the *raw* block, newlines
302
+ // included, not from the trimmed `lines` above.
303
+ return { type: "paragraph", index, inline: parseInline(raw), raw };
304
+ }
305
+ /**
306
+ * Split a normalized body into block-level chunks on blank lines.
307
+ *
308
+ * Fence-aware: a blank line inside a fenced code block is part of the code, not
309
+ * a block separator. Splitting on the blank line first would cut the fence in
310
+ * half and render both halves as paragraphs of literal backticks.
311
+ */
312
+ function splitBlocks(body) {
313
+ const chunks = [];
314
+ let current = [];
315
+ let openFence = null;
316
+ const flush = () => {
317
+ const text = current.join("\n").trim();
318
+ if (text.length > 0)
319
+ chunks.push(text);
320
+ current = [];
321
+ };
322
+ for (const line of body.split("\n")) {
323
+ const trimmed = line.trim();
324
+ if (openFence !== null) {
325
+ current.push(line);
326
+ if (trimmed.startsWith(openFence)) {
327
+ openFence = null;
328
+ flush();
329
+ }
330
+ continue;
331
+ }
332
+ const fence = FENCE_PATTERN.exec(trimmed);
333
+ if (fence) {
334
+ flush();
335
+ openFence = fence[1];
336
+ current.push(line);
337
+ continue;
338
+ }
339
+ if (trimmed.length === 0) {
340
+ flush();
341
+ continue;
342
+ }
343
+ current.push(line);
344
+ }
345
+ flush();
346
+ return chunks;
347
+ }
348
+ /**
349
+ * Parse a body that has already been through `normalizeRichTextBody`.
350
+ *
351
+ * Callers that need to inspect or transform the normalized string first (the
352
+ * editor overlay checks it for newlines) use this; everyone else wants
353
+ * `parseRichText`.
354
+ */
355
+ export function parseRichTextBlocks(normalizedBody) {
356
+ return splitBlocks(normalizedBody)
357
+ .map(parseBlock)
358
+ .filter((block) => block !== null);
359
+ }
360
+ /** Parse a rich-text string into its blocks. */
361
+ export function parseRichText(input) {
362
+ return parseRichTextBlocks(normalizeRichTextBody(input));
363
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Sanity Portable Text <-> the pivot document.
3
+ *
4
+ * Portable Text is a *flat* array. A nested list is not a tree: it is a run of
5
+ * sibling blocks that each declare `listItem` and a `level`, and the nesting
6
+ * only exists once a renderer groups them. A multi-paragraph quote is likewise
7
+ * several blocks that happen to share `style: "blockquote"`. Both are rebuilt
8
+ * into real trees on the way in and flattened again on the way out.
9
+ *
10
+ * Two things about the format make a lossless round trip possible, and both are
11
+ * relied on here:
12
+ *
13
+ * - Every block, span and annotation carries a `_key`. Those keys are how the
14
+ * Sanity dataset addresses content, so they are carried through the pivot
15
+ * (`attrs._key`) and written back rather than regenerated.
16
+ * - An unrecognised `_type` is just data. A block this converter does not know
17
+ * is not an error and must not be dropped; it rides through as
18
+ * `avocadoUnknownBlock` and comes back out byte-identical.
19
+ */
20
+ import { type RichTextDoc } from "./doc.ts";
21
+ export type PortableTextSpan = {
22
+ _type: string;
23
+ _key?: string;
24
+ text?: string;
25
+ marks?: string[];
26
+ [key: string]: unknown;
27
+ };
28
+ export type PortableTextMarkDef = {
29
+ _type: string;
30
+ _key: string;
31
+ [key: string]: unknown;
32
+ };
33
+ export type PortableTextBlock = {
34
+ _type: string;
35
+ _key?: string;
36
+ style?: string;
37
+ listItem?: string;
38
+ level?: number;
39
+ children?: PortableTextSpan[];
40
+ markDefs?: PortableTextMarkDef[];
41
+ [key: string]: unknown;
42
+ };
43
+ /** Convert an array of Portable Text blocks into the pivot document. */
44
+ export declare function fromPortableText(blocks: unknown): RichTextDoc;
45
+ export type ToPortableTextOptions = {
46
+ /**
47
+ * The blocks currently stored for this field.
48
+ *
49
+ * Supplying them is what turns a write into an update: an unchanged block is
50
+ * re-emitted as the stored object itself, keys and unrecognised fields
51
+ * included, so the CMS sees a diff of what actually changed.
52
+ */
53
+ previous?: unknown;
54
+ /** Prefix for generated `_key`s. Only used where no stored key exists. */
55
+ keyPrefix?: string;
56
+ };
57
+ /** Convert the pivot document back to an array of Portable Text blocks. */
58
+ export declare function toPortableText(doc: RichTextDoc, options?: ToPortableTextOptions): PortableTextBlock[];