@bendyline/squisq 0.1.1

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.
Files changed (102) hide show
  1. package/dist/Types-sh2VRxfo.d.ts +15 -0
  2. package/dist/chunk-7UDSRZKG.js +103 -0
  3. package/dist/chunk-7UDSRZKG.js.map +1 -0
  4. package/dist/chunk-O5HCIUAD.js +577 -0
  5. package/dist/chunk-O5HCIUAD.js.map +1 -0
  6. package/dist/chunk-PJ7AODIM.js +3040 -0
  7. package/dist/chunk-PJ7AODIM.js.map +1 -0
  8. package/dist/chunk-QWVRN6I4.js +81 -0
  9. package/dist/chunk-QWVRN6I4.js.map +1 -0
  10. package/dist/chunk-S4F2RY37.js +92 -0
  11. package/dist/chunk-S4F2RY37.js.map +1 -0
  12. package/dist/chunk-URU6QMLY.js +888 -0
  13. package/dist/chunk-URU6QMLY.js.map +1 -0
  14. package/dist/chunk-VJN7UB2Z.js +145 -0
  15. package/dist/chunk-VJN7UB2Z.js.map +1 -0
  16. package/dist/doc/index.d.ts +3 -0
  17. package/dist/doc/index.js +126 -0
  18. package/dist/doc/index.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.js +217 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/markdown/index.d.ts +237 -0
  23. package/dist/markdown/index.js +33 -0
  24. package/dist/markdown/index.js.map +1 -0
  25. package/dist/schemas/index.d.ts +70 -0
  26. package/dist/schemas/index.js +61 -0
  27. package/dist/schemas/index.js.map +1 -0
  28. package/dist/spatial/index.d.ts +111 -0
  29. package/dist/spatial/index.js +25 -0
  30. package/dist/spatial/index.js.map +1 -0
  31. package/dist/storage/index.d.ts +91 -0
  32. package/dist/storage/index.js +11 -0
  33. package/dist/storage/index.js.map +1 -0
  34. package/dist/story/index.d.ts +691 -0
  35. package/dist/story/index.js +126 -0
  36. package/dist/story/index.js.map +1 -0
  37. package/dist/themeLibrary-DySHPcgj.d.ts +1352 -0
  38. package/dist/types-DHiv_Pnm.d.ts +496 -0
  39. package/package.json +91 -0
  40. package/src/__tests__/animationUtils.test.ts +77 -0
  41. package/src/__tests__/geohash.test.ts +109 -0
  42. package/src/__tests__/getLayers.test.ts +200 -0
  43. package/src/__tests__/haversine.test.ts +63 -0
  44. package/src/__tests__/localForageAdapter.test.ts +155 -0
  45. package/src/__tests__/markdown.test.ts +806 -0
  46. package/src/__tests__/markdownToDoc.test.ts +374 -0
  47. package/src/__tests__/storage.test.ts +60 -0
  48. package/src/__tests__/templates.test.ts +247 -0
  49. package/src/doc/docToMarkdown.ts +98 -0
  50. package/src/doc/getLayers.ts +152 -0
  51. package/src/doc/index.ts +8 -0
  52. package/src/doc/markdownToDoc.ts +277 -0
  53. package/src/doc/templates/accentImage.ts +338 -0
  54. package/src/doc/templates/captionUtils.ts +33 -0
  55. package/src/doc/templates/comparisonBar.ts +140 -0
  56. package/src/doc/templates/coverBlock.ts +156 -0
  57. package/src/doc/templates/dateEvent.ts +122 -0
  58. package/src/doc/templates/definitionCard.ts +129 -0
  59. package/src/doc/templates/factCard.ts +114 -0
  60. package/src/doc/templates/fullBleedQuote.ts +59 -0
  61. package/src/doc/templates/imageWithCaption.ts +167 -0
  62. package/src/doc/templates/index.ts +571 -0
  63. package/src/doc/templates/listBlock.ts +101 -0
  64. package/src/doc/templates/mapBlock.ts +132 -0
  65. package/src/doc/templates/persistentLayers.ts +522 -0
  66. package/src/doc/templates/photoGrid.ts +199 -0
  67. package/src/doc/templates/pullQuote.ts +119 -0
  68. package/src/doc/templates/quoteBlock.ts +112 -0
  69. package/src/doc/templates/sectionHeader.ts +105 -0
  70. package/src/doc/templates/statHighlight.ts +113 -0
  71. package/src/doc/templates/titleBlock.ts +100 -0
  72. package/src/doc/templates/twoColumn.ts +239 -0
  73. package/src/doc/templates/videoPullQuote.ts +128 -0
  74. package/src/doc/templates/videoWithCaption.ts +126 -0
  75. package/src/doc/utils/animationUtils.ts +135 -0
  76. package/src/doc/utils/themeUtils.ts +139 -0
  77. package/src/index.ts +5 -0
  78. package/src/markdown/convert.ts +897 -0
  79. package/src/markdown/htmlParse.ts +212 -0
  80. package/src/markdown/index.ts +110 -0
  81. package/src/markdown/parse.ts +103 -0
  82. package/src/markdown/stringify.ts +122 -0
  83. package/src/markdown/types.ts +605 -0
  84. package/src/markdown/utils.ts +165 -0
  85. package/src/ngeohash.d.ts +11 -0
  86. package/src/schemas/BlockTemplates.ts +706 -0
  87. package/src/schemas/Doc.ts +565 -0
  88. package/src/schemas/LayoutStrategy.ts +178 -0
  89. package/src/schemas/MediaProvider.ts +70 -0
  90. package/src/schemas/Theme.ts +235 -0
  91. package/src/schemas/Types.ts +15 -0
  92. package/src/schemas/Viewport.ts +91 -0
  93. package/src/schemas/index.ts +14 -0
  94. package/src/schemas/themeLibrary.ts +492 -0
  95. package/src/spatial/Geohash.ts +168 -0
  96. package/src/spatial/Haversine.ts +59 -0
  97. package/src/spatial/index.ts +2 -0
  98. package/src/storage/LocalForageAdapter.ts +103 -0
  99. package/src/storage/LocalStorageAdapter.ts +57 -0
  100. package/src/storage/MemoryStorageAdapter.ts +37 -0
  101. package/src/storage/Storage.ts +28 -0
  102. package/src/storage/index.ts +5 -0
@@ -0,0 +1,897 @@
1
+ /**
2
+ * mdast ↔ Squisq Markdown Node Conversion
3
+ *
4
+ * Converts between the remark/mdast AST format and the squisq
5
+ * MarkdownDocument JSON format. This is the bridge layer that allows
6
+ * us to use the battle-tested unified/remark ecosystem for parsing
7
+ * and serialization while exposing our own clean, well-typed interfaces.
8
+ *
9
+ * The conversion is designed for perfect round-tripping:
10
+ * toMdast(fromMdast(tree)) should produce an equivalent mdast tree.
11
+ */
12
+
13
+ import type {
14
+ MarkdownDocument,
15
+ MarkdownBlockNode,
16
+ MarkdownInlineNode,
17
+ MarkdownListItem,
18
+ MarkdownTableRow,
19
+ MarkdownTableCell,
20
+ MarkdownSourcePosition,
21
+ HeadingTemplateAnnotation,
22
+ MarkdownHeading,
23
+ } from './types.js';
24
+ import { parseHtmlToNodes } from './htmlParse.js';
25
+
26
+ // ============================================
27
+ // Generic mdast node shape
28
+ // ============================================
29
+
30
+ /**
31
+ * Minimal interface for mdast/unist nodes.
32
+ * Using a generic shape avoids hard dependency on @types/mdast
33
+ * and handles extension nodes (GFM, math, directive) uniformly.
34
+ */
35
+ interface MdastPosition {
36
+ start: { line: number; column: number; offset?: number };
37
+ end: { line: number; column: number; offset?: number };
38
+ }
39
+
40
+ interface MdastNode {
41
+ type: string;
42
+ position?: MdastPosition;
43
+ children?: MdastNode[];
44
+ value?: string;
45
+ depth?: number;
46
+ ordered?: boolean | null;
47
+ start?: number | null;
48
+ spread?: boolean | null;
49
+ checked?: boolean | null;
50
+ lang?: string | null;
51
+ meta?: string | null;
52
+ url?: string;
53
+ title?: string | null;
54
+ alt?: string;
55
+ identifier?: string;
56
+ label?: string;
57
+ referenceType?: string;
58
+ align?: (string | null)[];
59
+ name?: string;
60
+ attributes?: Record<string, string>;
61
+ data?: Record<string, unknown>;
62
+ }
63
+
64
+ // ============================================
65
+ // Position conversion
66
+ // ============================================
67
+
68
+ function convertPosition(pos?: MdastPosition): MarkdownSourcePosition | undefined {
69
+ if (!pos) return undefined;
70
+ return {
71
+ start: {
72
+ line: pos.start.line,
73
+ column: pos.start.column,
74
+ ...(pos.start.offset != null ? { offset: pos.start.offset } : {}),
75
+ },
76
+ end: {
77
+ line: pos.end.line,
78
+ column: pos.end.column,
79
+ ...(pos.end.offset != null ? { offset: pos.end.offset } : {}),
80
+ },
81
+ };
82
+ }
83
+
84
+ function toMdastPosition(pos: MarkdownSourcePosition): MdastPosition {
85
+ return {
86
+ start: {
87
+ line: pos.start.line,
88
+ column: pos.start.column,
89
+ ...(pos.start.offset != null ? { offset: pos.start.offset } : {}),
90
+ },
91
+ end: {
92
+ line: pos.end.line,
93
+ column: pos.end.column,
94
+ ...(pos.end.offset != null ? { offset: pos.end.offset } : {}),
95
+ },
96
+ };
97
+ }
98
+
99
+ /** Conditionally include position in node. */
100
+ function posField(
101
+ pos?: MarkdownSourcePosition,
102
+ ): { position: MarkdownSourcePosition } | Record<string, never> {
103
+ return pos ? { position: pos } : {};
104
+ }
105
+
106
+ function mdastPosField(
107
+ pos?: MarkdownSourcePosition,
108
+ ): { position: MdastPosition } | Record<string, never> {
109
+ return pos ? { position: toMdastPosition(pos) } : {};
110
+ }
111
+
112
+ // ============================================
113
+ // mdast → Squisq (fromMdast)
114
+ // ============================================
115
+
116
+ /**
117
+ * Extract plain text content from an mdast node tree.
118
+ * Used for directive labels.
119
+ */
120
+ function extractText(node: MdastNode): string {
121
+ if (node.value != null) return node.value;
122
+ if (node.children) {
123
+ return node.children.map(extractText).join('');
124
+ }
125
+ return '';
126
+ }
127
+
128
+ // ============================================
129
+ // Template annotation helpers
130
+ // ============================================
131
+
132
+ /**
133
+ * Regex matching a trailing `{[templateName key=value …]}` annotation.
134
+ * Captures the content between `{[` and `]}`.
135
+ */
136
+ const TEMPLATE_ANNOTATION_RE = /\s*\{\[([^\]]+)\]\}\s*$/;
137
+
138
+ /**
139
+ * Extract a `{[templateName key=value …]}` annotation from a heading's
140
+ * inline children. Mutates the children array in-place: strips the
141
+ * annotation text from the last text node (or removes the node entirely).
142
+ *
143
+ * @returns The parsed annotation, or null if none found.
144
+ */
145
+ function extractTemplateAnnotation(
146
+ children: MarkdownInlineNode[],
147
+ ): HeadingTemplateAnnotation | null {
148
+ // Walk backwards to find the last text node
149
+ for (let i = children.length - 1; i >= 0; i--) {
150
+ const child = children[i];
151
+ if (child.type === 'text') {
152
+ const match = child.value.match(TEMPLATE_ANNOTATION_RE);
153
+ if (match) {
154
+ const inner = match[1].trim();
155
+ const annotation = parseAnnotationTokens(inner);
156
+
157
+ // Strip the matched portion from the text
158
+ const stripped = child.value.slice(0, match.index!).replace(/\s+$/, '');
159
+ if (stripped) {
160
+ (child as { value: string }).value = stripped;
161
+ } else {
162
+ // Remove the now-empty text node
163
+ children.splice(i, 1);
164
+ }
165
+ return annotation;
166
+ }
167
+ // Only check the last text node (trailing position)
168
+ break;
169
+ }
170
+ // If the last child isn't a text node, there's no annotation
171
+ break;
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /**
177
+ * Parse the inner content of a `{[…]}` annotation into template + params.
178
+ *
179
+ * Input: `"chart colorScheme=blue size=large"`
180
+ * Output: `{ template: 'chart', params: { colorScheme: 'blue', size: 'large' } }`
181
+ */
182
+ function parseAnnotationTokens(inner: string): HeadingTemplateAnnotation {
183
+ const tokens = inner.split(/\s+/);
184
+ const template = tokens[0];
185
+ const params: Record<string, string> = {};
186
+
187
+ for (let i = 1; i < tokens.length; i++) {
188
+ const eqIdx = tokens[i].indexOf('=');
189
+ if (eqIdx > 0) {
190
+ params[tokens[i].slice(0, eqIdx)] = tokens[i].slice(eqIdx + 1);
191
+ }
192
+ }
193
+
194
+ const result: HeadingTemplateAnnotation = { template };
195
+ if (Object.keys(params).length > 0) {
196
+ result.params = params;
197
+ }
198
+ return result;
199
+ }
200
+
201
+ /**
202
+ * Serialize a HeadingTemplateAnnotation back to `{[templateName key=value …]}` text.
203
+ */
204
+ function serializeTemplateAnnotation(annotation: HeadingTemplateAnnotation): string {
205
+ let result = `{[${annotation.template}`;
206
+ if (annotation.params) {
207
+ for (const [key, value] of Object.entries(annotation.params)) {
208
+ result += ` ${key}=${value}`;
209
+ }
210
+ }
211
+ result += ']}';
212
+ return result;
213
+ }
214
+
215
+ /**
216
+ * Convert an mdast Root node to a MarkdownDocument.
217
+ *
218
+ * @param root - The mdast root node (from remark-parse)
219
+ * @param options - Conversion options
220
+ * @returns A MarkdownDocument
221
+ */
222
+ export function fromMdast(root: MdastNode, options?: { parseHtml?: boolean }): MarkdownDocument {
223
+ const doParseHtml = options?.parseHtml !== false;
224
+ return {
225
+ type: 'document',
226
+ children: convertBlockChildren(root.children ?? [], doParseHtml),
227
+ ...posField(convertPosition(root.position)),
228
+ };
229
+ }
230
+
231
+ function convertBlockChildren(children: MdastNode[], parseHtml: boolean): MarkdownBlockNode[] {
232
+ const result: MarkdownBlockNode[] = [];
233
+ for (const child of children) {
234
+ const node = convertBlockNode(child, parseHtml);
235
+ if (node) result.push(node);
236
+ }
237
+ return result;
238
+ }
239
+
240
+ function convertInlineChildren(children: MdastNode[], parseHtml: boolean): MarkdownInlineNode[] {
241
+ const result: MarkdownInlineNode[] = [];
242
+ for (const child of children) {
243
+ const node = convertInlineNode(child, parseHtml);
244
+ if (node) result.push(node);
245
+ }
246
+ return result;
247
+ }
248
+
249
+ function convertBlockNode(node: MdastNode, parseHtml: boolean): MarkdownBlockNode | null {
250
+ const pos = convertPosition(node.position);
251
+
252
+ switch (node.type) {
253
+ case 'heading': {
254
+ const headingChildren = convertInlineChildren(node.children ?? [], parseHtml);
255
+ const annotation = extractTemplateAnnotation(headingChildren);
256
+ const result: MarkdownHeading = {
257
+ type: 'heading',
258
+ depth: (node.depth ?? 1) as 1 | 2 | 3 | 4 | 5 | 6,
259
+ children: headingChildren,
260
+ ...posField(pos),
261
+ };
262
+ if (annotation) {
263
+ result.templateAnnotation = annotation;
264
+ }
265
+ return result;
266
+ }
267
+
268
+ case 'paragraph':
269
+ return {
270
+ type: 'paragraph',
271
+ children: convertInlineChildren(node.children ?? [], parseHtml),
272
+ ...posField(pos),
273
+ };
274
+
275
+ case 'blockquote':
276
+ return {
277
+ type: 'blockquote',
278
+ children: convertBlockChildren(node.children ?? [], parseHtml),
279
+ ...posField(pos),
280
+ };
281
+
282
+ case 'list':
283
+ return {
284
+ type: 'list',
285
+ ordered: node.ordered ?? false,
286
+ ...(node.start != null ? { start: node.start } : {}),
287
+ ...(node.spread != null ? { spread: node.spread } : {}),
288
+ children: (node.children ?? []).map((item) => convertListItem(item, parseHtml)),
289
+ ...posField(pos),
290
+ };
291
+
292
+ case 'code':
293
+ return {
294
+ type: 'code',
295
+ ...(node.lang != null ? { lang: node.lang } : {}),
296
+ ...(node.meta != null ? { meta: node.meta } : {}),
297
+ value: node.value ?? '',
298
+ ...posField(pos),
299
+ };
300
+
301
+ case 'thematicBreak':
302
+ return {
303
+ type: 'thematicBreak',
304
+ ...posField(pos),
305
+ };
306
+
307
+ case 'table':
308
+ return {
309
+ type: 'table',
310
+ ...(node.align
311
+ ? { align: node.align.map((a) => a as 'left' | 'right' | 'center' | null) }
312
+ : {}),
313
+ children: (node.children ?? []).map((row, i) => convertTableRow(row, i === 0, parseHtml)),
314
+ ...posField(pos),
315
+ };
316
+
317
+ case 'html':
318
+ return {
319
+ type: 'htmlBlock',
320
+ rawHtml: node.value ?? '',
321
+ htmlChildren: parseHtml ? parseHtmlToNodes(node.value ?? '') : [],
322
+ ...posField(pos),
323
+ };
324
+
325
+ case 'math':
326
+ return {
327
+ type: 'math',
328
+ value: node.value ?? '',
329
+ ...(node.meta != null ? { meta: node.meta } : {}),
330
+ ...posField(pos),
331
+ };
332
+
333
+ case 'definition':
334
+ return {
335
+ type: 'definition',
336
+ identifier: node.identifier ?? '',
337
+ ...(node.label != null ? { label: node.label } : {}),
338
+ url: node.url ?? '',
339
+ ...(node.title != null ? { title: node.title } : {}),
340
+ ...posField(pos),
341
+ };
342
+
343
+ case 'footnoteDefinition':
344
+ return {
345
+ type: 'footnoteDefinition',
346
+ identifier: node.identifier ?? '',
347
+ ...(node.label != null ? { label: node.label } : {}),
348
+ children: convertBlockChildren(node.children ?? [], parseHtml),
349
+ ...posField(pos),
350
+ };
351
+
352
+ case 'containerDirective': {
353
+ const allChildren = node.children ?? [];
354
+ let label: string | undefined;
355
+ let contentChildren = allChildren;
356
+
357
+ // Extract directive label (first paragraph with directiveLabel flag)
358
+ if (
359
+ allChildren.length > 0 &&
360
+ allChildren[0].type === 'paragraph' &&
361
+ allChildren[0].data?.directiveLabel === true
362
+ ) {
363
+ label = extractText(allChildren[0]);
364
+ contentChildren = allChildren.slice(1);
365
+ }
366
+
367
+ return {
368
+ type: 'containerDirective',
369
+ name: node.name ?? '',
370
+ ...(label ? { label } : {}),
371
+ ...(node.attributes && Object.keys(node.attributes).length > 0
372
+ ? { attributes: node.attributes }
373
+ : {}),
374
+ children: convertBlockChildren(contentChildren, parseHtml),
375
+ ...posField(pos),
376
+ };
377
+ }
378
+
379
+ case 'leafDirective':
380
+ return {
381
+ type: 'leafDirective',
382
+ name: node.name ?? '',
383
+ ...(node.attributes && Object.keys(node.attributes).length > 0
384
+ ? { attributes: node.attributes }
385
+ : {}),
386
+ children: convertInlineChildren(node.children ?? [], parseHtml),
387
+ ...posField(pos),
388
+ };
389
+
390
+ case 'yaml':
391
+ // YAML frontmatter nodes are handled separately in parse.ts — skip silently
392
+ return null;
393
+
394
+ default:
395
+ // Unknown block node — skip with a warning
396
+ if (typeof console !== 'undefined') {
397
+ console.warn(`[squisq/markdown] Unknown mdast block node type: "${node.type}"`);
398
+ }
399
+ return null;
400
+ }
401
+ }
402
+
403
+ function convertListItem(node: MdastNode, parseHtml: boolean): MarkdownListItem {
404
+ const pos = convertPosition(node.position);
405
+ return {
406
+ type: 'listItem',
407
+ ...(node.checked != null ? { checked: node.checked } : {}),
408
+ ...(node.spread != null ? { spread: node.spread } : {}),
409
+ children: convertBlockChildren(node.children ?? [], parseHtml),
410
+ ...posField(pos),
411
+ };
412
+ }
413
+
414
+ function convertTableRow(node: MdastNode, isHeader: boolean, parseHtml: boolean): MarkdownTableRow {
415
+ const pos = convertPosition(node.position);
416
+ return {
417
+ type: 'tableRow',
418
+ children: (node.children ?? []).map((cell) => convertTableCell(cell, isHeader, parseHtml)),
419
+ ...posField(pos),
420
+ };
421
+ }
422
+
423
+ function convertTableCell(
424
+ node: MdastNode,
425
+ isHeader: boolean,
426
+ parseHtml: boolean,
427
+ ): MarkdownTableCell {
428
+ const pos = convertPosition(node.position);
429
+ return {
430
+ type: 'tableCell',
431
+ ...(isHeader ? { isHeader: true } : {}),
432
+ children: convertInlineChildren(node.children ?? [], parseHtml),
433
+ ...posField(pos),
434
+ };
435
+ }
436
+
437
+ function convertInlineNode(node: MdastNode, parseHtml: boolean): MarkdownInlineNode | null {
438
+ const pos = convertPosition(node.position);
439
+
440
+ switch (node.type) {
441
+ case 'text':
442
+ return {
443
+ type: 'text',
444
+ value: node.value ?? '',
445
+ ...posField(pos),
446
+ };
447
+
448
+ case 'emphasis':
449
+ return {
450
+ type: 'emphasis',
451
+ children: convertInlineChildren(node.children ?? [], parseHtml),
452
+ ...posField(pos),
453
+ };
454
+
455
+ case 'strong':
456
+ return {
457
+ type: 'strong',
458
+ children: convertInlineChildren(node.children ?? [], parseHtml),
459
+ ...posField(pos),
460
+ };
461
+
462
+ case 'delete':
463
+ return {
464
+ type: 'delete',
465
+ children: convertInlineChildren(node.children ?? [], parseHtml),
466
+ ...posField(pos),
467
+ };
468
+
469
+ case 'inlineCode':
470
+ return {
471
+ type: 'inlineCode',
472
+ value: node.value ?? '',
473
+ ...posField(pos),
474
+ };
475
+
476
+ case 'link':
477
+ return {
478
+ type: 'link',
479
+ url: node.url ?? '',
480
+ ...(node.title != null ? { title: node.title } : {}),
481
+ children: convertInlineChildren(node.children ?? [], parseHtml),
482
+ ...posField(pos),
483
+ };
484
+
485
+ case 'image':
486
+ return {
487
+ type: 'image',
488
+ url: node.url ?? '',
489
+ ...(node.alt != null ? { alt: node.alt } : {}),
490
+ ...(node.title != null ? { title: node.title } : {}),
491
+ ...posField(pos),
492
+ };
493
+
494
+ case 'break':
495
+ return {
496
+ type: 'break',
497
+ ...posField(pos),
498
+ };
499
+
500
+ case 'html':
501
+ return {
502
+ type: 'htmlInline',
503
+ rawHtml: node.value ?? '',
504
+ htmlChildren: parseHtml ? parseHtmlToNodes(node.value ?? '') : [],
505
+ ...posField(pos),
506
+ };
507
+
508
+ case 'inlineMath':
509
+ return {
510
+ type: 'inlineMath',
511
+ value: node.value ?? '',
512
+ ...posField(pos),
513
+ };
514
+
515
+ case 'footnoteReference':
516
+ return {
517
+ type: 'footnoteReference',
518
+ identifier: node.identifier ?? '',
519
+ ...(node.label != null ? { label: node.label } : {}),
520
+ ...posField(pos),
521
+ };
522
+
523
+ case 'linkReference':
524
+ return {
525
+ type: 'linkReference',
526
+ identifier: node.identifier ?? '',
527
+ ...(node.label != null ? { label: node.label } : {}),
528
+ referenceType: (node.referenceType ?? 'full') as 'full' | 'collapsed' | 'shortcut',
529
+ children: convertInlineChildren(node.children ?? [], parseHtml),
530
+ ...posField(pos),
531
+ };
532
+
533
+ case 'imageReference':
534
+ return {
535
+ type: 'imageReference',
536
+ identifier: node.identifier ?? '',
537
+ ...(node.label != null ? { label: node.label } : {}),
538
+ referenceType: (node.referenceType ?? 'full') as 'full' | 'collapsed' | 'shortcut',
539
+ ...(node.alt != null ? { alt: node.alt } : {}),
540
+ ...posField(pos),
541
+ };
542
+
543
+ case 'textDirective':
544
+ return {
545
+ type: 'textDirective',
546
+ name: node.name ?? '',
547
+ ...(node.attributes && Object.keys(node.attributes).length > 0
548
+ ? { attributes: node.attributes }
549
+ : {}),
550
+ children: convertInlineChildren(node.children ?? [], parseHtml),
551
+ ...posField(pos),
552
+ };
553
+
554
+ default:
555
+ if (typeof console !== 'undefined') {
556
+ console.warn(`[squisq/markdown] Unknown mdast inline node type: "${node.type}"`);
557
+ }
558
+ return null;
559
+ }
560
+ }
561
+
562
+ // ============================================
563
+ // Squisq → mdast (toMdast)
564
+ // ============================================
565
+
566
+ /**
567
+ * Convert a MarkdownDocument back to an mdast Root node.
568
+ *
569
+ * @param doc - A MarkdownDocument
570
+ * @returns An mdast Root node suitable for remark-stringify
571
+ */
572
+ export function toMdast(doc: MarkdownDocument): MdastNode {
573
+ return {
574
+ type: 'root',
575
+ children: doc.children.map((n) => blockToMdast(n)),
576
+ ...mdastPosField(doc.position),
577
+ };
578
+ }
579
+
580
+ function blockToMdast(node: MarkdownBlockNode): MdastNode {
581
+ switch (node.type) {
582
+ case 'heading': {
583
+ const mdastChildren = node.children.map(inlineToMdast);
584
+ if (node.templateAnnotation) {
585
+ const suffix = serializeTemplateAnnotation(node.templateAnnotation);
586
+ // Append to last text node, or create a new one
587
+ const lastChild = mdastChildren[mdastChildren.length - 1];
588
+ if (lastChild && lastChild.type === 'text') {
589
+ lastChild.value = (lastChild.value ?? '') + ' ' + suffix;
590
+ } else {
591
+ mdastChildren.push({ type: 'text', value: ' ' + suffix });
592
+ }
593
+ }
594
+ return {
595
+ type: 'heading',
596
+ depth: node.depth,
597
+ children: mdastChildren,
598
+ ...mdastPosField(node.position),
599
+ };
600
+ }
601
+
602
+ case 'paragraph':
603
+ return {
604
+ type: 'paragraph',
605
+ children: node.children.map(inlineToMdast),
606
+ ...mdastPosField(node.position),
607
+ };
608
+
609
+ case 'blockquote':
610
+ return {
611
+ type: 'blockquote',
612
+ children: node.children.map(blockToMdast),
613
+ ...mdastPosField(node.position),
614
+ };
615
+
616
+ case 'list':
617
+ return {
618
+ type: 'list',
619
+ ordered: node.ordered,
620
+ ...(node.start != null ? { start: node.start } : {}),
621
+ ...(node.spread != null ? { spread: node.spread } : {}),
622
+ children: node.children.map(listItemToMdast),
623
+ ...mdastPosField(node.position),
624
+ };
625
+
626
+ case 'code':
627
+ return {
628
+ type: 'code',
629
+ ...(node.lang != null ? { lang: node.lang } : {}),
630
+ ...(node.meta != null ? { meta: node.meta } : {}),
631
+ value: node.value,
632
+ ...mdastPosField(node.position),
633
+ };
634
+
635
+ case 'thematicBreak':
636
+ return {
637
+ type: 'thematicBreak',
638
+ ...mdastPosField(node.position),
639
+ };
640
+
641
+ case 'table':
642
+ return {
643
+ type: 'table',
644
+ ...(node.align ? { align: node.align } : {}),
645
+ children: node.children.map(tableRowToMdast),
646
+ ...mdastPosField(node.position),
647
+ };
648
+
649
+ case 'htmlBlock':
650
+ return {
651
+ type: 'html',
652
+ value: node.rawHtml,
653
+ ...mdastPosField(node.position),
654
+ };
655
+
656
+ case 'math':
657
+ return {
658
+ type: 'math',
659
+ value: node.value,
660
+ ...(node.meta != null ? { meta: node.meta } : {}),
661
+ ...mdastPosField(node.position),
662
+ };
663
+
664
+ case 'definition':
665
+ return {
666
+ type: 'definition',
667
+ identifier: node.identifier,
668
+ ...(node.label != null ? { label: node.label } : {}),
669
+ url: node.url,
670
+ ...(node.title != null ? { title: node.title } : {}),
671
+ ...mdastPosField(node.position),
672
+ };
673
+
674
+ case 'footnoteDefinition':
675
+ return {
676
+ type: 'footnoteDefinition',
677
+ identifier: node.identifier,
678
+ ...(node.label != null ? { label: node.label } : {}),
679
+ children: node.children.map(blockToMdast),
680
+ ...mdastPosField(node.position),
681
+ };
682
+
683
+ case 'containerDirective': {
684
+ const children: MdastNode[] = [];
685
+
686
+ // Reinject label as first paragraph with directiveLabel flag
687
+ if (node.label) {
688
+ children.push({
689
+ type: 'paragraph',
690
+ data: { directiveLabel: true },
691
+ children: [{ type: 'text', value: node.label }],
692
+ });
693
+ }
694
+
695
+ children.push(...node.children.map(blockToMdast));
696
+
697
+ return {
698
+ type: 'containerDirective',
699
+ name: node.name,
700
+ ...(node.attributes ? { attributes: node.attributes } : {}),
701
+ children,
702
+ ...mdastPosField(node.position),
703
+ };
704
+ }
705
+
706
+ case 'leafDirective':
707
+ return {
708
+ type: 'leafDirective',
709
+ name: node.name,
710
+ ...(node.attributes ? { attributes: node.attributes } : {}),
711
+ children: node.children.map(inlineToMdast),
712
+ ...mdastPosField(node.position),
713
+ };
714
+
715
+ case 'definitionList':
716
+ // Definition lists don't map to standard mdast.
717
+ // Convert to HTML as fallback.
718
+ return {
719
+ type: 'html',
720
+ value: definitionListToHtml(node),
721
+ ...mdastPosField(node.position),
722
+ };
723
+
724
+ default:
725
+ return { type: 'html', value: '' };
726
+ }
727
+ }
728
+
729
+ function listItemToMdast(item: MarkdownListItem): MdastNode {
730
+ return {
731
+ type: 'listItem',
732
+ ...(item.checked != null ? { checked: item.checked } : {}),
733
+ ...(item.spread != null ? { spread: item.spread } : {}),
734
+ children: item.children.map(blockToMdast),
735
+ ...mdastPosField(item.position),
736
+ };
737
+ }
738
+
739
+ function tableRowToMdast(row: MarkdownTableRow): MdastNode {
740
+ return {
741
+ type: 'tableRow',
742
+ children: row.children.map(tableCellToMdast),
743
+ ...mdastPosField(row.position),
744
+ };
745
+ }
746
+
747
+ function tableCellToMdast(cell: MarkdownTableCell): MdastNode {
748
+ return {
749
+ type: 'tableCell',
750
+ children: cell.children.map(inlineToMdast),
751
+ ...mdastPosField(cell.position),
752
+ };
753
+ }
754
+
755
+ function inlineToMdast(node: MarkdownInlineNode): MdastNode {
756
+ switch (node.type) {
757
+ case 'text':
758
+ return {
759
+ type: 'text',
760
+ value: node.value,
761
+ ...mdastPosField(node.position),
762
+ };
763
+
764
+ case 'emphasis':
765
+ return {
766
+ type: 'emphasis',
767
+ children: node.children.map(inlineToMdast),
768
+ ...mdastPosField(node.position),
769
+ };
770
+
771
+ case 'strong':
772
+ return {
773
+ type: 'strong',
774
+ children: node.children.map(inlineToMdast),
775
+ ...mdastPosField(node.position),
776
+ };
777
+
778
+ case 'delete':
779
+ return {
780
+ type: 'delete',
781
+ children: node.children.map(inlineToMdast),
782
+ ...mdastPosField(node.position),
783
+ };
784
+
785
+ case 'inlineCode':
786
+ return {
787
+ type: 'inlineCode',
788
+ value: node.value,
789
+ ...mdastPosField(node.position),
790
+ };
791
+
792
+ case 'link':
793
+ return {
794
+ type: 'link',
795
+ url: node.url,
796
+ ...(node.title != null ? { title: node.title } : {}),
797
+ children: node.children.map(inlineToMdast),
798
+ ...mdastPosField(node.position),
799
+ };
800
+
801
+ case 'image':
802
+ return {
803
+ type: 'image',
804
+ url: node.url,
805
+ ...(node.alt != null ? { alt: node.alt } : {}),
806
+ ...(node.title != null ? { title: node.title } : {}),
807
+ ...mdastPosField(node.position),
808
+ };
809
+
810
+ case 'break':
811
+ return {
812
+ type: 'break',
813
+ ...mdastPosField(node.position),
814
+ };
815
+
816
+ case 'htmlInline':
817
+ return {
818
+ type: 'html',
819
+ value: node.rawHtml,
820
+ ...mdastPosField(node.position),
821
+ };
822
+
823
+ case 'inlineMath':
824
+ return {
825
+ type: 'inlineMath',
826
+ value: node.value,
827
+ ...mdastPosField(node.position),
828
+ };
829
+
830
+ case 'footnoteReference':
831
+ return {
832
+ type: 'footnoteReference',
833
+ identifier: node.identifier,
834
+ ...(node.label != null ? { label: node.label } : {}),
835
+ ...mdastPosField(node.position),
836
+ };
837
+
838
+ case 'linkReference':
839
+ return {
840
+ type: 'linkReference',
841
+ identifier: node.identifier,
842
+ ...(node.label != null ? { label: node.label } : {}),
843
+ referenceType: node.referenceType,
844
+ children: node.children.map(inlineToMdast),
845
+ ...mdastPosField(node.position),
846
+ };
847
+
848
+ case 'imageReference':
849
+ return {
850
+ type: 'imageReference',
851
+ identifier: node.identifier,
852
+ ...(node.label != null ? { label: node.label } : {}),
853
+ referenceType: node.referenceType,
854
+ ...(node.alt != null ? { alt: node.alt } : {}),
855
+ ...mdastPosField(node.position),
856
+ };
857
+
858
+ case 'textDirective':
859
+ return {
860
+ type: 'textDirective',
861
+ name: node.name,
862
+ ...(node.attributes ? { attributes: node.attributes } : {}),
863
+ children: node.children.map(inlineToMdast),
864
+ ...mdastPosField(node.position),
865
+ };
866
+
867
+ default:
868
+ return { type: 'text', value: '' };
869
+ }
870
+ }
871
+
872
+ // ============================================
873
+ // Helpers
874
+ // ============================================
875
+
876
+ /**
877
+ * Convert a MarkdownDefinitionList to an HTML <dl> string.
878
+ * Fallback for serialization since mdast doesn't support definition lists.
879
+ */
880
+ function definitionListToHtml(list: {
881
+ children: Array<{ type: string; children?: unknown[] }>;
882
+ }): string {
883
+ let html = '<dl>\n';
884
+ for (const child of list.children) {
885
+ if (child.type === 'definitionTerm') {
886
+ // Extract plain text from inline children
887
+ const text = (child.children as Array<{ value?: string; children?: unknown[] }>)
888
+ .map((c) => c.value ?? '')
889
+ .join('');
890
+ html += ` <dt>${text}</dt>\n`;
891
+ } else if (child.type === 'definitionDescription') {
892
+ html += ` <dd>...</dd>\n`;
893
+ }
894
+ }
895
+ html += '</dl>';
896
+ return html;
897
+ }