@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
|
@@ -0,0 +1,424 @@
|
|
|
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 { MARK, NODE } from "./doc.js";
|
|
21
|
+
import { mergeByIdentity, omitDeep } from "./merge.js";
|
|
22
|
+
/** Portable Text's standard decorators, and the pivot marks they are. */
|
|
23
|
+
const DECORATOR_TO_MARK = {
|
|
24
|
+
strong: MARK.bold,
|
|
25
|
+
em: MARK.italic,
|
|
26
|
+
underline: MARK.underline,
|
|
27
|
+
"strike-through": MARK.strike,
|
|
28
|
+
code: MARK.code
|
|
29
|
+
};
|
|
30
|
+
const MARK_TO_DECORATOR = Object.fromEntries(Object.entries(DECORATOR_TO_MARK).map(([decorator, mark]) => [mark, decorator]));
|
|
31
|
+
const HEADING_STYLE = /^h([1-6])$/;
|
|
32
|
+
function isObject(value) {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Portable Text -> document
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
function spansToInline(block) {
|
|
39
|
+
const markDefs = new Map((block.markDefs ?? []).map((def) => [def._key, def]));
|
|
40
|
+
const nodes = [];
|
|
41
|
+
for (const span of block.children ?? []) {
|
|
42
|
+
if (span._type !== "span") {
|
|
43
|
+
// An inline object (a mention, an inline image). No text to salvage, so
|
|
44
|
+
// keep the whole thing as data rather than silently deleting it.
|
|
45
|
+
nodes.push({ type: NODE.unknown, attrs: { data: span, _key: span._key ?? null } });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const text = typeof span.text === "string" ? span.text : "";
|
|
49
|
+
if (text.length === 0)
|
|
50
|
+
continue;
|
|
51
|
+
const marks = [];
|
|
52
|
+
for (const name of span.marks ?? []) {
|
|
53
|
+
const decorator = DECORATOR_TO_MARK[name];
|
|
54
|
+
if (decorator) {
|
|
55
|
+
marks.push({ type: decorator });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const def = markDefs.get(name);
|
|
59
|
+
if (!def) {
|
|
60
|
+
// A decorator this converter does not know about — a custom one the
|
|
61
|
+
// schema declares. Carry the name through so it survives the trip.
|
|
62
|
+
marks.push({ type: name });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const { _type, _key, ...attrs } = def;
|
|
66
|
+
marks.push({
|
|
67
|
+
type: _type === "link" ? MARK.link : _type,
|
|
68
|
+
attrs: { ...attrs, _key }
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
nodes.push({
|
|
72
|
+
type: NODE.text,
|
|
73
|
+
text,
|
|
74
|
+
...(marks.length > 0 ? { marks } : {})
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return nodes;
|
|
78
|
+
}
|
|
79
|
+
function withKey(node, key) {
|
|
80
|
+
if (key === undefined)
|
|
81
|
+
return node;
|
|
82
|
+
return { ...node, attrs: { ...(node.attrs ?? {}), _key: key } };
|
|
83
|
+
}
|
|
84
|
+
function unknownNode(block) {
|
|
85
|
+
return { type: NODE.unknown, attrs: { data: block, _key: block._key ?? null } };
|
|
86
|
+
}
|
|
87
|
+
function textBlockToNode(block) {
|
|
88
|
+
const content = spansToInline(block);
|
|
89
|
+
const style = typeof block.style === "string" ? block.style : "normal";
|
|
90
|
+
const heading = HEADING_STYLE.exec(style);
|
|
91
|
+
const node = heading
|
|
92
|
+
? { type: NODE.heading, attrs: { level: Number(heading[1]) } }
|
|
93
|
+
: { type: NODE.paragraph };
|
|
94
|
+
const withBody = content.length > 0 ? { ...node, content } : node;
|
|
95
|
+
return withKey(withBody, block._key);
|
|
96
|
+
}
|
|
97
|
+
function listLevel(block) {
|
|
98
|
+
return typeof block.level === "number" && block.level > 0 ? block.level : 1;
|
|
99
|
+
}
|
|
100
|
+
function listTypeFor(block) {
|
|
101
|
+
return block.listItem === "number" ? NODE.orderedList : NODE.bulletList;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Rebuild one run of `listItem` blocks into nested list nodes.
|
|
105
|
+
*
|
|
106
|
+
* Returns the node plus the index of the first block it did not consume. A run
|
|
107
|
+
* ends when the level drops below where it started or the list style changes at
|
|
108
|
+
* the same level — which is exactly how a renderer has to read it too.
|
|
109
|
+
*/
|
|
110
|
+
function buildList(run, start, level) {
|
|
111
|
+
const type = listTypeFor(run[start]);
|
|
112
|
+
const items = [];
|
|
113
|
+
let i = start;
|
|
114
|
+
while (i < run.length) {
|
|
115
|
+
const blockLevel = listLevel(run[i]);
|
|
116
|
+
if (blockLevel < level)
|
|
117
|
+
break;
|
|
118
|
+
if (blockLevel > level) {
|
|
119
|
+
const [nested, next] = buildList(run, i, blockLevel);
|
|
120
|
+
const parent = items[items.length - 1];
|
|
121
|
+
if (parent) {
|
|
122
|
+
parent.content = [...(parent.content ?? []), nested];
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// A run that opens deeper than it should. Keep the content by treating
|
|
126
|
+
// the nested list as a sibling rather than dropping it.
|
|
127
|
+
items.push({ type: NODE.listItem, content: [nested] });
|
|
128
|
+
}
|
|
129
|
+
i = next;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (listTypeFor(run[i]) !== type)
|
|
133
|
+
break;
|
|
134
|
+
const content = spansToInline(run[i]);
|
|
135
|
+
const paragraph = content.length > 0 ? { type: NODE.paragraph, content } : { type: NODE.paragraph };
|
|
136
|
+
items.push(withKey({ type: NODE.listItem, content: [paragraph] }, run[i]._key));
|
|
137
|
+
i++;
|
|
138
|
+
}
|
|
139
|
+
return [{ type, content: items }, i];
|
|
140
|
+
}
|
|
141
|
+
/** Convert an array of Portable Text blocks into the pivot document. */
|
|
142
|
+
export function fromPortableText(blocks) {
|
|
143
|
+
if (!Array.isArray(blocks))
|
|
144
|
+
return { type: "doc", content: [] };
|
|
145
|
+
const source = blocks.filter(isObject);
|
|
146
|
+
const content = [];
|
|
147
|
+
let i = 0;
|
|
148
|
+
while (i < source.length) {
|
|
149
|
+
const block = source[i];
|
|
150
|
+
if (block._type !== "block") {
|
|
151
|
+
// Sanity's code-input plugin is common enough to be worth understanding;
|
|
152
|
+
// everything else rides through untouched.
|
|
153
|
+
if (block._type === "code" && typeof block.code === "string") {
|
|
154
|
+
content.push(withKey({
|
|
155
|
+
type: NODE.codeBlock,
|
|
156
|
+
attrs: { language: typeof block.language === "string" ? block.language : null },
|
|
157
|
+
...(block.code.length > 0 ? { content: [{ type: NODE.text, text: block.code }] } : {})
|
|
158
|
+
}, block._key));
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
content.push(unknownNode(block));
|
|
162
|
+
}
|
|
163
|
+
i++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (typeof block.listItem === "string" && block.listItem.length > 0) {
|
|
167
|
+
const runEnd = (() => {
|
|
168
|
+
let j = i;
|
|
169
|
+
while (j < source.length && typeof source[j].listItem === "string" && source[j]._type === "block")
|
|
170
|
+
j++;
|
|
171
|
+
return j;
|
|
172
|
+
})();
|
|
173
|
+
const run = source.slice(i, runEnd);
|
|
174
|
+
let cursor = 0;
|
|
175
|
+
while (cursor < run.length) {
|
|
176
|
+
const [node, next] = buildList(run, cursor, listLevel(run[cursor]));
|
|
177
|
+
content.push(node);
|
|
178
|
+
cursor = next === cursor ? cursor + 1 : next;
|
|
179
|
+
}
|
|
180
|
+
i = runEnd;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (block.style === "blockquote") {
|
|
184
|
+
// A quote spanning several paragraphs is several blocks in a row. Fold
|
|
185
|
+
// them into one node so the editor shows one quote, not three.
|
|
186
|
+
const children = [];
|
|
187
|
+
let j = i;
|
|
188
|
+
while (j < source.length && source[j]._type === "block" && source[j].style === "blockquote" && !source[j].listItem) {
|
|
189
|
+
children.push(textBlockToNode({ ...source[j], style: "normal" }));
|
|
190
|
+
j++;
|
|
191
|
+
}
|
|
192
|
+
content.push({ type: NODE.blockquote, content: children });
|
|
193
|
+
i = j;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
content.push(textBlockToNode(block));
|
|
197
|
+
i++;
|
|
198
|
+
}
|
|
199
|
+
return { type: "doc", content };
|
|
200
|
+
}
|
|
201
|
+
function createKeyMint(prefix) {
|
|
202
|
+
let counter = 0;
|
|
203
|
+
// Deterministic, not random: the same document must serialise to the same
|
|
204
|
+
// keys every time or every publish diff shows every block as changed.
|
|
205
|
+
return () => `${prefix}${counter++}`;
|
|
206
|
+
}
|
|
207
|
+
function nodeKey(node) {
|
|
208
|
+
const key = node.attrs?._key;
|
|
209
|
+
return typeof key === "string" && key.length > 0 ? key : undefined;
|
|
210
|
+
}
|
|
211
|
+
function inlineToSpans(nodes, blockKey) {
|
|
212
|
+
const children = [];
|
|
213
|
+
const markDefs = [];
|
|
214
|
+
const defByKey = new Map();
|
|
215
|
+
let annotationCount = 0;
|
|
216
|
+
for (const node of nodes ?? []) {
|
|
217
|
+
if (node.type === NODE.unknown && isObject(node.attrs?.data)) {
|
|
218
|
+
children.push(node.attrs.data);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (node.type === NODE.hardBreak) {
|
|
222
|
+
// Portable Text has no break node; a newline inside the span is how every
|
|
223
|
+
// Sanity renderer expresses one.
|
|
224
|
+
const last = children[children.length - 1];
|
|
225
|
+
if (last && typeof last.text === "string")
|
|
226
|
+
last.text += "\n";
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (typeof node.text !== "string" || node.text.length === 0)
|
|
230
|
+
continue;
|
|
231
|
+
const marks = [];
|
|
232
|
+
for (const mark of node.marks ?? []) {
|
|
233
|
+
const decorator = MARK_TO_DECORATOR[mark.type];
|
|
234
|
+
if (decorator && !mark.attrs) {
|
|
235
|
+
marks.push(decorator);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (!mark.attrs) {
|
|
239
|
+
marks.push(mark.type);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const { _key, ...rest } = mark.attrs;
|
|
243
|
+
const key = typeof _key === "string" && _key.length > 0 ? _key : `${blockKey}a${annotationCount++}`;
|
|
244
|
+
if (!defByKey.has(key)) {
|
|
245
|
+
const def = {
|
|
246
|
+
_type: mark.type === MARK.link ? "link" : mark.type,
|
|
247
|
+
_key: key,
|
|
248
|
+
...rest
|
|
249
|
+
};
|
|
250
|
+
defByKey.set(key, def);
|
|
251
|
+
markDefs.push(def);
|
|
252
|
+
}
|
|
253
|
+
marks.push(key);
|
|
254
|
+
}
|
|
255
|
+
children.push({
|
|
256
|
+
_type: "span",
|
|
257
|
+
_key: `${blockKey}s${children.length}`,
|
|
258
|
+
text: node.text,
|
|
259
|
+
...(marks.length > 0 ? { marks } : {})
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return { children, markDefs };
|
|
263
|
+
}
|
|
264
|
+
function textNodeToBlock(node, style, mint) {
|
|
265
|
+
const key = nodeKey(node) ?? mint("b");
|
|
266
|
+
const { children, markDefs } = inlineToSpans(node.content, key);
|
|
267
|
+
return {
|
|
268
|
+
_type: "block",
|
|
269
|
+
_key: key,
|
|
270
|
+
style,
|
|
271
|
+
...(markDefs.length > 0 ? { markDefs } : { markDefs: [] }),
|
|
272
|
+
children
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function styleForNode(node) {
|
|
276
|
+
if (node.type !== NODE.heading)
|
|
277
|
+
return "normal";
|
|
278
|
+
const level = typeof node.attrs?.level === "number" ? node.attrs.level : 2;
|
|
279
|
+
return `h${Math.min(6, Math.max(1, level))}`;
|
|
280
|
+
}
|
|
281
|
+
function flattenList(node, level, mint, out) {
|
|
282
|
+
const listItem = node.type === NODE.orderedList ? "number" : "bullet";
|
|
283
|
+
for (const item of node.content ?? []) {
|
|
284
|
+
const children = item.content ?? [];
|
|
285
|
+
const paragraph = children.find((c) => c.type === NODE.paragraph);
|
|
286
|
+
const key = nodeKey(item) ?? mint("b");
|
|
287
|
+
const spans = inlineToSpans(paragraph?.content, key);
|
|
288
|
+
out.push({
|
|
289
|
+
_type: "block",
|
|
290
|
+
_key: key,
|
|
291
|
+
style: "normal",
|
|
292
|
+
listItem,
|
|
293
|
+
level,
|
|
294
|
+
...(spans.markDefs.length > 0 ? { markDefs: spans.markDefs } : { markDefs: [] }),
|
|
295
|
+
children: spans.children
|
|
296
|
+
});
|
|
297
|
+
for (const child of children) {
|
|
298
|
+
if (child.type === NODE.bulletList || child.type === NODE.orderedList) {
|
|
299
|
+
flattenList(child, level + 1, mint, out);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function nodeToBlocks(node, mint) {
|
|
305
|
+
switch (node.type) {
|
|
306
|
+
case NODE.paragraph:
|
|
307
|
+
case NODE.heading:
|
|
308
|
+
return [textNodeToBlock(node, styleForNode(node), mint)];
|
|
309
|
+
case NODE.bulletList:
|
|
310
|
+
case NODE.orderedList: {
|
|
311
|
+
const out = [];
|
|
312
|
+
flattenList(node, 1, mint, out);
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
case NODE.blockquote:
|
|
316
|
+
return (node.content ?? []).map((child) => textNodeToBlock(child, "blockquote", mint));
|
|
317
|
+
case NODE.codeBlock:
|
|
318
|
+
return [
|
|
319
|
+
{
|
|
320
|
+
_type: "code",
|
|
321
|
+
_key: nodeKey(node) ?? mint("b"),
|
|
322
|
+
code: (node.content ?? []).map((c) => c.text ?? "").join(""),
|
|
323
|
+
...(typeof node.attrs?.language === "string" ? { language: node.attrs.language } : {})
|
|
324
|
+
}
|
|
325
|
+
];
|
|
326
|
+
case NODE.horizontalRule:
|
|
327
|
+
// Portable Text has no rule primitive. Sanity schemas that want one
|
|
328
|
+
// declare their own object type, so emitting a fabricated `_type` here
|
|
329
|
+
// would write a block the dataset rejects.
|
|
330
|
+
return [];
|
|
331
|
+
case NODE.unknown:
|
|
332
|
+
return isObject(node.attrs?.data) ? [node.attrs.data] : [];
|
|
333
|
+
default:
|
|
334
|
+
return (node.content ?? []).flatMap((child) => nodeToBlocks(child, mint));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* The fields of a `block` that this converter can rebuild from the pivot.
|
|
339
|
+
*
|
|
340
|
+
* Anything else on the block — a field the Sanity schema adds, an annotation
|
|
341
|
+
* store, whatever a plugin wrote — is content Avocado never saw and cannot
|
|
342
|
+
* reproduce. It is excluded from the content comparison (or every such block
|
|
343
|
+
* would look edited on every publish) and carried across verbatim instead.
|
|
344
|
+
*/
|
|
345
|
+
const REPRESENTABLE_BLOCK_FIELDS = ["_type", "_key", "style", "listItem", "level", "children", "markDefs"];
|
|
346
|
+
function unrepresentedFields(block) {
|
|
347
|
+
const out = {};
|
|
348
|
+
for (const [key, value] of Object.entries(block)) {
|
|
349
|
+
if (REPRESENTABLE_BLOCK_FIELDS.includes(key))
|
|
350
|
+
continue;
|
|
351
|
+
out[key] = value;
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Reduce a block to the part a person authored.
|
|
357
|
+
*
|
|
358
|
+
* Keys are stripped, and annotation references renumbered in order of first
|
|
359
|
+
* use, so a block rebuilt with generated keys compares equal to the stored one
|
|
360
|
+
* it came from. Without the renumbering every block with a link in it would
|
|
361
|
+
* look changed on every publish.
|
|
362
|
+
*
|
|
363
|
+
* Empty is also absent here, on both sides. Sanity writes `marks: []` on a
|
|
364
|
+
* span with no marks and this converter omits the key entirely, so the two
|
|
365
|
+
* spell the same span two ways — and a comparison that reads them as different
|
|
366
|
+
* makes *every* stored block look edited. That is not a cosmetic difference:
|
|
367
|
+
* failing the content match drops the block to the positional pass, which
|
|
368
|
+
* keeps the block's own key but reissues every span key beneath it, so a
|
|
369
|
+
* publish rewrites prose nobody touched. It is the same mistake as a required
|
|
370
|
+
* prop holding `undefined`, one level down.
|
|
371
|
+
*
|
|
372
|
+
* An empty span goes the same way. It holds no text, so the pivot has no node
|
|
373
|
+
* to put it in — ProseMirror has no empty text node — and it can never be
|
|
374
|
+
* rebuilt. Dropping it from the stored side too lets the block match and be
|
|
375
|
+
* re-emitted *as the stored object*, which is what actually preserves it.
|
|
376
|
+
*
|
|
377
|
+
* Only `_type: "block"` gets the field projection. For every other type the
|
|
378
|
+
* object *is* the content — a code block's `code` and `language` are the whole
|
|
379
|
+
* value — so those compare whole, minus the key.
|
|
380
|
+
*/
|
|
381
|
+
function canonicalBlock(block) {
|
|
382
|
+
if (block._type !== "block")
|
|
383
|
+
return omitDeep(block, ["_key"]);
|
|
384
|
+
const order = new Map();
|
|
385
|
+
for (const def of block.markDefs ?? []) {
|
|
386
|
+
if (!order.has(def._key))
|
|
387
|
+
order.set(def._key, `a${order.size}`);
|
|
388
|
+
}
|
|
389
|
+
const rewritten = {
|
|
390
|
+
_type: block._type,
|
|
391
|
+
style: block.style,
|
|
392
|
+
listItem: block.listItem,
|
|
393
|
+
level: block.level,
|
|
394
|
+
markDefs: (block.markDefs ?? []).map((def) => ({ ...def, _key: order.get(def._key) ?? def._key })),
|
|
395
|
+
children: (block.children ?? [])
|
|
396
|
+
.filter((span) => span._type !== "span" || (typeof span.text === "string" && span.text.length > 0))
|
|
397
|
+
.map(({ marks, ...span }) => ({
|
|
398
|
+
...span,
|
|
399
|
+
...(marks && marks.length > 0 ? { marks: marks.map((m) => order.get(m) ?? m) } : {})
|
|
400
|
+
}))
|
|
401
|
+
};
|
|
402
|
+
if (rewritten.markDefs?.length === 0)
|
|
403
|
+
delete rewritten.markDefs;
|
|
404
|
+
return omitDeep(rewritten, ["_key"]);
|
|
405
|
+
}
|
|
406
|
+
/** Convert the pivot document back to an array of Portable Text blocks. */
|
|
407
|
+
export function toPortableText(doc, options = {}) {
|
|
408
|
+
const mint = createKeyMint(options.keyPrefix ?? "avo");
|
|
409
|
+
const next = (doc.content ?? []).flatMap((node) => nodeToBlocks(node, mint));
|
|
410
|
+
const previous = Array.isArray(options.previous) ? options.previous.filter(isObject) : [];
|
|
411
|
+
if (previous.length === 0)
|
|
412
|
+
return next;
|
|
413
|
+
return mergeByIdentity(next, previous, {
|
|
414
|
+
canonical: canonicalBlock,
|
|
415
|
+
// An edited block keeps the identity *and* the fields the pivot could not
|
|
416
|
+
// carry — editing the prose must not strip a schema field off the block.
|
|
417
|
+
adopt: (rebuilt, stored) => ({
|
|
418
|
+
...(stored._type === "block" ? unrepresentedFields(stored) : {}),
|
|
419
|
+
...rebuilt,
|
|
420
|
+
...(stored._key ? { _key: stored._key } : {})
|
|
421
|
+
}),
|
|
422
|
+
matches: (rebuilt, stored) => rebuilt._type === stored._type && rebuilt.listItem === stored.listItem
|
|
423
|
+
});
|
|
424
|
+
}
|
package/dist/strapi.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strapi 5 Blocks <-> the pivot document.
|
|
3
|
+
*
|
|
4
|
+
* Strapi's blocks field is Slate-shaped: a flat array of block objects, each
|
|
5
|
+
* with a `children` array, and formatting expressed as *boolean props on the
|
|
6
|
+
* text node* (`bold: true`) rather than as a list of marks. Links are inline
|
|
7
|
+
* nodes wrapping their text, the way Contentful does it, so the same
|
|
8
|
+
* gather-adjacent-text logic applies on the way out.
|
|
9
|
+
*
|
|
10
|
+
* Nesting is real here — a `list` may appear inside another list's children —
|
|
11
|
+
* which makes lists the one place Strapi is closer to the pivot than Portable
|
|
12
|
+
* Text is. Like Contentful and unlike Sanity, nothing carries a key, so a write
|
|
13
|
+
* replaces the field and there is nothing to merge against.
|
|
14
|
+
*
|
|
15
|
+
* The vocabulary has no thematic break. A `horizontalRule` in the pivot has
|
|
16
|
+
* nowhere to go and is dropped rather than faked as a paragraph of dashes.
|
|
17
|
+
*/
|
|
18
|
+
import { type RichTextDoc } from "./doc.ts";
|
|
19
|
+
export type StrapiText = {
|
|
20
|
+
type: "text";
|
|
21
|
+
text: string;
|
|
22
|
+
bold?: boolean;
|
|
23
|
+
italic?: boolean;
|
|
24
|
+
underline?: boolean;
|
|
25
|
+
strikethrough?: boolean;
|
|
26
|
+
code?: boolean;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
};
|
|
29
|
+
export type StrapiNode = {
|
|
30
|
+
type: string;
|
|
31
|
+
children?: StrapiNode[];
|
|
32
|
+
level?: number;
|
|
33
|
+
format?: string;
|
|
34
|
+
url?: string;
|
|
35
|
+
text?: string;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
};
|
|
38
|
+
/** Convert a Strapi Blocks value into the pivot document. */
|
|
39
|
+
export declare function fromStrapiBlocks(blocks: unknown): RichTextDoc;
|
|
40
|
+
/** Convert the pivot document back to a Strapi Blocks value. */
|
|
41
|
+
export declare function toStrapiBlocks(doc: RichTextDoc): StrapiNode[];
|