@platecms/delta-cast 0.6.0 → 0.8.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.
@@ -0,0 +1,390 @@
1
+ import { Content, Parent, Root } from "./schemas/schema";
2
+
3
+ /**
4
+ * Formatting node priority for nesting order (lower number = higher priority)
5
+ * This constant should be used across all packages to ensure consistent formatting hierarchy.
6
+ */
7
+ export const FORMATTING_PRIORITY = {
8
+ bold: 1,
9
+ italic: 2,
10
+ underline: 3,
11
+ strikethrough: 4,
12
+ highlight: 5,
13
+ } as const;
14
+
15
+ /**
16
+ * Type guard to check if a node has children
17
+ */
18
+ function hasChildren(node: Content | Parent): node is Content & { children: Content[] } {
19
+ return "children" in node && Array.isArray((node as { children: unknown }).children);
20
+ }
21
+
22
+ /**
23
+ * Type guard to check if a node is a formatting node
24
+ */
25
+ function isFormattingNode(node: Content): boolean {
26
+ const formattingTypes = ["bold", "italic", "underline", "strikethrough", "highlight"];
27
+ return formattingTypes.includes(node.type);
28
+ }
29
+
30
+ /**
31
+ * Determines if two nodes can be merged
32
+ */
33
+ function canMerge(node1: Content, node2: Content): boolean {
34
+ // Formatting nodes: merge if same type and mergeable
35
+ const mergeableTypes = ["bold", "italic", "underline", "strikethrough", "highlight", "text"];
36
+ if (mergeableTypes.includes(node1.type) && node1.type === node2.type) {
37
+ return true;
38
+ }
39
+
40
+ return false;
41
+ }
42
+
43
+ /**
44
+ * Merges two nodes by combining their content
45
+ */
46
+ function mergeNodes(source: Content, target: Content): void {
47
+ if (source.type === "text" && target.type === "text") {
48
+ // Concatenate text values
49
+ target.value = source.value + target.value;
50
+ } else if (source.type === target.type && hasChildren(source) && hasChildren(target)) {
51
+ // Combine children arrays for formatting nodes
52
+ const sourceWithChildren = source as Content & { children: Content[] };
53
+ const targetWithChildren = target as Content & { children: Content[] };
54
+ targetWithChildren.children = [...sourceWithChildren.children, ...targetWithChildren.children];
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Determines if a child formatting node should be above its parent in the hierarchy
60
+ */
61
+ function shouldChildBeAboveParent(childType: string, parentType: string): boolean {
62
+ const childPriority = FORMATTING_PRIORITY[childType as keyof typeof FORMATTING_PRIORITY] || 999;
63
+ const parentPriority = FORMATTING_PRIORITY[parentType as keyof typeof FORMATTING_PRIORITY] || 999;
64
+
65
+ return childPriority < parentPriority;
66
+ }
67
+
68
+ /**
69
+ * Splits a parent node into separate nodes while preserving other grandparent children.
70
+ * Gets all the siblings before and after the child, and creates at most two new parent nodes which are inserted
71
+ * before and after the original parent node in the grandparent node, containing the original before and after children respectively.
72
+ * Example:
73
+ * Input:
74
+ * {
75
+ * type: 'italic',
76
+ * children: [
77
+ * { type: 'text', value: 'Another parent' },
78
+ * { type: 'bold', children: [
79
+ * { type: 'text', value: 'Before' },
80
+ * { type: 'text', value: 'Child node' },
81
+ * { type: 'text', value: 'After' }
82
+ * ] },
83
+ * { type: 'text', value: 'Even more parents' }
84
+ * ]
85
+ * }
86
+ * Output:
87
+ * {
88
+ * type: 'italic',
89
+ * children: [
90
+ * { type: 'text', value: 'Another parent' },
91
+ * { type: 'bold', children: [
92
+ * { type: 'text', value: 'Before' },
93
+ * ] },
94
+ * { type: 'bold', children: [
95
+ * { type: 'text', value: 'Child node' },
96
+ * ] },
97
+ * { type: 'bold', children: [
98
+ * { type: 'text', value: 'After' },
99
+ * ] },
100
+ * { type: 'text', value: 'Even more parents' }
101
+ * ]
102
+ * }
103
+ * @param grandParent
104
+ * @param parent
105
+ * @param child
106
+ * @param parentIndexInGrandParent
107
+ * @param childIndexInParent
108
+ * @returns The number of new parent nodes inserted before the original parent node
109
+ */
110
+ function splitParentIntoSeparateNodes(
111
+ grandParent: Parent,
112
+ parent: Parent,
113
+ child: Parent,
114
+ parentIndexInGrandParent: number,
115
+ childIndexInParent: number,
116
+ ): number {
117
+ const siblingsBeforeChild = parent.children.slice(0, childIndexInParent);
118
+ const siblingsAfterChild = parent.children.slice(childIndexInParent + 1);
119
+
120
+ let insertionOffset = 0;
121
+ if (siblingsBeforeChild.length > 0) {
122
+ const newBeforeParent: Parent = { ...parent, children: siblingsBeforeChild };
123
+ grandParent.children.splice(parentIndexInGrandParent, 0, newBeforeParent as Content);
124
+ insertionOffset = 1;
125
+ }
126
+
127
+ if (siblingsAfterChild.length > 0) {
128
+ const newAfterParent: Parent = { ...parent, children: siblingsAfterChild };
129
+ grandParent.children.splice(parentIndexInGrandParent + 1 + insertionOffset, 0, newAfterParent as Content);
130
+ }
131
+
132
+ parent.children = [child as Content];
133
+
134
+ return insertionOffset;
135
+ }
136
+
137
+ /**
138
+ * Processes a formatting child to determine if it should be flipped with its parent.
139
+ * This assumes that both the parent and child are formatting nodes.
140
+ */
141
+ function processFormattingChild(
142
+ grandParent: Parent,
143
+ parent: Parent,
144
+ child: Parent,
145
+ parentIndexInGrandParent: number,
146
+ childIndexInParent: number,
147
+ ): boolean {
148
+ if (!shouldChildBeAboveParent(child.type, parent.type)) {
149
+ return false;
150
+ }
151
+
152
+ let parentIndexInGrandParentOffset = 0;
153
+ if (parent.children.length > 1) {
154
+ parentIndexInGrandParentOffset += splitParentIntoSeparateNodes(
155
+ grandParent,
156
+ parent,
157
+ child,
158
+ parentIndexInGrandParent,
159
+ childIndexInParent,
160
+ );
161
+ }
162
+
163
+ parent.children = child.children;
164
+ child.children = [parent as Content];
165
+ grandParent.children[parentIndexInGrandParent + parentIndexInGrandParentOffset] = child as Content;
166
+ return true;
167
+ }
168
+
169
+ /**
170
+ * Single pass of the bubble sort algorithm for formatting nodes
171
+ */
172
+ function bubbleFormattingNodes(node: Parent, parent?: Parent, nodeIndexInParent?: number): boolean {
173
+ let hasChanges = false;
174
+
175
+ if (!hasChildren(node)) {
176
+ return hasChanges;
177
+ }
178
+
179
+ // DFS: process children first
180
+ for (let i = 0; i < node.children.length; i++) {
181
+ const child = node.children[i];
182
+ if (hasChildren(child)) {
183
+ hasChanges = bubbleFormattingNodes(child, node, i) || hasChanges;
184
+ }
185
+ }
186
+
187
+ // If not a formatting node, return
188
+ if (!isFormattingNode(node)) {
189
+ return hasChanges;
190
+ }
191
+
192
+ // If no parent is provided, we are at the root, and we don't need to check for formatting nodes
193
+ if (!parent || nodeIndexInParent === undefined) {
194
+ return hasChanges;
195
+ }
196
+
197
+ for (let i = 0; i < node.children.length; i++) {
198
+ const child = node.children[i];
199
+ if (isFormattingNode(child) && hasChildren(child)) {
200
+ hasChanges = processFormattingChild(parent, node, child, nodeIndexInParent, i) || hasChanges;
201
+ }
202
+ }
203
+
204
+ return hasChanges;
205
+ }
206
+
207
+ /**
208
+ * Determines if a node should be removed because it's empty
209
+ */
210
+ function shouldRemoveEmptyNode(node: Content): boolean {
211
+ // Only remove empty formatting (inline) nodes
212
+ if (!isFormattingNode(node)) {
213
+ return false;
214
+ }
215
+
216
+ // For formatting nodes with children, check if they're empty
217
+ if (!hasChildren(node) || node.children.length === 0) {
218
+ return true;
219
+ }
220
+
221
+ return false;
222
+ }
223
+
224
+ /**
225
+ * Merges adjacent nodes that can be combined (text nodes and same-type formatting nodes)
226
+ * Example:
227
+ * Input:
228
+ * {
229
+ * type: 'root',
230
+ * children: [
231
+ * { type: 'text', value: 'Hello' },
232
+ * { type: 'text', value: ' world' }
233
+ * ]
234
+ * }
235
+ * Output:
236
+ * {
237
+ * type: 'root',
238
+ * children: [
239
+ * { type: 'text', value: 'Hello world' }
240
+ * ]
241
+ * }
242
+ */
243
+ function mergeAdjacentNodes(node: Parent): boolean {
244
+ let hasChanges = false;
245
+
246
+ // Process current level
247
+ for (let i = 0; i < node.children.length - 1; i++) {
248
+ const current = node.children[i];
249
+ const next = node.children[i + 1];
250
+
251
+ if (canMerge(current, next)) {
252
+ // Merge current into next, remove current
253
+ mergeNodes(current, next);
254
+ node.children.splice(i, 1);
255
+ hasChanges = true;
256
+ i--; // Check the same position again since we removed an element
257
+ }
258
+ }
259
+
260
+ // Recursively process children
261
+ for (const child of node.children) {
262
+ if (hasChildren(child)) {
263
+ hasChanges = mergeAdjacentNodes(child) || hasChanges;
264
+ }
265
+ }
266
+
267
+ return hasChanges;
268
+ }
269
+
270
+ /**
271
+ * Reorders formatting nodes to follow the standard hierarchy using bubble sort approach
272
+ */
273
+ function reorderFormattingNodes(node: Parent): boolean {
274
+ let hasChanges = true;
275
+ let anyChanges = false;
276
+
277
+ // Keep bubbling until no more changes (like bubble sort)
278
+ while (hasChanges) {
279
+ hasChanges = false;
280
+ hasChanges = bubbleFormattingNodes(node) || hasChanges;
281
+ if (hasChanges) {
282
+ anyChanges = true;
283
+ }
284
+ }
285
+
286
+ return anyChanges;
287
+ }
288
+
289
+ /**
290
+ * Removes duplicate formatting nodes using bottom-up consolidation approach
291
+ */
292
+ function removeDuplicateFormatting(
293
+ node: Parent,
294
+ ancestorsFormattingNodeTypes: string[],
295
+ parent?: Parent,
296
+ nodeIndexInParent?: number,
297
+ ): boolean {
298
+ let hasChanges = false;
299
+
300
+ if (!hasChildren(node)) {
301
+ return hasChanges;
302
+ }
303
+
304
+ // Create new array with current node's type if it's a formatting node
305
+ const newAncestorsFormattingNodeTypes = isFormattingNode(node)
306
+ ? [...ancestorsFormattingNodeTypes, node.type]
307
+ : ancestorsFormattingNodeTypes;
308
+
309
+ // DFS: process children first
310
+ for (let i = 0; i < node.children.length; i++) {
311
+ const child = node.children[i];
312
+ if (hasChildren(child)) {
313
+ hasChanges = removeDuplicateFormatting(child, newAncestorsFormattingNodeTypes, node, i) || hasChanges;
314
+ }
315
+ }
316
+
317
+ if (!parent || nodeIndexInParent === undefined) {
318
+ return hasChanges;
319
+ }
320
+
321
+ // Then process current level for duplicate formatting
322
+ if (ancestorsFormattingNodeTypes.includes(node.type)) {
323
+ parent.children.splice(nodeIndexInParent, 1, ...node.children);
324
+ hasChanges = true;
325
+ }
326
+
327
+ return hasChanges;
328
+ }
329
+
330
+ /**
331
+ * Removes empty nodes from the CAST tree
332
+ */
333
+ function cleanupEmptyNodes(node: Parent): boolean {
334
+ let hasChanges = false;
335
+
336
+ if (!hasChildren(node)) {
337
+ return hasChanges;
338
+ }
339
+
340
+ // DFS: process children first
341
+ for (let i = node.children.length - 1; i >= 0; i--) {
342
+ const child = node.children[i];
343
+ if (hasChildren(child)) {
344
+ hasChanges = cleanupEmptyNodes(child) || hasChanges;
345
+ }
346
+ }
347
+
348
+ // Then process current level - remove empty children
349
+ // Iterate backwards to avoid index issues when removing items
350
+ for (let i = node.children.length - 1; i >= 0; i--) {
351
+ const child = node.children[i];
352
+
353
+ if (shouldRemoveEmptyNode(child)) {
354
+ node.children.splice(i, 1);
355
+ hasChanges = true;
356
+ }
357
+ }
358
+
359
+ return hasChanges;
360
+ }
361
+ /**
362
+ * Normalizes a CAST tree to ensure it is in a consistent and predictable state.
363
+ * This function applies various normalization rules to merge equivalent nodes,
364
+ * reorder formatting, and clean up empty structures.
365
+ *
366
+ * @param root - The CAST root node to normalize
367
+ * @returns A normalized copy of the CAST tree
368
+ */
369
+ export function normalizeCast(root: Root): Root {
370
+ let hasChanges = true;
371
+ const currentRoot = JSON.parse(JSON.stringify(root)) as Root;
372
+
373
+ while (hasChanges) {
374
+ hasChanges = false;
375
+
376
+ // Pass 1: Merge adjacent nodes
377
+ hasChanges = mergeAdjacentNodes(currentRoot) || hasChanges;
378
+
379
+ // Pass 2: Reorder formatting nodes
380
+ hasChanges = reorderFormattingNodes(currentRoot) || hasChanges;
381
+
382
+ // Pass 3: Remove duplicate formatting
383
+ hasChanges = removeDuplicateFormatting(currentRoot, []) || hasChanges;
384
+
385
+ // Pass 4: Clean up empty nodes
386
+ hasChanges = cleanupEmptyNodes(currentRoot) || hasChanges;
387
+ }
388
+
389
+ return currentRoot;
390
+ }
@@ -603,3 +603,4 @@
603
603
  },
604
604
  "description": "The root node of a CAST document. The unist tree starts here. Each CAST document has one root node.\n\n```json\n{\n \"type\": \"root\",\n \"children\": [\n ...\n ]\n}"
605
605
  }
606
+
@@ -0,0 +1,234 @@
1
+ import { Node, Literal as UnistLiteral, Parent as UnistParent } from "unist";
2
+
3
+ export type Content = BlockContent | InlineContent;
4
+
5
+ export type BlockContent = BlockContentMap[keyof BlockContentMap];
6
+ export type InlineContent = InlineContentMap[keyof InlineContentMap];
7
+
8
+ export interface BlockContentMap {
9
+ paragraph: Paragraph;
10
+ heading: Heading;
11
+ list: List;
12
+ // List item should not be used anywhere except in the list's children
13
+ listItem: ListItem;
14
+ blockquote: Blockquote;
15
+ code: Code;
16
+ }
17
+
18
+ export interface InlineContentMap {
19
+ text: Text;
20
+ bold: Bold;
21
+ italic: Italic;
22
+ underline: Underline;
23
+ strikethrough: Strikethrough;
24
+ inlineCode: InlineCode;
25
+ highlight: Highlight;
26
+ contentValue: InterpolatedContentValue;
27
+ externalLink: ExternalLink;
28
+ internalLink: InternalLink;
29
+ }
30
+
31
+ export interface Parent extends UnistParent {
32
+ children: Content[];
33
+ }
34
+
35
+ export interface Literal extends UnistLiteral {
36
+ value: string;
37
+ }
38
+
39
+ /*
40
+ ** Specific CAST Definitions
41
+ */
42
+
43
+ /**
44
+ * The root node of a CAST document. The unist tree starts here. Each CAST document has one root node.
45
+ *
46
+ * ```json
47
+ * {
48
+ * "type": "root",
49
+ * "children": [
50
+ * ...
51
+ * ]
52
+ * }
53
+ */
54
+ export interface Root extends Parent {
55
+ type: "root";
56
+ }
57
+
58
+ /*
59
+ ** Block Content
60
+ */
61
+ export interface Paragraph extends Parent {
62
+ type: "paragraph";
63
+
64
+ /**
65
+ * The children of a paragraph are inline content.
66
+ */
67
+ children: InlineContent[];
68
+ }
69
+
70
+ export interface Heading extends Parent {
71
+ type: "heading";
72
+
73
+ /**
74
+ * The level of a heading is a number between 1 and 6.
75
+ */
76
+ level: 1 | 2 | 3 | 4 | 5 | 6;
77
+
78
+ /**
79
+ * The children of a heading are inline content.
80
+ */
81
+ children: InlineContent[];
82
+ }
83
+
84
+ export interface List extends Parent {
85
+ type: "list";
86
+
87
+ children: ListItem[];
88
+
89
+ /**
90
+ * Whether the list is ordered or not.
91
+ */
92
+ ordered: boolean;
93
+
94
+ /**
95
+ * The start of an ordered list.
96
+ */
97
+ start?: number;
98
+ }
99
+
100
+ export interface ListItem extends Parent {
101
+ type: "listItem";
102
+
103
+ /**
104
+ * The children of a list item are inline content.
105
+ */
106
+ children: InlineContent[];
107
+ }
108
+
109
+ export interface Blockquote extends Parent {
110
+ type: "blockquote";
111
+
112
+ /**
113
+ * The children of a blockquote are inline content.
114
+ */
115
+ children: InlineContent[];
116
+ }
117
+
118
+ export interface Code extends Parent {
119
+ type: "code";
120
+
121
+ /**
122
+ * The language of the code block.
123
+ */
124
+ lang?: string;
125
+
126
+ children: Text[];
127
+ }
128
+
129
+ /*
130
+ ** Inline Content
131
+ */
132
+ export interface Text extends Literal {
133
+ type: "text";
134
+ }
135
+
136
+ export interface Bold extends Parent {
137
+ type: "bold";
138
+
139
+ /**
140
+ * The children of a bold node are inline content.
141
+ */
142
+ children: InlineContent[];
143
+ }
144
+
145
+ export interface Italic extends Parent {
146
+ type: "italic";
147
+
148
+ /**
149
+ * The children of an italic node are inline content.
150
+ */
151
+ children: InlineContent[];
152
+ }
153
+
154
+ export interface Underline extends Parent {
155
+ type: "underline";
156
+
157
+ /**
158
+ * The children of an underline node are inline content.
159
+ */
160
+ children: InlineContent[];
161
+ }
162
+
163
+ export interface Strikethrough extends Parent {
164
+ type: "strikethrough";
165
+
166
+ /**
167
+ * The children of a strikethrough node are inline content.
168
+ */
169
+ children: InlineContent[];
170
+ }
171
+
172
+ export interface InlineCode extends Literal {
173
+ type: "inlineCode";
174
+ }
175
+
176
+ export interface Highlight extends Parent {
177
+ type: "highlight";
178
+
179
+ /**
180
+ * The children of a highlight node are inline content
181
+ */
182
+ children: InlineContent[];
183
+ }
184
+
185
+ /**
186
+ * Represents an interpolated Content Value. Interpolated values are values that are stored elsewhere in the database
187
+ * and inserted into the document at request-time.
188
+ */
189
+ export interface InterpolatedContentValue extends Node {
190
+ type: "contentValue";
191
+
192
+ /**
193
+ * The PRN (identifier) of the referenced Content Value.
194
+ */
195
+ prn: string;
196
+
197
+ /**
198
+ * The nested CAST tree of the referenced Content Value.
199
+ */
200
+ value?: Root;
201
+ }
202
+
203
+ export interface ExternalLink extends Parent {
204
+ type: "externalLink";
205
+
206
+ children: Text[];
207
+
208
+ /**
209
+ * The URL of the link.
210
+ */
211
+ url: string;
212
+
213
+ target?: "_parent" | "_top" | "blank" | "self";
214
+ }
215
+
216
+ export interface InternalLink extends Parent {
217
+ type: "internalLink";
218
+
219
+ children: Text[];
220
+
221
+ /**
222
+ * The PRN (identifier) of the referenced Path Part or Grid Placement.
223
+ */
224
+ prn: string;
225
+
226
+ /**
227
+ * The URL of the link. Consists of the channel and the path part. Optionally includes a grid placement reference (e.g. #anchor-name).
228
+ * Even though the URL is marked as optional, it is always defined when retrieving an internal link.
229
+ * It is not required when saving an internal link.
230
+ */
231
+ url?: string;
232
+
233
+ target?: "_parent" | "_top" | "blank" | "self";
234
+ }