@avocadostudio-ai/richtext 0.5.1 → 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 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.5.1",
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 and Strapi Blocks",
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",