@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/strapi.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
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 { MARK, NODE } from "./doc.js";
|
|
19
|
+
/** The boolean formatting props, and the pivot marks they are. */
|
|
20
|
+
const FLAG_TO_MARK = {
|
|
21
|
+
bold: MARK.bold,
|
|
22
|
+
italic: MARK.italic,
|
|
23
|
+
underline: MARK.underline,
|
|
24
|
+
strikethrough: MARK.strike,
|
|
25
|
+
code: MARK.code
|
|
26
|
+
};
|
|
27
|
+
const MARK_TO_FLAG = Object.fromEntries(Object.entries(FLAG_TO_MARK).map(([flag, mark]) => [mark, flag]));
|
|
28
|
+
function isObject(value) {
|
|
29
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Strapi -> document
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
function textToPivot(node, extraMarks = []) {
|
|
35
|
+
const text = typeof node.text === "string" ? node.text : "";
|
|
36
|
+
if (text.length === 0)
|
|
37
|
+
return null;
|
|
38
|
+
const marks = [];
|
|
39
|
+
for (const [flag, mark] of Object.entries(FLAG_TO_MARK)) {
|
|
40
|
+
if (node[flag] === true)
|
|
41
|
+
marks.push({ type: mark });
|
|
42
|
+
}
|
|
43
|
+
marks.push(...extraMarks);
|
|
44
|
+
return { type: NODE.text, text, ...(marks.length > 0 ? { marks } : {}) };
|
|
45
|
+
}
|
|
46
|
+
function inlineToPivot(nodes) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const node of nodes ?? []) {
|
|
49
|
+
if (node.type === "text") {
|
|
50
|
+
const converted = textToPivot(node);
|
|
51
|
+
if (converted)
|
|
52
|
+
out.push(converted);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (node.type === "link") {
|
|
56
|
+
const href = typeof node.url === "string" ? node.url : "";
|
|
57
|
+
for (const child of node.children ?? []) {
|
|
58
|
+
const converted = textToPivot(child, [{ type: MARK.link, attrs: { href } }]);
|
|
59
|
+
if (converted)
|
|
60
|
+
out.push(converted);
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
out.push({ type: NODE.unknown, attrs: { data: node } });
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function listToPivot(node) {
|
|
69
|
+
const type = node.format === "ordered" ? NODE.orderedList : NODE.bulletList;
|
|
70
|
+
const items = [];
|
|
71
|
+
for (const child of node.children ?? []) {
|
|
72
|
+
if (child.type === "list") {
|
|
73
|
+
// A nested list belongs to the item above it. With no item above it — a
|
|
74
|
+
// malformed value — it becomes an item of its own so the content stays.
|
|
75
|
+
const nested = listToPivot(child);
|
|
76
|
+
const parent = items[items.length - 1];
|
|
77
|
+
if (parent)
|
|
78
|
+
parent.content = [...(parent.content ?? []), nested];
|
|
79
|
+
else
|
|
80
|
+
items.push({ type: NODE.listItem, content: [nested] });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const content = inlineToPivot(child.children);
|
|
84
|
+
items.push({
|
|
85
|
+
type: NODE.listItem,
|
|
86
|
+
content: [content.length > 0 ? { type: NODE.paragraph, content } : { type: NODE.paragraph }]
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return { type, content: items };
|
|
90
|
+
}
|
|
91
|
+
function blockToPivot(node) {
|
|
92
|
+
switch (node.type) {
|
|
93
|
+
case "paragraph": {
|
|
94
|
+
const content = inlineToPivot(node.children);
|
|
95
|
+
return [content.length > 0 ? { type: NODE.paragraph, content } : { type: NODE.paragraph }];
|
|
96
|
+
}
|
|
97
|
+
case "heading": {
|
|
98
|
+
const level = typeof node.level === "number" ? Math.min(6, Math.max(1, node.level)) : 2;
|
|
99
|
+
const content = inlineToPivot(node.children);
|
|
100
|
+
const base = { type: NODE.heading, attrs: { level } };
|
|
101
|
+
return [content.length > 0 ? { ...base, content } : base];
|
|
102
|
+
}
|
|
103
|
+
case "list":
|
|
104
|
+
return [listToPivot(node)];
|
|
105
|
+
case "quote": {
|
|
106
|
+
const content = inlineToPivot(node.children);
|
|
107
|
+
return [
|
|
108
|
+
{
|
|
109
|
+
type: NODE.blockquote,
|
|
110
|
+
content: [content.length > 0 ? { type: NODE.paragraph, content } : { type: NODE.paragraph }]
|
|
111
|
+
}
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
case "code": {
|
|
115
|
+
const code = (node.children ?? []).map((c) => (typeof c.text === "string" ? c.text : "")).join("");
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
type: NODE.codeBlock,
|
|
119
|
+
attrs: { language: typeof node.language === "string" ? node.language : null },
|
|
120
|
+
...(code.length > 0 ? { content: [{ type: NODE.text, text: code }] } : {})
|
|
121
|
+
}
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
default:
|
|
125
|
+
return [{ type: NODE.unknown, attrs: { data: node } }];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Convert a Strapi Blocks value into the pivot document. */
|
|
129
|
+
export function fromStrapiBlocks(blocks) {
|
|
130
|
+
if (!Array.isArray(blocks))
|
|
131
|
+
return { type: "doc", content: [] };
|
|
132
|
+
return { type: "doc", content: blocks.filter(isObject).flatMap(blockToPivot) };
|
|
133
|
+
}
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// document -> Strapi
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
function pivotTextToStrapi(node) {
|
|
138
|
+
const out = { type: "text", text: node.text ?? "" };
|
|
139
|
+
for (const mark of node.marks ?? []) {
|
|
140
|
+
const flag = MARK_TO_FLAG[mark.type];
|
|
141
|
+
if (flag)
|
|
142
|
+
out[flag] = true;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
function inlineToStrapi(nodes) {
|
|
147
|
+
const out = [];
|
|
148
|
+
let openLink;
|
|
149
|
+
for (const node of nodes ?? []) {
|
|
150
|
+
if (node.type === NODE.unknown && isObject(node.attrs?.data)) {
|
|
151
|
+
openLink = undefined;
|
|
152
|
+
out.push(node.attrs.data);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (node.type === NODE.hardBreak) {
|
|
156
|
+
const target = openLink ? openLink.node.children : out;
|
|
157
|
+
const last = target?.[target.length - 1];
|
|
158
|
+
if (last && typeof last.text === "string")
|
|
159
|
+
last.text += "\n";
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (typeof node.text !== "string" || node.text.length === 0)
|
|
163
|
+
continue;
|
|
164
|
+
const link = node.marks?.find((m) => m.type === MARK.link);
|
|
165
|
+
const href = typeof link?.attrs?.href === "string" ? link.attrs.href : undefined;
|
|
166
|
+
if (href === undefined) {
|
|
167
|
+
openLink = undefined;
|
|
168
|
+
out.push(pivotTextToStrapi(node));
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!openLink || openLink.href !== href) {
|
|
172
|
+
openLink = { href, node: { type: "link", url: href, children: [] } };
|
|
173
|
+
out.push(openLink.node);
|
|
174
|
+
}
|
|
175
|
+
openLink.node.children.push(pivotTextToStrapi(node));
|
|
176
|
+
}
|
|
177
|
+
// Strapi rejects an empty children array; every block needs at least one
|
|
178
|
+
// (possibly empty) text node.
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
function withChildren(node, children) {
|
|
182
|
+
return { ...node, children: children.length > 0 ? children : [{ type: "text", text: "" }] };
|
|
183
|
+
}
|
|
184
|
+
function listToStrapi(node) {
|
|
185
|
+
const children = [];
|
|
186
|
+
for (const item of node.content ?? []) {
|
|
187
|
+
const parts = item.content ?? [];
|
|
188
|
+
const paragraphs = parts.filter((p) => p.type !== NODE.bulletList && p.type !== NODE.orderedList);
|
|
189
|
+
const nested = parts.filter((p) => p.type === NODE.bulletList || p.type === NODE.orderedList);
|
|
190
|
+
children.push(withChildren({ type: "list-item" }, paragraphs.flatMap((p) => inlineToStrapi(p.content))));
|
|
191
|
+
for (const child of nested)
|
|
192
|
+
children.push(listToStrapi(child));
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
type: "list",
|
|
196
|
+
format: node.type === NODE.orderedList ? "ordered" : "unordered",
|
|
197
|
+
children
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function nodeToStrapi(node) {
|
|
201
|
+
switch (node.type) {
|
|
202
|
+
case NODE.paragraph:
|
|
203
|
+
return [withChildren({ type: "paragraph" }, inlineToStrapi(node.content))];
|
|
204
|
+
case NODE.heading: {
|
|
205
|
+
const level = typeof node.attrs?.level === "number" ? Math.min(6, Math.max(1, node.attrs.level)) : 2;
|
|
206
|
+
return [withChildren({ type: "heading", level }, inlineToStrapi(node.content))];
|
|
207
|
+
}
|
|
208
|
+
case NODE.bulletList:
|
|
209
|
+
case NODE.orderedList:
|
|
210
|
+
return [listToStrapi(node)];
|
|
211
|
+
case NODE.blockquote:
|
|
212
|
+
// Strapi's `quote` holds inline children, not blocks, so a multi-paragraph
|
|
213
|
+
// quote becomes one quote per paragraph rather than losing the split.
|
|
214
|
+
return (node.content ?? []).map((child) => withChildren({ type: "quote" }, inlineToStrapi(child.content)));
|
|
215
|
+
case NODE.codeBlock: {
|
|
216
|
+
const code = (node.content ?? []).map((c) => c.text ?? "").join("");
|
|
217
|
+
return [
|
|
218
|
+
{
|
|
219
|
+
type: "code",
|
|
220
|
+
...(typeof node.attrs?.language === "string" ? { language: node.attrs.language } : {}),
|
|
221
|
+
children: [{ type: "text", text: code }]
|
|
222
|
+
}
|
|
223
|
+
];
|
|
224
|
+
}
|
|
225
|
+
case NODE.horizontalRule:
|
|
226
|
+
return [];
|
|
227
|
+
case NODE.unknown:
|
|
228
|
+
return isObject(node.attrs?.data) ? [node.attrs.data] : [];
|
|
229
|
+
default:
|
|
230
|
+
return (node.content ?? []).flatMap(nodeToStrapi);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Convert the pivot document back to a Strapi Blocks value. */
|
|
234
|
+
export function toStrapiBlocks(doc) {
|
|
235
|
+
return (doc.content ?? []).flatMap(nodeToStrapi);
|
|
236
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@avocadostudio-ai/richtext",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/avocadostudio-ai/avocado.git",
|
|
18
|
+
"directory": "packages/richtext"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"registry": "https://registry.npmjs.org",
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"tsx": "^4.21.0",
|
|
29
|
+
"typescript": "^5.7.3"
|
|
30
|
+
},
|
|
31
|
+
"description": "The Avocado Studio rich-text grammar and converters for Contentful, Sanity Portable Text and Strapi Blocks",
|
|
32
|
+
"keywords": [
|
|
33
|
+
"richtext",
|
|
34
|
+
"markdown",
|
|
35
|
+
"prosemirror",
|
|
36
|
+
"portable-text",
|
|
37
|
+
"contentful",
|
|
38
|
+
"strapi"
|
|
39
|
+
],
|
|
40
|
+
"license": "Apache-2.0",
|
|
41
|
+
"homepage": "https://github.com/avocadostudio-ai/avocado/tree/main/packages/richtext#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/avocadostudio-ai/avocado/issues"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.build.json",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"test": "NODE_ENV=test node ../../scripts/run-tests.mjs"
|
|
49
|
+
}
|
|
50
|
+
}
|