@morphika/andami 0.2.4 → 0.2.6

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.
@@ -80,6 +80,16 @@ export function isSectionBlockType(type: string): boolean {
80
80
  return SECTION_BLOCK_TYPES.has(type);
81
81
  }
82
82
 
83
+ /** Check if a V2 section contains a single section-level block (e.g. projectGridBlock).
84
+ * These sections behave like top-level content items: no column management,
85
+ * selecting the section auto-selects the block. */
86
+ export function isSectionBlockSection(section: { columns?: Array<{ blocks?: Array<{ _type: string }> }> }): boolean {
87
+ const cols = section.columns || [];
88
+ if (cols.length !== 1) return false;
89
+ const blocks = cols[0].blocks || [];
90
+ return blocks.length === 1 && SECTION_BLOCK_TYPES.has(blocks[0]._type);
91
+ }
92
+
83
93
  export type SectionType = "empty-v2" | "parallaxGroup" | SectionBlockType;
84
94
 
85
95
  export interface SectionTypeInfo {
@@ -0,0 +1,2 @@
1
+ export { portableTextToTiptap } from "./portableToTiptap";
2
+ export { tiptapToPortableText } from "./tiptapToPortable";
@@ -0,0 +1,156 @@
1
+ /**
2
+ * portableToTiptap.ts
3
+ *
4
+ * Converts Sanity Portable Text blocks → Tiptap JSON document.
5
+ * Used when loading existing content into the RichTextEditor.
6
+ *
7
+ * Supported mappings:
8
+ * Block styles: normal → paragraph, h1–h4 → heading, blockquote → blockquote
9
+ * Decorators: strong → bold, em → italic, underline → underline, strike-through → strike
10
+ * Annotations: link (href, blank) → link mark with href + target
11
+ */
12
+
13
+ import type { PortableTextContent, PortableTextBlock } from "../sanity/types";
14
+
15
+ // ── Tiptap JSON node types ──────────────────────────────────────────
16
+
17
+ interface TiptapMark {
18
+ type: string;
19
+ attrs?: Record<string, unknown>;
20
+ }
21
+
22
+ interface TiptapNode {
23
+ type: string;
24
+ attrs?: Record<string, unknown>;
25
+ content?: TiptapNode[];
26
+ text?: string;
27
+ marks?: TiptapMark[];
28
+ }
29
+
30
+ interface TiptapDoc {
31
+ type: "doc";
32
+ content: TiptapNode[];
33
+ }
34
+
35
+ // ── Mark mapping ────────────────────────────────────────────────────
36
+
37
+ const DECORATOR_MAP: Record<string, string> = {
38
+ strong: "bold",
39
+ em: "italic",
40
+ underline: "underline",
41
+ "strike-through": "strike",
42
+ };
43
+
44
+ // ── Block style → Tiptap node ───────────────────────────────────────
45
+
46
+ function blockStyleToNode(style: string | undefined): { type: string; attrs?: Record<string, unknown> } {
47
+ switch (style) {
48
+ case "h1": return { type: "heading", attrs: { level: 1 } };
49
+ case "h2": return { type: "heading", attrs: { level: 2 } };
50
+ case "h3": return { type: "heading", attrs: { level: 3 } };
51
+ case "h4": return { type: "heading", attrs: { level: 4 } };
52
+ case "blockquote": return { type: "blockquote" };
53
+ default: return { type: "paragraph" };
54
+ }
55
+ }
56
+
57
+ // ── Span → Tiptap text nodes ────────────────────────────────────────
58
+
59
+ function convertSpan(
60
+ span: { text?: string; marks?: string[] },
61
+ markDefs: PortableTextBlock["markDefs"]
62
+ ): TiptapNode | null {
63
+ const text = span.text ?? "";
64
+ // Tiptap doesn't support empty text nodes well — skip them
65
+ if (!text) return null;
66
+
67
+ const marks: TiptapMark[] = [];
68
+
69
+ for (const mark of span.marks ?? []) {
70
+ // Check decorators first
71
+ if (DECORATOR_MAP[mark]) {
72
+ marks.push({ type: DECORATOR_MAP[mark] });
73
+ continue;
74
+ }
75
+ // Check if it's a markDef reference (e.g. link annotation)
76
+ const def = markDefs?.find((d) => d._key === mark);
77
+ if (def && def._type === "link") {
78
+ marks.push({
79
+ type: "link",
80
+ attrs: {
81
+ href: (def.href as string) || "",
82
+ target: (def.blank as boolean) ? "_blank" : null,
83
+ rel: (def.blank as boolean) ? "noopener noreferrer" : null,
84
+ class: null,
85
+ },
86
+ });
87
+ }
88
+ // Color annotation → Tiptap textStyle mark
89
+ if (def && def._type === "color") {
90
+ marks.push({
91
+ type: "textStyle",
92
+ attrs: { color: (def.hex as string) || null },
93
+ });
94
+ }
95
+ }
96
+
97
+ return {
98
+ type: "text",
99
+ text,
100
+ ...(marks.length > 0 ? { marks } : {}),
101
+ };
102
+ }
103
+
104
+ // ── Block → Tiptap node ─────────────────────────────────────────────
105
+
106
+ function convertBlock(block: PortableTextBlock): TiptapNode {
107
+ const { type, attrs } = blockStyleToNode(block.style);
108
+ const children = block.children ?? [];
109
+
110
+ const textNodes = children
111
+ .map((child) => convertSpan(child, block.markDefs))
112
+ .filter((n): n is TiptapNode => n !== null);
113
+
114
+ // Blockquote wraps a paragraph in Tiptap
115
+ if (type === "blockquote") {
116
+ return {
117
+ type: "blockquote",
118
+ content: [
119
+ {
120
+ type: "paragraph",
121
+ content: textNodes.length > 0 ? textNodes : undefined,
122
+ },
123
+ ],
124
+ };
125
+ }
126
+
127
+ return {
128
+ type,
129
+ ...(attrs ? { attrs } : {}),
130
+ ...(textNodes.length > 0 ? { content: textNodes } : {}),
131
+ };
132
+ }
133
+
134
+ // ── Main export ─────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * Convert Sanity Portable Text content to a Tiptap JSON document.
138
+ * Returns a valid Tiptap `doc` node ready for `editor.commands.setContent()`.
139
+ */
140
+ export function portableTextToTiptap(content: PortableTextContent | undefined | null): TiptapDoc {
141
+ if (!content || content.length === 0) {
142
+ return {
143
+ type: "doc",
144
+ content: [{ type: "paragraph" }],
145
+ };
146
+ }
147
+
148
+ const nodes = content
149
+ .filter((block) => block._type === "block")
150
+ .map(convertBlock);
151
+
152
+ return {
153
+ type: "doc",
154
+ content: nodes.length > 0 ? nodes : [{ type: "paragraph" }],
155
+ };
156
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * tiptapToPortable.ts
3
+ *
4
+ * Converts Tiptap JSON document → Sanity Portable Text blocks.
5
+ * Used when saving edits from the RichTextEditor back to the builder store.
6
+ *
7
+ * Supported mappings:
8
+ * paragraph → style "normal"
9
+ * heading (level 1–4) → style "h1"–"h4"
10
+ * blockquote → style "blockquote"
11
+ * bold → decorator "strong"
12
+ * italic → decorator "em"
13
+ * underline → decorator "underline"
14
+ * strike → decorator "strike-through"
15
+ * link (href, target) → link annotation with href + blank
16
+ */
17
+
18
+ import type { PortableTextBlock, PortableTextContent } from "../sanity/types";
19
+
20
+ // ── Tiptap JSON types (minimal) ─────────────────────────────────────
21
+
22
+ interface TiptapMark {
23
+ type: string;
24
+ attrs?: Record<string, unknown>;
25
+ }
26
+
27
+ interface TiptapNode {
28
+ type: string;
29
+ attrs?: Record<string, unknown>;
30
+ content?: TiptapNode[];
31
+ text?: string;
32
+ marks?: TiptapMark[];
33
+ }
34
+
35
+ // ── Key generation ──────────────────────────────────────────────────
36
+
37
+ let _counter = 0;
38
+
39
+ function genKey(prefix: string): string {
40
+ _counter += 1;
41
+ return `${prefix}_${Date.now().toString(36)}_${_counter.toString(36)}`;
42
+ }
43
+
44
+ /** Reset counter — useful for deterministic test output. */
45
+ export function _resetKeyCounter(): void {
46
+ _counter = 0;
47
+ }
48
+
49
+ // ── Mark mapping (Tiptap → Sanity) ──────────────────────────────────
50
+
51
+ const MARK_MAP: Record<string, string> = {
52
+ bold: "strong",
53
+ italic: "em",
54
+ underline: "underline",
55
+ strike: "strike-through",
56
+ };
57
+
58
+ // ── Convert text node marks ─────────────────────────────────────────
59
+
60
+ interface MarkResult {
61
+ /** Decorator mark names to add to the span's `marks` array */
62
+ decorators: string[];
63
+ /** Link markDef to collect (if any) */
64
+ linkDef: { _key: string; _type: "link"; href: string; blank?: boolean } | null;
65
+ /** The _key of the link markDef to add to the span's marks array */
66
+ linkKey: string | null;
67
+ /** Color markDef to collect (if any) */
68
+ colorDef: { _key: string; _type: "color"; hex: string } | null;
69
+ /** The _key of the color markDef to add to the span's marks array */
70
+ colorKey: string | null;
71
+ }
72
+
73
+ function processMarks(
74
+ marks: TiptapMark[] | undefined,
75
+ existingLinkDefs: Map<string, string>,
76
+ existingColorDefs: Map<string, string>
77
+ ): MarkResult {
78
+ const decorators: string[] = [];
79
+ let linkDef: MarkResult["linkDef"] = null;
80
+ let linkKey: string | null = null;
81
+ let colorDef: MarkResult["colorDef"] = null;
82
+ let colorKey: string | null = null;
83
+
84
+ if (!marks) return { decorators, linkDef, linkKey, colorDef, colorKey };
85
+
86
+ for (const mark of marks) {
87
+ // Decorator marks
88
+ if (MARK_MAP[mark.type]) {
89
+ decorators.push(MARK_MAP[mark.type]);
90
+ continue;
91
+ }
92
+
93
+ // Link annotation
94
+ if (mark.type === "link" && mark.attrs) {
95
+ const href = (mark.attrs.href as string) || "";
96
+ const blank = mark.attrs.target === "_blank";
97
+
98
+ // Reuse existing markDef key for identical links to reduce duplication
99
+ const cacheKey = `${href}|${blank}`;
100
+ if (existingLinkDefs.has(cacheKey)) {
101
+ linkKey = existingLinkDefs.get(cacheKey)!;
102
+ } else {
103
+ const key = genKey("lnk");
104
+ linkDef = { _key: key, _type: "link", href, ...(blank ? { blank: true } : {}) };
105
+ linkKey = key;
106
+ existingLinkDefs.set(cacheKey, key);
107
+ }
108
+ }
109
+
110
+ // Color annotation (from Tiptap textStyle mark)
111
+ if (mark.type === "textStyle" && mark.attrs?.color) {
112
+ const hex = mark.attrs.color as string;
113
+
114
+ // Reuse existing markDef key for identical colors
115
+ if (existingColorDefs.has(hex)) {
116
+ colorKey = existingColorDefs.get(hex)!;
117
+ } else {
118
+ const key = genKey("clr");
119
+ colorDef = { _key: key, _type: "color", hex };
120
+ colorKey = key;
121
+ existingColorDefs.set(hex, key);
122
+ }
123
+ }
124
+ }
125
+
126
+ return { decorators, linkDef, linkKey, colorDef, colorKey };
127
+ }
128
+
129
+ // ── Block style from Tiptap node type ───────────────────────────────
130
+
131
+ function nodeToBlockStyle(node: TiptapNode): string {
132
+ if (node.type === "heading" && node.attrs?.level) {
133
+ return `h${node.attrs.level}`;
134
+ }
135
+ if (node.type === "blockquote") return "blockquote";
136
+ return "normal";
137
+ }
138
+
139
+ // ── Flatten blockquote content ──────────────────────────────────────
140
+
141
+ /**
142
+ * Tiptap blockquotes wrap paragraph nodes. We need to extract the
143
+ * text nodes from the inner paragraph.
144
+ */
145
+ function flattenBlockquoteContent(node: TiptapNode): TiptapNode[] {
146
+ if (!node.content) return [];
147
+ // Collect text nodes from all inner paragraphs
148
+ const textNodes: TiptapNode[] = [];
149
+ for (const child of node.content) {
150
+ if (child.type === "paragraph" && child.content) {
151
+ textNodes.push(...child.content);
152
+ } else if (child.text) {
153
+ textNodes.push(child);
154
+ }
155
+ }
156
+ return textNodes;
157
+ }
158
+
159
+ // ── Convert a single block node ─────────────────────────────────────
160
+
161
+ function convertBlockNode(node: TiptapNode): PortableTextBlock {
162
+ const style = nodeToBlockStyle(node);
163
+ const blockKey = genKey("blk");
164
+ const linkDefsMap = new Map<string, string>();
165
+ const colorDefsMap = new Map<string, string>();
166
+
167
+ // Get text nodes — flatten if blockquote
168
+ const textNodes = node.type === "blockquote"
169
+ ? flattenBlockquoteContent(node)
170
+ : (node.content ?? []).filter((n) => n.type === "text");
171
+
172
+ const children: PortableTextBlock["children"] = [];
173
+ const markDefs: PortableTextBlock["markDefs"] = [];
174
+
175
+ for (const textNode of textNodes) {
176
+ if (textNode.type !== "text" || !textNode.text) continue;
177
+
178
+ const { decorators, linkDef, linkKey, colorDef, colorKey } = processMarks(textNode.marks, linkDefsMap, colorDefsMap);
179
+
180
+ if (linkDef) {
181
+ markDefs!.push(linkDef as PortableTextBlock["markDefs"] extends (infer U)[] | undefined ? U : never);
182
+ }
183
+ if (colorDef) {
184
+ markDefs!.push(colorDef as PortableTextBlock["markDefs"] extends (infer U)[] | undefined ? U : never);
185
+ }
186
+
187
+ const spanMarks = [...decorators];
188
+ if (linkKey) spanMarks.push(linkKey);
189
+ if (colorKey) spanMarks.push(colorKey);
190
+
191
+ children!.push({
192
+ _type: "span",
193
+ _key: genKey("sp"),
194
+ text: textNode.text,
195
+ marks: spanMarks,
196
+ });
197
+ }
198
+
199
+ // Ensure at least one empty child so Sanity doesn't reject the block
200
+ if (children!.length === 0) {
201
+ children!.push({
202
+ _type: "span",
203
+ _key: genKey("sp"),
204
+ text: "",
205
+ marks: [],
206
+ });
207
+ }
208
+
209
+ return {
210
+ _type: "block",
211
+ _key: blockKey,
212
+ style,
213
+ markDefs: markDefs as PortableTextBlock["markDefs"],
214
+ children,
215
+ };
216
+ }
217
+
218
+ // ── Main export ─────────────────────────────────────────────────────
219
+
220
+ /**
221
+ * Convert a Tiptap JSON document to Sanity Portable Text content.
222
+ * Accepts the Tiptap editor's `getJSON()` output.
223
+ */
224
+ export function tiptapToPortableText(doc: { type: string; content?: TiptapNode[] }): PortableTextContent {
225
+ if (!doc.content || doc.content.length === 0) {
226
+ return [
227
+ {
228
+ _type: "block",
229
+ _key: genKey("blk"),
230
+ style: "normal",
231
+ markDefs: [],
232
+ children: [{ _type: "span", _key: genKey("sp"), text: "", marks: [] }],
233
+ },
234
+ ];
235
+ }
236
+
237
+ return doc.content.map(convertBlockNode);
238
+ }