@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/LICENSE +201 -0
- package/README.md +85 -0
- package/dist/contentful.d.ts +39 -0
- package/dist/contentful.js +246 -0
- package/dist/doc.d.ts +83 -0
- package/dist/doc.js +306 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/merge.d.ts +68 -0
- package/dist/merge.js +252 -0
- package/dist/parse.d.ts +153 -0
- package/dist/parse.js +363 -0
- package/dist/portable-text.d.ts +58 -0
- package/dist/portable-text.js +424 -0
- package/dist/strapi.d.ts +41 -0
- package/dist/strapi.js +236 -0
- package/package.json +50 -0
package/dist/parse.d.ts
ADDED
|
@@ -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
|
+
* `` 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
|
+
* `` 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
|
+
* `` 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[];
|