@avocadostudio-ai/richtext 0.5.0 → 0.6.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/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/storyblok.d.ts +66 -0
- package/dist/storyblok.js +135 -0
- package/package.json +4 -3
package/dist/index.d.ts
CHANGED
|
@@ -21,3 +21,4 @@ export { deepEqual, omitDeep, mergeByIdentity, mergeRichTextDoc } from "./merge.
|
|
|
21
21
|
export { fromPortableText, toPortableText, type PortableTextBlock, type PortableTextSpan, type PortableTextMarkDef, type ToPortableTextOptions } from "./portable-text.ts";
|
|
22
22
|
export { fromContentful, toContentful, type ContentfulDocument, type ContentfulNode, type ContentfulMark } from "./contentful.ts";
|
|
23
23
|
export { fromStrapiBlocks, toStrapiBlocks, type StrapiNode, type StrapiText } from "./strapi.ts";
|
|
24
|
+
export { fromStoryblok, toStoryblok, isStoryblokRichText, isEmptyRichText, type StoryblokRichText } from "./storyblok.ts";
|
package/dist/index.js
CHANGED
|
@@ -21,3 +21,4 @@ export { deepEqual, omitDeep, mergeByIdentity, mergeRichTextDoc } from "./merge.
|
|
|
21
21
|
export { fromPortableText, toPortableText } from "./portable-text.js";
|
|
22
22
|
export { fromContentful, toContentful } from "./contentful.js";
|
|
23
23
|
export { fromStrapiBlocks, toStrapiBlocks } from "./strapi.js";
|
|
24
|
+
export { fromStoryblok, toStoryblok, isStoryblokRichText, isEmptyRichText } from "./storyblok.js";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storyblok rich text <-> the pivot document.
|
|
3
|
+
*
|
|
4
|
+
* Both are ProseMirror documents, which is the whole reason this file is
|
|
5
|
+
* short — and the reason it is embarrassing that it was the missing one. This
|
|
6
|
+
* package calls the ProseMirror document "the pivot every CMS converts to and
|
|
7
|
+
* from", ships converters for Portable Text, Contentful and Strapi, all three
|
|
8
|
+
* of which are a different shape, and had nothing for the one CMS whose native
|
|
9
|
+
* rich text already *is* the pivot. An integrator arriving with Storyblok
|
|
10
|
+
* either rewrites these ninety lines or, much more likely, flattens the field
|
|
11
|
+
* to a string and loses every mark, link and list on the first publish.
|
|
12
|
+
*
|
|
13
|
+
* The differences are three, and all mechanical:
|
|
14
|
+
*
|
|
15
|
+
* 1. Node naming. Storyblok writes `bullet_list`, the pivot writes
|
|
16
|
+
* `bulletList`; same for ordered_list / list_item / code_block /
|
|
17
|
+
* horizontal_rule / hard_break. Everything else — `paragraph`,
|
|
18
|
+
* `heading`, `blockquote`, `text`, and every mark the pivot models —
|
|
19
|
+
* is spelled identically in both.
|
|
20
|
+
* 2. `blok`: a Storyblok node that embeds a whole component inside prose.
|
|
21
|
+
* The pivot has a slot for exactly this, `avocadoUnknownBlock`, which
|
|
22
|
+
* carries the source object under `attrs.data` and re-emits it unchanged.
|
|
23
|
+
* Storyblok keeps the same payload under `attrs.body`, so the conversion
|
|
24
|
+
* is a move, not a projection — nothing about the embedded component is
|
|
25
|
+
* interpreted, which is what lets it survive a trip through the panel.
|
|
26
|
+
* 3. Marks Storyblok has and the pivot does not model — `styled`,
|
|
27
|
+
* `highlight`, `textStyle`, `anchor`, `subscript`, `superscript`. They
|
|
28
|
+
* are carried through untouched rather than dropped; a span keeps its
|
|
29
|
+
* unknown marks unless the span itself is edited.
|
|
30
|
+
*
|
|
31
|
+
* Round-trip is the contract: for any value in the space,
|
|
32
|
+
* `toStoryblok(fromStoryblok(doc))` must equal `doc`. This implementation was
|
|
33
|
+
* verified that way over 559 documents in a production tri-lingual space
|
|
34
|
+
* before it was moved here.
|
|
35
|
+
*/
|
|
36
|
+
import { type RichTextDoc, type RichTextNode } from "./doc.ts";
|
|
37
|
+
/** A Storyblok rich-text value, as the Delivery API returns it. */
|
|
38
|
+
export type StoryblokRichText = {
|
|
39
|
+
type: string;
|
|
40
|
+
content?: unknown[];
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
};
|
|
43
|
+
export declare function isStoryblokRichText(value: unknown): value is StoryblokRichText;
|
|
44
|
+
/**
|
|
45
|
+
* True when the document holds no text and embeds nothing — Storyblok's
|
|
46
|
+
* "empty" rich text, which is a `doc` with an empty paragraph in it rather
|
|
47
|
+
* than an absent value. Worth asking before writing: publishing an empty
|
|
48
|
+
* document over a field the CMS considers unset is a change, and it will show
|
|
49
|
+
* up in the diff of every page nobody edited.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isEmptyRichText(value: unknown): boolean;
|
|
52
|
+
/** Storyblok rich text -> the ProseMirror document the property panel edits. */
|
|
53
|
+
export declare function fromStoryblok(doc: unknown): RichTextDoc;
|
|
54
|
+
/**
|
|
55
|
+
* The edited document -> Storyblok's rich text, ready to store.
|
|
56
|
+
*
|
|
57
|
+
* A plain string is accepted and wrapped in a paragraph. It should not happen
|
|
58
|
+
* — the manifest pins the prop's schema to `type: "doc"`, and the panel gives
|
|
59
|
+
* it a rich-text editor — but a planner adding a *new* list row writes the
|
|
60
|
+
* value it would write for any other text field, and a string arriving at a
|
|
61
|
+
* document field used to convert to an empty document. The row saved, the card
|
|
62
|
+
* drew its title, and the sentence underneath it was gone, with nothing
|
|
63
|
+
* logged.
|
|
64
|
+
*/
|
|
65
|
+
export declare function toStoryblok(doc: RichTextDoc | unknown): StoryblokRichText;
|
|
66
|
+
export type { RichTextDoc, RichTextNode };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storyblok rich text <-> the pivot document.
|
|
3
|
+
*
|
|
4
|
+
* Both are ProseMirror documents, which is the whole reason this file is
|
|
5
|
+
* short — and the reason it is embarrassing that it was the missing one. This
|
|
6
|
+
* package calls the ProseMirror document "the pivot every CMS converts to and
|
|
7
|
+
* from", ships converters for Portable Text, Contentful and Strapi, all three
|
|
8
|
+
* of which are a different shape, and had nothing for the one CMS whose native
|
|
9
|
+
* rich text already *is* the pivot. An integrator arriving with Storyblok
|
|
10
|
+
* either rewrites these ninety lines or, much more likely, flattens the field
|
|
11
|
+
* to a string and loses every mark, link and list on the first publish.
|
|
12
|
+
*
|
|
13
|
+
* The differences are three, and all mechanical:
|
|
14
|
+
*
|
|
15
|
+
* 1. Node naming. Storyblok writes `bullet_list`, the pivot writes
|
|
16
|
+
* `bulletList`; same for ordered_list / list_item / code_block /
|
|
17
|
+
* horizontal_rule / hard_break. Everything else — `paragraph`,
|
|
18
|
+
* `heading`, `blockquote`, `text`, and every mark the pivot models —
|
|
19
|
+
* is spelled identically in both.
|
|
20
|
+
* 2. `blok`: a Storyblok node that embeds a whole component inside prose.
|
|
21
|
+
* The pivot has a slot for exactly this, `avocadoUnknownBlock`, which
|
|
22
|
+
* carries the source object under `attrs.data` and re-emits it unchanged.
|
|
23
|
+
* Storyblok keeps the same payload under `attrs.body`, so the conversion
|
|
24
|
+
* is a move, not a projection — nothing about the embedded component is
|
|
25
|
+
* interpreted, which is what lets it survive a trip through the panel.
|
|
26
|
+
* 3. Marks Storyblok has and the pivot does not model — `styled`,
|
|
27
|
+
* `highlight`, `textStyle`, `anchor`, `subscript`, `superscript`. They
|
|
28
|
+
* are carried through untouched rather than dropped; a span keeps its
|
|
29
|
+
* unknown marks unless the span itself is edited.
|
|
30
|
+
*
|
|
31
|
+
* Round-trip is the contract: for any value in the space,
|
|
32
|
+
* `toStoryblok(fromStoryblok(doc))` must equal `doc`. This implementation was
|
|
33
|
+
* verified that way over 559 documents in a production tri-lingual space
|
|
34
|
+
* before it was moved here.
|
|
35
|
+
*/
|
|
36
|
+
import { NODE } from "./doc.js";
|
|
37
|
+
/** Storyblok's node name on the left, the pivot's on the right. */
|
|
38
|
+
const NODE_IN = {
|
|
39
|
+
bullet_list: NODE.bulletList,
|
|
40
|
+
ordered_list: NODE.orderedList,
|
|
41
|
+
list_item: NODE.listItem,
|
|
42
|
+
code_block: NODE.codeBlock,
|
|
43
|
+
horizontal_rule: NODE.horizontalRule,
|
|
44
|
+
hard_break: NODE.hardBreak,
|
|
45
|
+
blok: NODE.unknown
|
|
46
|
+
};
|
|
47
|
+
const NODE_OUT = Object.fromEntries(Object.entries(NODE_IN).map(([storyblok, pivot]) => [pivot, storyblok]));
|
|
48
|
+
export function isStoryblokRichText(value) {
|
|
49
|
+
return (!!value &&
|
|
50
|
+
typeof value === "object" &&
|
|
51
|
+
!Array.isArray(value) &&
|
|
52
|
+
value.type === "doc");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* True when the document holds no text and embeds nothing — Storyblok's
|
|
56
|
+
* "empty" rich text, which is a `doc` with an empty paragraph in it rather
|
|
57
|
+
* than an absent value. Worth asking before writing: publishing an empty
|
|
58
|
+
* document over a field the CMS considers unset is a change, and it will show
|
|
59
|
+
* up in the diff of every page nobody edited.
|
|
60
|
+
*/
|
|
61
|
+
export function isEmptyRichText(value) {
|
|
62
|
+
if (!isStoryblokRichText(value))
|
|
63
|
+
return true;
|
|
64
|
+
return !hasContent(value.content);
|
|
65
|
+
}
|
|
66
|
+
function hasContent(nodes) {
|
|
67
|
+
if (!Array.isArray(nodes))
|
|
68
|
+
return false;
|
|
69
|
+
for (const node of nodes) {
|
|
70
|
+
if (!isObject(node))
|
|
71
|
+
continue;
|
|
72
|
+
if (node.type === "blok" || node.type === NODE.unknown)
|
|
73
|
+
return true;
|
|
74
|
+
if (typeof node.text === "string" && node.text !== "")
|
|
75
|
+
return true;
|
|
76
|
+
if (hasContent(node.content))
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
function isObject(value) {
|
|
82
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
83
|
+
}
|
|
84
|
+
function convert(node, map, toStoryblokDirection) {
|
|
85
|
+
if (!isObject(node))
|
|
86
|
+
return node;
|
|
87
|
+
const sourceType = typeof node.type === "string" ? node.type : undefined;
|
|
88
|
+
const out = { ...node };
|
|
89
|
+
if (sourceType !== undefined)
|
|
90
|
+
out.type = map[sourceType] ?? sourceType;
|
|
91
|
+
/*
|
|
92
|
+
* `blok` carries its payload in `attrs.body`; the pivot's unknown-block slot
|
|
93
|
+
* carries it in `attrs.data`. Moving it is what makes the node survive the
|
|
94
|
+
* editor, which renders an unknown block read-only rather than deleting it.
|
|
95
|
+
*/
|
|
96
|
+
if (!toStoryblokDirection && sourceType === "blok") {
|
|
97
|
+
out.attrs = { data: isObject(node.attrs) ? node.attrs : {} };
|
|
98
|
+
}
|
|
99
|
+
else if (toStoryblokDirection && sourceType === NODE.unknown) {
|
|
100
|
+
const attrs = isObject(node.attrs) ? node.attrs : {};
|
|
101
|
+
out.attrs = isObject(attrs.data) ? attrs.data : {};
|
|
102
|
+
}
|
|
103
|
+
if (Array.isArray(node.content)) {
|
|
104
|
+
out.content = node.content.map((child) => convert(child, map, toStoryblokDirection));
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/** Storyblok rich text -> the ProseMirror document the property panel edits. */
|
|
109
|
+
export function fromStoryblok(doc) {
|
|
110
|
+
if (!isStoryblokRichText(doc))
|
|
111
|
+
return { type: "doc", content: [] };
|
|
112
|
+
return convert(doc, NODE_IN, false);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The edited document -> Storyblok's rich text, ready to store.
|
|
116
|
+
*
|
|
117
|
+
* A plain string is accepted and wrapped in a paragraph. It should not happen
|
|
118
|
+
* — the manifest pins the prop's schema to `type: "doc"`, and the panel gives
|
|
119
|
+
* it a rich-text editor — but a planner adding a *new* list row writes the
|
|
120
|
+
* value it would write for any other text field, and a string arriving at a
|
|
121
|
+
* document field used to convert to an empty document. The row saved, the card
|
|
122
|
+
* drew its title, and the sentence underneath it was gone, with nothing
|
|
123
|
+
* logged.
|
|
124
|
+
*/
|
|
125
|
+
export function toStoryblok(doc) {
|
|
126
|
+
if (typeof doc === "string") {
|
|
127
|
+
const text = doc.trim();
|
|
128
|
+
if (text === "")
|
|
129
|
+
return { type: "doc", content: [] };
|
|
130
|
+
return { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: doc }] }] };
|
|
131
|
+
}
|
|
132
|
+
if (!isObject(doc))
|
|
133
|
+
return { type: "doc", content: [] };
|
|
134
|
+
return convert(doc, NODE_OUT, true);
|
|
135
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/richtext",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -28,14 +28,15 @@
|
|
|
28
28
|
"tsx": "^4.21.0",
|
|
29
29
|
"typescript": "^5.7.3"
|
|
30
30
|
},
|
|
31
|
-
"description": "The Avocado Studio rich-text grammar and converters for Contentful, Sanity Portable Text
|
|
31
|
+
"description": "The Avocado Studio rich-text grammar and converters for Contentful, Sanity Portable Text, Strapi Blocks and Storyblok",
|
|
32
32
|
"keywords": [
|
|
33
33
|
"richtext",
|
|
34
34
|
"markdown",
|
|
35
35
|
"prosemirror",
|
|
36
36
|
"portable-text",
|
|
37
37
|
"contentful",
|
|
38
|
-
"strapi"
|
|
38
|
+
"strapi",
|
|
39
|
+
"storyblok"
|
|
39
40
|
],
|
|
40
41
|
"license": "Apache-2.0",
|
|
41
42
|
"homepage": "https://docs.avocadostudio.dev",
|