@cairn-tool/cairn 3.0.0 → 3.1.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.
Files changed (46) hide show
  1. package/README.md +4 -3
  2. package/dist/agent/types.d.ts +2 -1
  3. package/dist/agent/types.js.map +1 -1
  4. package/dist/cli.js +33 -0
  5. package/dist/cli.js.map +1 -1
  6. package/dist/commands/jira.d.ts +17 -0
  7. package/dist/commands/jira.js +195 -0
  8. package/dist/commands/jira.js.map +1 -0
  9. package/dist/contract/registry.js +37 -0
  10. package/dist/contract/registry.js.map +1 -1
  11. package/dist/contract/schemas/index.js +2 -0
  12. package/dist/contract/schemas/index.js.map +1 -1
  13. package/dist/contract/schemas/jira.d.ts +2 -0
  14. package/dist/contract/schemas/jira.js +102 -0
  15. package/dist/contract/schemas/jira.js.map +1 -0
  16. package/dist/jira/adf/diagnostics.d.ts +77 -0
  17. package/dist/jira/adf/diagnostics.js +100 -0
  18. package/dist/jira/adf/diagnostics.js.map +1 -0
  19. package/dist/jira/adf/from-markdown.d.ts +6 -0
  20. package/dist/jira/adf/from-markdown.js +650 -0
  21. package/dist/jira/adf/from-markdown.js.map +1 -0
  22. package/dist/jira/adf/inspect.d.ts +11 -0
  23. package/dist/jira/adf/inspect.js +56 -0
  24. package/dist/jira/adf/inspect.js.map +1 -0
  25. package/dist/jira/adf/profile.d.ts +101 -0
  26. package/dist/jira/adf/profile.js +248 -0
  27. package/dist/jira/adf/profile.js.map +1 -0
  28. package/dist/jira/adf/read.d.ts +33 -0
  29. package/dist/jira/adf/read.js +250 -0
  30. package/dist/jira/adf/read.js.map +1 -0
  31. package/dist/jira/adf/serialize.d.ts +5 -0
  32. package/dist/jira/adf/serialize.js +54 -0
  33. package/dist/jira/adf/serialize.js.map +1 -0
  34. package/dist/jira/adf/to-markdown.d.ts +14 -0
  35. package/dist/jira/adf/to-markdown.js +512 -0
  36. package/dist/jira/adf/to-markdown.js.map +1 -0
  37. package/dist/jira/adf/types.d.ts +73 -0
  38. package/dist/jira/adf/types.js +2 -0
  39. package/dist/jira/adf/types.js.map +1 -0
  40. package/dist/jira/adf/validate.d.ts +7 -0
  41. package/dist/jira/adf/validate.js +182 -0
  42. package/dist/jira/adf/validate.js.map +1 -0
  43. package/dist/mapping-quality.d.ts +18 -0
  44. package/dist/mapping-quality.js +12 -0
  45. package/dist/mapping-quality.js.map +1 -0
  46. package/package.json +4 -1
@@ -0,0 +1,650 @@
1
+ import { parseMarkdown } from "../../markdown-ast.js";
2
+ import { CODES, DiagnosticSink } from "./diagnostics.js";
3
+ import { accepts, degradationFor } from "./profile.js";
4
+ /**
5
+ * Markdown to ADF.
6
+ *
7
+ * The hard direction, and not because of missing node types: ADF validates
8
+ * per-node content, and Markdown permits nestings ADF forbids. So this is not a
9
+ * node-for-node walk — every emission is checked against the content model in
10
+ * `profile.ts`, and an illegal pair takes that table's degradation rather than
11
+ * being emitted and rejected downstream.
12
+ *
13
+ * The rule the degradations follow: flatten in place, never lift. Promoting a
14
+ * heading out of a list item would move it past the text that followed it, so
15
+ * the output would be legal, plausible, and saying something the input did not.
16
+ */
17
+ const text = (value, marks) => ({
18
+ type: "text",
19
+ ...(marks?.length ? { marks } : {}),
20
+ text: value,
21
+ });
22
+ /** An empty paragraph, the filler for containers ADF requires to be nonempty. */
23
+ const emptyParagraph = () => ({ type: "paragraph", content: [] });
24
+ function withMark(marks, mark) {
25
+ return marks.some((existing) => existing.type === mark.type) ? marks : [...marks, mark];
26
+ }
27
+ class Converter {
28
+ root;
29
+ sink = new DiagnosticSink();
30
+ definitions = new Map();
31
+ footnotes = [];
32
+ localIds = 0;
33
+ constructor(root) {
34
+ this.root = root;
35
+ collect(root, this.definitions, this.footnotes);
36
+ }
37
+ /**
38
+ * Derived, never generated.
39
+ *
40
+ * `taskList` and `taskItem` require a `localId`, and `crypto.randomUUID()`
41
+ * here would make every test that compares output bytes unrunnable.
42
+ */
43
+ nextLocalId(prefix) {
44
+ this.localIds += 1;
45
+ return `${prefix}-${this.localIds}`;
46
+ }
47
+ convert() {
48
+ const content = this.blocks(this.root.children, "doc", ["doc"]);
49
+ if (this.footnotes.length)
50
+ content.push(...this.footnoteBlocks());
51
+ return { version: 1, type: "doc", content };
52
+ }
53
+ /**
54
+ * Footnote definitions become paragraphs after a trailing rule.
55
+ *
56
+ * Order-preserving in practice: footnote definitions conventionally sit at the
57
+ * end of a document already.
58
+ */
59
+ footnoteBlocks() {
60
+ const out = [{ type: "rule" }];
61
+ for (const [index, definition] of this.footnotes.entries()) {
62
+ const label = definition.label ?? definition.identifier ?? String(index + 1);
63
+ const body = this.blocks(definition.children, "doc", ["doc", "footnote"]);
64
+ out.push({
65
+ type: "paragraph",
66
+ content: [text(`[${label}]`, [{ type: "strong" }])],
67
+ });
68
+ out.push(...body);
69
+ }
70
+ return out;
71
+ }
72
+ /** Converts mdast flow content into ADF blocks legal inside `parent`. */
73
+ blocks(nodes, parent, trail) {
74
+ const out = [];
75
+ for (const node of nodes)
76
+ out.push(...this.block(node, parent, trail));
77
+ return out;
78
+ }
79
+ block(node, parent, trail) {
80
+ switch (node.type) {
81
+ case "yaml": {
82
+ // Frontmatter is metadata about the document, not content of it, so it
83
+ // never becomes ADF body content.
84
+ this.sink.add({
85
+ code: CODES.frontmatterDropped,
86
+ quality: "approximate",
87
+ message: "YAML frontmatter is metadata and was not converted into the document body",
88
+ node: "yaml",
89
+ location: trail.join("/"),
90
+ remediation: "Carry the frontmatter separately if the receiving system needs it.",
91
+ });
92
+ return [];
93
+ }
94
+ case "definition":
95
+ // Consumed while resolving references, not emitted.
96
+ return [];
97
+ case "footnoteDefinition":
98
+ // Emitted at the end of the document instead.
99
+ return [];
100
+ case "html": {
101
+ this.sink.add({
102
+ code: CODES.htmlPreserved,
103
+ quality: "approximate",
104
+ message: "ADF has no raw HTML node, so the markup was preserved verbatim in a code block",
105
+ node: "html",
106
+ location: trail.join("/"),
107
+ });
108
+ return this.place({ type: "codeBlock", content: [text(node.value)] }, parent, trail);
109
+ }
110
+ case "paragraph":
111
+ return this.paragraph(node, parent, trail);
112
+ case "heading":
113
+ return this.place(this.heading(node, trail), parent, trail);
114
+ case "thematicBreak":
115
+ return this.place({ type: "rule" }, parent, trail);
116
+ case "code": {
117
+ const language = node.lang ?? undefined;
118
+ return this.place({
119
+ type: "codeBlock",
120
+ ...(language ? { attrs: { language } } : {}),
121
+ content: node.value === "" ? [] : [text(node.value)],
122
+ }, parent, trail);
123
+ }
124
+ case "blockquote": {
125
+ const children = this.blocks(node.children, "blockquote", [
126
+ ...trail,
127
+ "blockquote",
128
+ ]);
129
+ if (!children.length) {
130
+ this.sink.add({
131
+ code: CODES.contentDropped,
132
+ quality: "unsupported",
133
+ message: "An empty block quote was dropped: ADF requires a quote to hold content",
134
+ node: "blockquote",
135
+ location: trail.join("/"),
136
+ });
137
+ return [];
138
+ }
139
+ return this.place({ type: "blockquote", content: children }, parent, trail);
140
+ }
141
+ case "list":
142
+ return this.lists(node, parent, trail);
143
+ case "table":
144
+ return this.place(this.table(node, trail), parent, trail);
145
+ default:
146
+ return [];
147
+ }
148
+ }
149
+ /**
150
+ * Emits `node` inside `parent`, degrading it when the content model forbids it.
151
+ *
152
+ * Every block goes through here, which is what makes the legality pass total
153
+ * rather than a set of special cases.
154
+ */
155
+ place(node, parent, trail) {
156
+ if (accepts(parent, node.type))
157
+ return [node];
158
+ const rule = degradationFor(parent, node.type);
159
+ if (!rule) {
160
+ // Unreachable while tests/unit/jira-adf-profile.test.ts passes: it fails the
161
+ // build on any pair this walk can form that has neither a legal mapping
162
+ // nor a rule. Reported rather than thrown so a gap degrades visibly.
163
+ this.sink.add({
164
+ code: CODES.contentDropped,
165
+ quality: "unsupported",
166
+ message: `ADF does not allow ${node.type} inside ${parent}, and no degradation is defined`,
167
+ node: node.type,
168
+ location: trail.join("/"),
169
+ });
170
+ return [];
171
+ }
172
+ const location = trail.join("/");
173
+ switch (rule.action) {
174
+ case "drop":
175
+ this.sink.add({
176
+ code: CODES.contentDropped,
177
+ quality: rule.quality,
178
+ message: `ADF does not allow ${node.type} inside ${parent}; it carries no content, so it was omitted`,
179
+ node: node.type,
180
+ location,
181
+ });
182
+ return [];
183
+ case "strong-paragraph": {
184
+ this.sink.add({
185
+ code: CODES.headingFlattened,
186
+ quality: rule.quality,
187
+ message: `ADF does not allow ${node.type} inside ${parent}; it became a paragraph in bold, in place`,
188
+ node: node.type,
189
+ location,
190
+ });
191
+ return [{ type: "paragraph", content: boldRun(node.content ?? []) }];
192
+ }
193
+ case "unwrap": {
194
+ this.sink.add({
195
+ code: CODES.blockquoteUnwrapped,
196
+ quality: rule.quality,
197
+ message: `ADF does not allow ${node.type} inside ${parent}; its contents were lifted in place`,
198
+ node: node.type,
199
+ location,
200
+ });
201
+ // Re-place each child, so an unwrapped child that is itself illegal is
202
+ // degraded rather than emitted.
203
+ return (node.content ?? []).flatMap((child) => this.place(child, parent, trail));
204
+ }
205
+ case "rows-as-paragraphs": {
206
+ this.sink.add({
207
+ code: CODES.tableFlattenedToRows,
208
+ quality: rule.quality,
209
+ message: `ADF does not allow ${node.type} inside ${parent}; each row became a paragraph`,
210
+ node: node.type,
211
+ location,
212
+ });
213
+ return (node.content ?? []).map((row) => ({
214
+ type: "paragraph",
215
+ content: joinCells(row.content ?? []),
216
+ }));
217
+ }
218
+ case "list-downgrade": {
219
+ this.sink.add({
220
+ code: CODES.listSplit,
221
+ quality: rule.quality,
222
+ message: `ADF does not allow ${node.type} inside ${parent}; it became a bulleted list keeping each state as a text prefix`,
223
+ node: node.type,
224
+ location,
225
+ });
226
+ return [downgradeTaskList(node)];
227
+ }
228
+ case "inline-flatten": {
229
+ this.sink.add({
230
+ code: CODES.contentDropped,
231
+ quality: rule.quality,
232
+ message: `ADF allows only inline content inside ${parent}; block content was flattened`,
233
+ node: node.type,
234
+ location,
235
+ });
236
+ return [];
237
+ }
238
+ }
239
+ }
240
+ heading(node, trail) {
241
+ return {
242
+ type: "heading",
243
+ attrs: { level: node.depth },
244
+ content: this.inline(node.children, [], [...trail, "heading"]),
245
+ };
246
+ }
247
+ /**
248
+ * A paragraph, split around any block-level image it contains.
249
+ *
250
+ * `mediaInline` cannot carry an external URL — verified against the published
251
+ * schema — so a Markdown image cannot stay inside its paragraph. Splitting
252
+ * preserves reading order exactly: nothing moves past anything else, which is
253
+ * what separates this from lifting.
254
+ */
255
+ paragraph(node, parent, trail) {
256
+ const here = [...trail, "paragraph"];
257
+ const images = node.children.filter((child) => child.type === "image");
258
+ if (!images.length || !accepts(parent, "mediaSingle"))
259
+ return this.place({ type: "paragraph", content: this.inline(node.children, [], here) }, parent, trail);
260
+ this.sink.add({
261
+ code: CODES.paragraphSplit,
262
+ quality: "approximate",
263
+ message: "ADF images are block-level, so the paragraph was split around the image rather than reordered",
264
+ node: "image",
265
+ location: here.join("/"),
266
+ });
267
+ const out = [];
268
+ let run = [];
269
+ const flush = () => {
270
+ // Trimmed at the edges: the split leaves the space that sat beside the
271
+ // image, and a text node that is only whitespace serializes back as a
272
+ // literal ` `.
273
+ const content = trimEdges(this.inline(run, [], here));
274
+ if (content.length)
275
+ out.push({ type: "paragraph", content });
276
+ run = [];
277
+ };
278
+ for (const child of node.children) {
279
+ if (child.type !== "image") {
280
+ run.push(child);
281
+ continue;
282
+ }
283
+ flush();
284
+ out.push({
285
+ type: "mediaSingle",
286
+ content: [
287
+ {
288
+ type: "media",
289
+ attrs: {
290
+ type: "external",
291
+ url: child.url,
292
+ ...(child.alt ? { alt: child.alt } : {}),
293
+ },
294
+ },
295
+ ],
296
+ });
297
+ }
298
+ flush();
299
+ return out;
300
+ }
301
+ /**
302
+ * A GFM list, split into runs so a mixed list keeps every item in place.
303
+ *
304
+ * ADF has no list that holds both task items and plain items, so a contiguous
305
+ * run of checkbox items becomes a `taskList` and a run without becomes a
306
+ * bulleted or ordered list.
307
+ */
308
+ lists(node, parent, trail) {
309
+ const isTask = (item) => item.checked === true || item.checked === false;
310
+ const runs = [];
311
+ for (const child of node.children) {
312
+ const task = isTask(child);
313
+ const last = runs[runs.length - 1];
314
+ if (last && last.task === task)
315
+ last.items.push(child);
316
+ else
317
+ runs.push({ task, items: [child] });
318
+ }
319
+ if (runs.length > 1)
320
+ this.sink.add({
321
+ code: CODES.listSplit,
322
+ quality: "approximate",
323
+ message: "ADF has no list holding both task and plain items, so the list was split into runs in place",
324
+ node: "list",
325
+ location: trail.join("/"),
326
+ });
327
+ const out = [];
328
+ for (const run of runs) {
329
+ const emitted = run.task
330
+ ? this.taskList(run.items, trail)
331
+ : this.plainList(node, run.items, trail);
332
+ if (emitted)
333
+ out.push(...this.place(emitted, parent, trail));
334
+ }
335
+ return out;
336
+ }
337
+ plainList(node, items, trail) {
338
+ const type = node.ordered ? "orderedList" : "bulletList";
339
+ const here = [...trail, type];
340
+ const content = [];
341
+ for (const item of items) {
342
+ const blocks = this.blocks(item.children, "listItem", [...here, "listItem"]);
343
+ // ADF requires a list item to hold at least one block.
344
+ content.push({ type: "listItem", content: blocks.length ? blocks : [emptyParagraph()] });
345
+ }
346
+ if (!content.length) {
347
+ this.sink.add({
348
+ code: CODES.contentDropped,
349
+ quality: "unsupported",
350
+ message: "An empty list was dropped: ADF requires a list to hold at least one item",
351
+ node: type,
352
+ location: trail.join("/"),
353
+ });
354
+ return undefined;
355
+ }
356
+ const start = node.start ?? 1;
357
+ return {
358
+ type,
359
+ ...(node.ordered && start !== 1 ? { attrs: { order: start } } : {}),
360
+ content,
361
+ };
362
+ }
363
+ taskList(items, trail) {
364
+ const here = [...trail, "taskList"];
365
+ const content = [];
366
+ for (const item of items) {
367
+ const inline = this.taskItemInline(item, here);
368
+ content.push({
369
+ type: "taskItem",
370
+ attrs: { localId: this.nextLocalId("item"), state: item.checked ? "DONE" : "TODO" },
371
+ content: inline,
372
+ });
373
+ }
374
+ if (!content.length)
375
+ return undefined;
376
+ return { type: "taskList", attrs: { localId: this.nextLocalId("list") }, content };
377
+ }
378
+ /**
379
+ * A task item holds inline content only, so a multi-block item collapses into
380
+ * one run separated by hard breaks.
381
+ */
382
+ taskItemInline(item, trail) {
383
+ const blocks = item.children;
384
+ const out = [];
385
+ let flattened = false;
386
+ for (const block of blocks) {
387
+ if (out.length) {
388
+ out.push({ type: "hardBreak" });
389
+ flattened = true;
390
+ }
391
+ if (block.type === "paragraph" || block.type === "heading") {
392
+ out.push(...this.inline(block.children, [], [...trail, "taskItem"]));
393
+ continue;
394
+ }
395
+ flattened = true;
396
+ out.push(...flattenBlockToInline(block));
397
+ }
398
+ if (flattened)
399
+ this.sink.add({
400
+ code: CODES.contentDropped,
401
+ quality: "approximate",
402
+ message: "ADF allows only inline content in a task item, so its blocks were joined in place",
403
+ node: "taskItem",
404
+ location: trail.join("/"),
405
+ });
406
+ return out;
407
+ }
408
+ table(node, trail) {
409
+ const here = [...trail, "table"];
410
+ if (node.align?.some((value) => value !== null && value !== undefined))
411
+ this.sink.add({
412
+ code: CODES.alignmentDropped,
413
+ quality: "unsupported",
414
+ message: "An ADF table cell has no alignment attribute, so column alignment was dropped",
415
+ node: "table",
416
+ location: here.join("/"),
417
+ });
418
+ const rows = node.children.map((row, rowIndex) => ({
419
+ type: "tableRow",
420
+ content: row.children.map((cell) => {
421
+ const inline = this.inline(cell.children, [], [...here, "tableCell"]);
422
+ return {
423
+ // A GFM table always has a header row; ADF spells that as tableHeader.
424
+ type: rowIndex === 0 ? "tableHeader" : "tableCell",
425
+ // ADF requires a cell to hold at least one block, and an empty cell is
426
+ // ordinary Markdown, so the filler is load-bearing rather than defensive.
427
+ content: [inline.length ? { type: "paragraph", content: inline } : emptyParagraph()],
428
+ };
429
+ }),
430
+ }));
431
+ return { type: "table", content: rows };
432
+ }
433
+ /** Converts mdast phrasing content to ADF inline nodes, accumulating marks. */
434
+ inline(nodes, marks, trail) {
435
+ const out = [];
436
+ for (const node of nodes) {
437
+ switch (node.type) {
438
+ case "text":
439
+ if (node.value !== "")
440
+ out.push(text(node.value, marks));
441
+ break;
442
+ case "inlineCode":
443
+ if (node.value !== "")
444
+ out.push(text(node.value, withMark(marks, { type: "code" })));
445
+ break;
446
+ case "strong":
447
+ out.push(...this.inline(node.children, withMark(marks, { type: "strong" }), trail));
448
+ break;
449
+ case "emphasis":
450
+ out.push(...this.inline(node.children, withMark(marks, { type: "em" }), trail));
451
+ break;
452
+ case "delete":
453
+ out.push(...this.inline(node.children, withMark(marks, { type: "strike" }), trail));
454
+ break;
455
+ case "break":
456
+ out.push({ type: "hardBreak" });
457
+ break;
458
+ case "link": {
459
+ const mark = {
460
+ type: "link",
461
+ attrs: { href: node.url, ...(node.title ? { title: node.title } : {}) },
462
+ };
463
+ out.push(...this.inline(node.children, withMark(marks, mark), trail));
464
+ break;
465
+ }
466
+ case "linkReference": {
467
+ const definition = this.definitions.get(node.identifier);
468
+ // Always resolves: CommonMark makes an unresolved reference literal
469
+ // text, so remark never produces a reference node without a matching
470
+ // definition. Kept as a fallback rather than an assertion, but there
471
+ // is no diagnostic for it because nothing can reach it.
472
+ if (!definition) {
473
+ out.push(...this.inline(node.children, marks, trail));
474
+ break;
475
+ }
476
+ const mark = {
477
+ type: "link",
478
+ attrs: {
479
+ href: definition.url,
480
+ ...(definition.title ? { title: definition.title } : {}),
481
+ },
482
+ };
483
+ out.push(...this.inline(node.children, withMark(marks, mark), trail));
484
+ break;
485
+ }
486
+ case "image": {
487
+ // Reached only inside a container that cannot hold a mediaSingle, such
488
+ // as a table cell. The alt text keeps a link to the source.
489
+ this.sink.add({
490
+ code: CODES.paragraphSplit,
491
+ quality: "approximate",
492
+ message: "An image in inline-only content became a link to its source",
493
+ node: "image",
494
+ location: trail.join("/"),
495
+ });
496
+ const mark = { type: "link", attrs: { href: node.url } };
497
+ out.push(text(node.alt ?? node.url, withMark(marks, mark)));
498
+ break;
499
+ }
500
+ case "imageReference": {
501
+ const definition = this.definitions.get(node.identifier);
502
+ // Unreachable for the same reason as linkReference above.
503
+ if (!definition) {
504
+ out.push(text(node.alt ?? node.identifier, marks));
505
+ break;
506
+ }
507
+ const mark = { type: "link", attrs: { href: definition.url } };
508
+ out.push(text(node.alt ?? definition.url, withMark(marks, mark)));
509
+ break;
510
+ }
511
+ case "footnoteReference": {
512
+ this.sink.add({
513
+ code: CODES.footnoteApproximated,
514
+ quality: "approximate",
515
+ message: "ADF has no footnotes, so the marker became superscript text and the definition moved to the end",
516
+ node: "footnoteReference",
517
+ location: trail.join("/"),
518
+ });
519
+ const label = node.label ?? node.identifier;
520
+ out.push(text(label, withMark(marks, { type: "subsup", attrs: { type: "sup" } })));
521
+ break;
522
+ }
523
+ case "html": {
524
+ this.sink.add({
525
+ code: CODES.htmlPreserved,
526
+ quality: "approximate",
527
+ message: "ADF has no raw HTML, so inline markup was preserved as inline code",
528
+ node: "html",
529
+ location: trail.join("/"),
530
+ });
531
+ out.push(text(node.value, withMark(marks, { type: "code" })));
532
+ break;
533
+ }
534
+ default: {
535
+ // Exhaustive over mdast's own phrasing types, so this is reached only
536
+ // if a future parser plugin introduces one. Contribute its text rather
537
+ // than taking the content with it.
538
+ const unknown = node;
539
+ if (unknown.children)
540
+ out.push(...this.inline(unknown.children, marks, trail));
541
+ else if (typeof unknown.value === "string" && unknown.value !== "")
542
+ out.push(text(unknown.value, marks));
543
+ }
544
+ }
545
+ }
546
+ return out;
547
+ }
548
+ }
549
+ function collect(root, definitions, footnotes) {
550
+ const walk = (nodes) => {
551
+ for (const node of nodes) {
552
+ if (node.type === "definition")
553
+ definitions.set(node.identifier, node);
554
+ else if (node.type === "footnoteDefinition")
555
+ footnotes.push(node);
556
+ if ("children" in node)
557
+ walk(node.children);
558
+ }
559
+ };
560
+ walk(root.children);
561
+ }
562
+ /** The text of an ADF inline run, with `strong` applied. */
563
+ function boldRun(content) {
564
+ const value = adfText(content);
565
+ return value === "" ? [] : [text(value, [{ type: "strong" }])];
566
+ }
567
+ function adfText(nodes) {
568
+ return nodes.map((node) => node.text ?? adfText(node.content ?? [])).join("");
569
+ }
570
+ /** Joins a table row's cells into one inline run, separated by a pipe. */
571
+ function joinCells(cells) {
572
+ const parts = cells.map((cell) => adfText(cell.content ?? []));
573
+ const value = parts.join(" | ");
574
+ return value === "" ? [] : [text(value)];
575
+ }
576
+ function flattenBlockToInline(block) {
577
+ if ("children" in block) {
578
+ const value = mdastText(block.children);
579
+ return value === "" ? [] : [text(value)];
580
+ }
581
+ if ("value" in block && typeof block.value === "string" && block.value !== "")
582
+ return [text(block.value)];
583
+ return [];
584
+ }
585
+ /**
586
+ * Drops whitespace-only nodes at both ends of an inline run and trims the text
587
+ * at the edges. Only used where a run is produced by splitting, never on author
588
+ * text that stands on its own.
589
+ */
590
+ function trimEdges(nodes) {
591
+ const out = [...nodes];
592
+ while (out.length && isBlank(out[0]))
593
+ out.shift();
594
+ while (out.length && isBlank(out[out.length - 1]))
595
+ out.pop();
596
+ if (out.length) {
597
+ const first = out[0];
598
+ if (first.type === "text" && first.text !== undefined)
599
+ out[0] = { ...first, text: first.text.replace(/^\s+/, "") };
600
+ const last = out[out.length - 1];
601
+ if (last.type === "text" && last.text !== undefined)
602
+ out[out.length - 1] = { ...last, text: last.text.replace(/\s+$/, "") };
603
+ }
604
+ return out.filter((node) => node.type !== "text" || node.text !== "");
605
+ }
606
+ function isBlank(node) {
607
+ return node.type === "text" && (node.text ?? "").trim() === "";
608
+ }
609
+ function mdastText(nodes) {
610
+ return nodes
611
+ .map((node) => {
612
+ if ("value" in node && typeof node.value === "string")
613
+ return node.value;
614
+ if ("children" in node)
615
+ return mdastText(node.children);
616
+ return "";
617
+ })
618
+ .join(" ")
619
+ .trim();
620
+ }
621
+ /**
622
+ * A `taskList` as a `bulletList`, each item keeping a literal state prefix.
623
+ *
624
+ * The prefix keeps the state visible to a reader. It does not survive a round
625
+ * trip as a checkbox: converting back to Markdown escapes the bracket to
626
+ * `\[x]`, because an unescaped one at the start of a list item would silently
627
+ * become a task item again. Visible, not reversible.
628
+ */
629
+ function downgradeTaskList(node) {
630
+ return {
631
+ type: "bulletList",
632
+ content: (node.content ?? []).map((item) => ({
633
+ type: "listItem",
634
+ content: [
635
+ {
636
+ type: "paragraph",
637
+ content: [text(item.attrs?.state === "DONE" ? "[x] " : "[ ] "), ...(item.content ?? [])],
638
+ },
639
+ ],
640
+ })),
641
+ };
642
+ }
643
+ export function fromMarkdown(markdown) {
644
+ // Must be the frontmatter-aware parser. Under the plain one, `---\ntitle: x\n---`
645
+ // parses as a thematic break plus a level-2 heading, so the frontmatter does
646
+ // not go missing — it converts into a rule and a heading reading "title: x".
647
+ const converter = new Converter(parseMarkdown(markdown));
648
+ return { document: converter.convert(), diagnostics: converter.sink.all() };
649
+ }
650
+ //# sourceMappingURL=from-markdown.js.map