@markup-carve/pandoc-carve 0.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.
@@ -0,0 +1,1457 @@
1
+ /**
2
+ * Pandoc JSON AST -> Carve AST.
3
+ *
4
+ * The inverse of convert.ts: walks a Pandoc document (api 1.23) and builds
5
+ * the AST shape `@markup-carve/carve`'s `parse()` produces, so `renderCarve()`
6
+ * (the `carve fmt` serializer) can turn it into Carve source. Spans that
7
+ * convert.ts emits as classed degradations (mention, tag, symbol, ext-*, ...)
8
+ * are recognized and restored to their native Carve nodes, which makes
9
+ * carve -> pandoc -> carve round-trips clean.
10
+ */
11
+ import { parse as parseCarve, renderCarve } from '@markup-carve/carve';
12
+ /**
13
+ * The node type THIS engine uses for an editorial comment.
14
+ *
15
+ * carve-js renamed `critic-comment` to `critic_comment`, and the nodes this
16
+ * module builds go straight to that engine's own writer - which accepts only
17
+ * its current spelling and throws on the other. A constant cannot satisfy both,
18
+ * and both are in the wild: the published release wants the hyphen, main wants
19
+ * the underscore.
20
+ *
21
+ * So it is PROBED rather than assumed, the same way carve-lsp probes the column
22
+ * unit. Parse one editorial comment and read back what the engine called it.
23
+ */
24
+ let criticCommentType;
25
+ function criticCommentNodeType() {
26
+ if (criticCommentType)
27
+ return criticCommentType;
28
+ criticCommentType = 'critic_comment';
29
+ try {
30
+ const probe = parseCarve('{# c #}\n');
31
+ const para = (probe.children ?? [])[0];
32
+ const found = (para?.children ?? []).find((n) => String(n?.type ?? '').includes('critic'));
33
+ if (found?.type)
34
+ criticCommentType = found.type;
35
+ }
36
+ catch {
37
+ // Keep the current spelling: an engine too old to parse the probe is
38
+ // older than either name this cares about.
39
+ }
40
+ return criticCommentType;
41
+ }
42
+ function warn(ctx, msg) {
43
+ ctx.warnings.push(msg);
44
+ }
45
+ function fromAttr(a) {
46
+ if (!a)
47
+ return undefined;
48
+ const [id, classes, kvs] = a;
49
+ const attrs = {};
50
+ const order = [];
51
+ if (id) {
52
+ attrs.id = id;
53
+ order.push('#id');
54
+ }
55
+ if (classes.length) {
56
+ attrs.classes = classes;
57
+ order.push('.class');
58
+ }
59
+ if (kvs.length) {
60
+ attrs.keyValues = Object.fromEntries(kvs);
61
+ order.push('key');
62
+ }
63
+ if (!order.length)
64
+ return undefined;
65
+ attrs.order = order;
66
+ return attrs;
67
+ }
68
+ /**
69
+ * Attrs for two Pandoc nodes that collapse into ONE Carve node: `outer` wins
70
+ * per field (it was the referenceable one), classes union, key/values merge
71
+ * with the outer taking precedence. `order` is recomputed from what survived.
72
+ */
73
+ function mergeCAttrs(inner, outer) {
74
+ const id = outer?.id ?? inner?.id;
75
+ const classes = [...(inner?.classes ?? []), ...(outer?.classes ?? [])].filter((c, i, all) => all.indexOf(c) === i);
76
+ const keyValues = { ...(inner?.keyValues ?? {}), ...(outer?.keyValues ?? {}) };
77
+ const out = {};
78
+ const order = [];
79
+ if (id) {
80
+ out.id = id;
81
+ order.push('#id');
82
+ }
83
+ if (classes.length) {
84
+ out.classes = classes;
85
+ order.push('.class');
86
+ }
87
+ if (Object.keys(keyValues).length) {
88
+ out.keyValues = keyValues;
89
+ order.push('key');
90
+ }
91
+ if (!order.length)
92
+ return undefined;
93
+ out.order = order;
94
+ return out;
95
+ }
96
+ const text = (value) => ({ type: 'text', value });
97
+ /** Glyph and authored source of each quote mark, keyed by its resolved kind. */
98
+ const QUOTE_MARKS = {
99
+ left_double_quote: { value: '"', glyph: '“' },
100
+ right_double_quote: { value: '"', glyph: '”' },
101
+ left_single_quote: { value: "'", glyph: '‘' },
102
+ right_single_quote: { value: "'", glyph: '’' },
103
+ };
104
+ /**
105
+ * One resolved quote mark.
106
+ *
107
+ * `value` is what an author typed and what `renderCarve` writes, so the source
108
+ * carries a plain `"`; `glyph` is what the parser resolved it to, so a consumer
109
+ * reading the AST alone still gets the curly character. Both fields are what
110
+ * the engine's own parse of `"` produces, which is what makes the emitted
111
+ * source re-parse to an identical node.
112
+ */
113
+ function quoteMark(kind) {
114
+ const mark = QUOTE_MARKS[kind];
115
+ return { type: 'smart_punctuation', kind, value: mark.value, glyph: mark.glyph };
116
+ }
117
+ /** Merge adjacent text nodes so renderCarve sees natural runs. */
118
+ function mergeText(nodes) {
119
+ const out = [];
120
+ for (const n of nodes) {
121
+ const prev = out[out.length - 1];
122
+ if (n.type === 'text' && prev?.type === 'text') {
123
+ prev.value = String(prev.value) + String(n.value);
124
+ }
125
+ else {
126
+ out.push(n);
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+ // --- Inlines ---
132
+ function inlines(ctx, xs) {
133
+ if (!xs)
134
+ return [];
135
+ const out = [];
136
+ for (const x of xs)
137
+ out.push(...inline(ctx, x));
138
+ return mergeText(out);
139
+ }
140
+ function wrapped(ctx, type, xs) {
141
+ return [{ type, children: inlines(ctx, xs) }];
142
+ }
143
+ function inline(ctx, n) {
144
+ const c = n.c;
145
+ switch (n.t) {
146
+ case 'Str':
147
+ return [text(String(c))];
148
+ case 'Space':
149
+ return [text(' ')];
150
+ case 'SoftBreak':
151
+ return [{ type: 'soft_break' }];
152
+ case 'LineBreak':
153
+ return [{ type: 'hard_break' }];
154
+ case 'Emph':
155
+ return wrapped(ctx, 'emphasis', c);
156
+ case 'Strong':
157
+ // Strong[Emph[..]] needs no special case: it reverses to a strong
158
+ // wrapping an emphasis, which is exactly how carve represents
159
+ // bold-italic now that it has no node type of its own.
160
+ return wrapped(ctx, 'strong', c);
161
+ case 'Underline':
162
+ return wrapped(ctx, 'underline', c);
163
+ case 'Strikeout':
164
+ return wrapped(ctx, 'strike', c);
165
+ case 'Superscript':
166
+ return wrapped(ctx, 'superscript', c);
167
+ case 'Subscript':
168
+ return wrapped(ctx, 'subscript', c);
169
+ // POLICY: Carve has no small-caps node and is not getting one - the
170
+ // typographic distinction is a presentation choice, which is what the
171
+ // `.smallcaps` span already carries. The degradation is not one-way:
172
+ // `convert.ts` reads that class back as a pandoc `SmallCaps` (pandoc's
173
+ // own markdown reader defines the same convention), so the construct
174
+ // survives Pandoc -> Carve -> Pandoc intact. The warning stays because
175
+ // a consumer reading the Carve document itself sees a class, not a
176
+ // semantic - it says what the Carve side holds, not that the value is
177
+ // lost.
178
+ case 'SmallCaps':
179
+ warn(ctx, 'SmallCaps has no Carve form - degraded to a .smallcaps span');
180
+ return [
181
+ {
182
+ type: 'span',
183
+ children: inlines(ctx, c),
184
+ attrs: { classes: ['smallcaps'], order: ['.class'] },
185
+ },
186
+ ];
187
+ case 'Quoted': {
188
+ // The two QUOTE MARKS, as the nodes Carve's own parser produces for
189
+ // them - not the literal glyphs this used to write.
190
+ //
191
+ // The old form was a genuine one-way loss, and the note explaining
192
+ // it was wrong about why: Carve does have a node here. `"` and `'`
193
+ // resolve to `smart_punctuation` carrying the KIND
194
+ // (`left_double_quote` ... ), which is what `pairQuotes` on the way
195
+ // back reads to rebuild pandoc's wrapping `Quoted`. Writing the
196
+ // glyph instead threw that kind away - a literal `“` in the source
197
+ // is ordinary text to the parser, so the quotation came back as
198
+ // `Str "“alpha”"`.
199
+ //
200
+ // It is also unambiguous, which is what makes it safe: the writer
201
+ // ESCAPES a quote character that is ordinary text (`it\'s`,
202
+ // `\"x\"`), so an unescaped mark in the emitted source is always
203
+ // one the bridge put there.
204
+ const [kind, xs] = c;
205
+ const single = kind.t === 'SingleQuote';
206
+ return [
207
+ quoteMark(single ? 'left_single_quote' : 'left_double_quote'),
208
+ ...inlines(ctx, xs),
209
+ quoteMark(single ? 'right_single_quote' : 'right_double_quote'),
210
+ ];
211
+ }
212
+ case 'Cite': {
213
+ const [citations, xs] = c;
214
+ return [citeGroup(ctx, citations ?? [], xs ?? [])];
215
+ }
216
+ case 'Code': {
217
+ const [a, s] = c;
218
+ const node = { type: 'code', value: s };
219
+ const attrs = fromAttr(a);
220
+ if (attrs)
221
+ node.attrs = attrs;
222
+ return [node];
223
+ }
224
+ case 'Math': {
225
+ const [style, s] = c;
226
+ return [{ type: 'math', display: style.t === 'DisplayMath', content: s }];
227
+ }
228
+ case 'RawInline': {
229
+ const [format, s] = c;
230
+ return [{ type: 'raw_inline', format, content: s }];
231
+ }
232
+ case 'Link':
233
+ return link(ctx, c);
234
+ case 'Image': {
235
+ const [a, alt, [src, title]] = c;
236
+ const node = { type: 'image', src, alt: stringify(alt) };
237
+ if (title)
238
+ node.title = title;
239
+ const attrs = fromAttr(a);
240
+ if (attrs)
241
+ node.attrs = attrs;
242
+ return [node];
243
+ }
244
+ case 'Note': {
245
+ const noteBlocks = c;
246
+ // The post-split names. carve-js renamed these to
247
+ // `inline_footnote` and `footnote_ref` (markup-carve/carve#405),
248
+ // and 0.1.3 is the release that carries the split, so the engine
249
+ // this produces for now knows them.
250
+ //
251
+ // A producer cannot satisfy both spellings the way a consumer can
252
+ // accept both, because the type string is a single value. That is
253
+ // why this waited for the lockfile to move rather than emitting
254
+ // the new names defensively.
255
+ if (noteBlocks.length === 1 && (noteBlocks[0].t === 'Para' || noteBlocks[0].t === 'Plain')) {
256
+ return [{ type: 'inline_footnote', inline: inlines(ctx, noteBlocks[0].c) }];
257
+ }
258
+ const id = `fn${++ctx.noteCounter}`;
259
+ ctx.footnoteDefs[id] = blocks(ctx, noteBlocks);
260
+ return [{ type: 'footnote_ref', id }];
261
+ }
262
+ case 'Span':
263
+ return span(ctx, c);
264
+ default:
265
+ warn(ctx, `inline: pandoc node "${n.t}" has no Carve mapping - dropped`);
266
+ return [];
267
+ }
268
+ }
269
+ function link(ctx, c) {
270
+ const [a, xs, [href, title]] = c;
271
+ const [, classes] = a;
272
+ if (classes.includes('uri') || classes.includes('email')) {
273
+ return [{ type: 'autolink', href, text: stringify(xs) }];
274
+ }
275
+ if (classes.includes('crossref')) {
276
+ return [{ type: 'heading_ref', target: href.replace(/^#/, '') }];
277
+ }
278
+ const node = { type: 'link', href, children: inlines(ctx, xs) };
279
+ if (title)
280
+ node.title = title;
281
+ const attrs = fromAttr(a);
282
+ if (attrs)
283
+ node.attrs = attrs;
284
+ return [node];
285
+ }
286
+ /** Restore convert.ts's classed-span degradations to native Carve nodes. */
287
+ function span(ctx, c) {
288
+ const [a, xs] = c;
289
+ const [, classes, kvs] = a;
290
+ const kv = Object.fromEntries(kvs);
291
+ const cls = classes[0];
292
+ switch (cls) {
293
+ case 'mark':
294
+ return wrapped(ctx, 'highlight', xs);
295
+ case 'mention':
296
+ if (kv['data-user'])
297
+ return [{ type: 'mention', user: kv['data-user'] }];
298
+ break;
299
+ case 'tag':
300
+ if (kv['data-tag'])
301
+ return [{ type: 'tag', name: kv['data-tag'] }];
302
+ break;
303
+ case 'symbol':
304
+ if (kv['data-symbol'])
305
+ return [{ type: 'symbol', name: kv['data-symbol'] }];
306
+ break;
307
+ case 'abbr':
308
+ if (kv['title']) {
309
+ const abbr = stringify(xs);
310
+ // renderCarve only re-emits `*[abbr]: expansion` definitions;
311
+ // the inline node alone would serialize as plain text.
312
+ ctx.abbrevDefs.set(abbr, kv['title']);
313
+ return [{ type: 'abbreviation', abbr, expansion: kv['title'] }];
314
+ }
315
+ break;
316
+ case 'insertion':
317
+ return wrapped(ctx, 'insert', xs);
318
+ case 'deletion':
319
+ return wrapped(ctx, 'delete', xs);
320
+ case 'substitution': {
321
+ const parts = xs.filter((x) => x.t === 'Span');
322
+ if (parts.length === 2) {
323
+ const [oldC, newC] = parts.map((p) => p.c[1]);
324
+ return [
325
+ { type: 'substitution', oldText: stringify(oldC), newText: stringify(newC) },
326
+ ];
327
+ }
328
+ break;
329
+ }
330
+ case 'comment-annotation':
331
+ return [{ type: criticCommentNodeType(), text: stringify(xs) }];
332
+ }
333
+ if (cls?.startsWith('ext-')) {
334
+ return [{ type: 'inline_extension', name: cls.slice(4), content: inlines(ctx, xs) }];
335
+ }
336
+ const node = { type: 'span', children: inlines(ctx, xs) };
337
+ const attrs = fromAttr(a);
338
+ if (attrs)
339
+ node.attrs = attrs;
340
+ return [node];
341
+ }
342
+ /**
343
+ * pandoc `Cite` -> PART 9 §22 `citation_group`.
344
+ *
345
+ * The inverse of `convert.ts`'s `citationGroup`. Two asymmetries:
346
+ *
347
+ * - **Mode.** Pandoc carries one mode per item; Carve's integral `+` is a
348
+ * cluster property. A group is integral when any item is `AuthorInText` -
349
+ * which is exactly the shape the forward direction emits, since an integral
350
+ * group's suppressed items become `SuppressAuthor` and never
351
+ * `NormalCitation`. A foreign group that mixes `AuthorInText` with
352
+ * `NormalCitation` cannot be spelled and is reported.
353
+ * - **Locator typing.** `locatorLabel`/`locatorValue` are NOT rebuilt here.
354
+ * They are derived fields: §4.2's label table lives in the engine, and a
355
+ * second copy in this bridge is the drift this tracker exists to kill. The
356
+ * locator TEXT round-trips, so parsing the emitted source with the citations
357
+ * extension re-derives the same pair.
358
+ */
359
+ function citeGroup(ctx, citations, content) {
360
+ const modes = citations.map((cit) => cit.citationMode?.t ?? 'NormalCitation');
361
+ const keys = citations.map((cit) => String(cit.citationId ?? ''));
362
+ // `raw` is required by the schema AND is what `renderCarve` writes out
363
+ // verbatim, so it decides whether the source round-trips byte for byte.
364
+ // Pandoc's own markdown reader stores the source here too, which is why
365
+ // recovering it beats rebuilding it - but only while it still DESCRIBES the
366
+ // records, since a filter may have rewritten them and left the display text
367
+ // behind.
368
+ const recovered = carveShapedSource(content, keys);
369
+ // Pandoc's markdown reader has no integral marker: it reads Carve's `[+@k]`
370
+ // as `NormalCitation` with a `+` prefix. The recovered source is the only
371
+ // place that fact survives, and reading it back is what keeps `mode` from
372
+ // contradicting the `raw` sitting next to it.
373
+ const integral = modes.includes('AuthorInText') || (recovered?.startsWith('[+') ?? false);
374
+ if (integral && modes.includes('NormalCitation') && !recovered?.startsWith('[+')) {
375
+ warn(ctx, 'Cite mixes AuthorInText with NormalCitation - Carve\'s integral marker is a property of the whole group, so the group is emitted as integral');
376
+ }
377
+ if (!ctx.bibliographyWarned) {
378
+ ctx.bibliographyWarned = true;
379
+ warn(ctx, 'Cite mapped to a Carve citation group; bibliography entries live in pandoc metadata, so no `[@key]:` definitions are emitted and an undefined key renders verbatim');
380
+ }
381
+ const items = citations.map((cit) => {
382
+ // A citation item is a plain object, not a node: it has no `type`.
383
+ const item = {
384
+ key: String(cit.citationId ?? ''),
385
+ suppressAuthor: (cit.citationMode?.t ?? '') === 'SuppressAuthor',
386
+ };
387
+ const prefix = citationPrefixNodes(ctx, cit.citationPrefix);
388
+ if (prefix.length)
389
+ item.prefix = prefix;
390
+ const locator = locatorNodes(ctx, cit.citationSuffix);
391
+ if (locator.length)
392
+ item.locator = locator;
393
+ return item;
394
+ });
395
+ const group = { type: 'citation_group', items };
396
+ group.raw = recovered ?? synthesizeRaw(items, integral);
397
+ if (integral)
398
+ group.mode = 'integral';
399
+ return group;
400
+ }
401
+ /**
402
+ * The `+` pandoc's markdown reader leaves in the prefix when it reads Carve's
403
+ * own integral spelling is the group marker, not prose - it is read off the
404
+ * modes instead, so carrying it here would print `[+see +@k]`.
405
+ */
406
+ function citationPrefixNodes(ctx, prefix) {
407
+ if (!prefix?.length)
408
+ return [];
409
+ const stripped = prefix[0]?.t === 'Str' && String(prefix[0].c) === '+' ? prefix.slice(1) : prefix;
410
+ const nodes = mergeText(inlines(ctx, stripped));
411
+ const first = nodes[0];
412
+ if (first?.type === 'text') {
413
+ const trimmed = String(first.value ?? '').replace(/^\s+/, '');
414
+ if (!trimmed)
415
+ return nodes.slice(1);
416
+ first.value = trimmed;
417
+ }
418
+ return nodes;
419
+ }
420
+ /** Drop citeproc's `, ` separator; what remains is Carve's locator run. */
421
+ function locatorNodes(ctx, suffix) {
422
+ if (!suffix?.length)
423
+ return [];
424
+ const nodes = mergeText(inlines(ctx, suffix));
425
+ const first = nodes[0];
426
+ if (first?.type === 'text') {
427
+ const trimmed = String(first.value ?? '').replace(/^\s*,?\s*/, '');
428
+ if (!trimmed)
429
+ return nodes.slice(1);
430
+ first.value = trimmed;
431
+ }
432
+ return nodes;
433
+ }
434
+ /**
435
+ * A Cite's content is Carve source when it is a tail-less bracket holding
436
+ * exactly this Cite's keys, in order - the shape §4.1 claims. Two things fail
437
+ * the test and both must: a rendered "(Smith 2020)" out of a docx is prose
438
+ * about the citation rather than the citation, and content a filter left stale
439
+ * after rewriting the records would otherwise be written back as source and
440
+ * silently restore the old keys on the next parse.
441
+ */
442
+ function carveShapedSource(content, keys) {
443
+ const raw = stringify(content).trim();
444
+ if (!raw.startsWith('[') || !raw.endsWith(']'))
445
+ return null;
446
+ return sameKeys(citedKeys(raw), keys) ? raw : null;
447
+ }
448
+ /** The `@key` run of a Carve citation group; `\@` is literal and not a key. */
449
+ function citedKeys(raw) {
450
+ const out = [];
451
+ for (const m of raw.matchAll(/(^|[^\\])@([^\s;,\]]+)/g)) {
452
+ out.push(String(m[2]).replace(/[.,;:]+$/, ''));
453
+ }
454
+ return out;
455
+ }
456
+ function sameKeys(found, keys) {
457
+ return found.length === keys.length && found.every((k, i) => k === keys[i]);
458
+ }
459
+ function synthesizeRaw(items, integral) {
460
+ const body = items
461
+ .map((item) => {
462
+ const prefix = plainOf(item.prefix);
463
+ const locator = plainOf(item.locator);
464
+ return ((prefix ? `${prefix} ` : '')
465
+ + (item.suppressAuthor ? '-' : '')
466
+ + `@${item.key}`
467
+ + (locator ? `, ${locator}` : ''));
468
+ })
469
+ .join('; ');
470
+ return `[${integral ? '+' : ''}${body}]`;
471
+ }
472
+ function plainOf(nodes) {
473
+ if (!nodes?.length)
474
+ return '';
475
+ return nodes.map((n) => (typeof n.value === 'string' ? n.value : plainOf(n.children))).join('');
476
+ }
477
+ function stringify(xs) {
478
+ let out = '';
479
+ for (const x of xs) {
480
+ if (x.t === 'Str')
481
+ out += String(x.c);
482
+ else if (x.t === 'Space' || x.t === 'SoftBreak')
483
+ out += ' ';
484
+ else if (Array.isArray(x.c))
485
+ out += stringify(x.c);
486
+ else if (Array.isArray(x.c?.[1]))
487
+ out += stringify(x.c[1]);
488
+ }
489
+ return out;
490
+ }
491
+ // --- Blocks ---
492
+ function blocks(ctx, xs) {
493
+ if (!xs)
494
+ return [];
495
+ const out = [];
496
+ for (const x of xs)
497
+ out.push(...block(ctx, x));
498
+ return out;
499
+ }
500
+ const KNOWN_ADMONITIONS = new Set(['note', 'tip', 'warning', 'danger', 'info', 'caution', 'important']);
501
+ function block(ctx, n) {
502
+ const c = n.c;
503
+ switch (n.t) {
504
+ case 'Para':
505
+ case 'Plain':
506
+ return [{ type: 'paragraph', children: inlines(ctx, c) }];
507
+ case 'LineBlock': {
508
+ // Carve HAS a form for this - the `::: |` line block (PART 9 SS23) -
509
+ // and the warning here used to say it did not, while flattening the
510
+ // verse into one paragraph of hard breaks. An EMPTY line is a stanza
511
+ // break, which is a blank line between the paragraphs of the block.
512
+ const lines = c;
513
+ const stanzas = [[]];
514
+ for (const line of lines) {
515
+ if (line.length === 0) {
516
+ stanzas.push([]);
517
+ continue;
518
+ }
519
+ const stanza = stanzas[stanzas.length - 1];
520
+ if (stanza.length > 0)
521
+ stanza.push({ type: 'hard_break' });
522
+ stanza.push(...inlines(ctx, line));
523
+ }
524
+ const children = stanzas
525
+ .filter((stanza) => stanza.length > 0)
526
+ .map((stanza) => ({ type: 'paragraph', children: mergeText(stanza) }));
527
+ // The `line_block` NODE of PART 9 §23, which is what the engine's
528
+ // own parser produces for `::: |`. This used to be the
529
+ // `{.line-block}` div instead: `0.1.2`'s writer threw
530
+ // "renderCarve: unknown block line_block", and the two spellings
531
+ // parse to the same rendering, so the div was the one both could
532
+ // carry. `0.1.3` writes the node, and the floor moved to it.
533
+ return [{ type: 'line_block', children }];
534
+ }
535
+ case 'Header': {
536
+ const [level, a, xs] = c;
537
+ const node = { type: 'heading', level, children: inlines(ctx, xs) };
538
+ const attrs = fromAttr(a);
539
+ if (attrs)
540
+ node.attrs = attrs;
541
+ return [node];
542
+ }
543
+ case 'BlockQuote':
544
+ return [{ type: 'block_quote', children: blocks(ctx, c) }];
545
+ case 'CodeBlock': {
546
+ const [a, content] = c;
547
+ const [id, classes, kvs] = a;
548
+ const node = { type: 'code_block', content };
549
+ if (classes[0])
550
+ node.lang = classes[0];
551
+ const kv = Object.fromEntries(kvs);
552
+ if (kv['title'])
553
+ node.header = kv['title'];
554
+ const rest = fromAttr([id, classes.slice(1), kvs.filter(([k]) => k !== 'title')]);
555
+ if (rest)
556
+ node.attrs = rest;
557
+ return [node];
558
+ }
559
+ case 'RawBlock': {
560
+ const [format, content] = c;
561
+ return [{ type: 'raw_block', format, content }];
562
+ }
563
+ case 'HorizontalRule':
564
+ return [{ type: 'thematic_break' }];
565
+ case 'BulletList':
566
+ return [bulletList(ctx, c)];
567
+ case 'OrderedList':
568
+ return [orderedList(ctx, c)];
569
+ case 'DefinitionList':
570
+ return [definitionList(ctx, c)];
571
+ case 'Table':
572
+ return [table(ctx, c, null)];
573
+ case 'Figure':
574
+ return figure(ctx, c);
575
+ case 'Div':
576
+ return div(ctx, c);
577
+ default:
578
+ warn(ctx, `block: pandoc node "${n.t}" has no Carve mapping - dropped`);
579
+ return [];
580
+ }
581
+ }
582
+ // --- Lists ---
583
+ function listItems(ctx, items) {
584
+ let tight = true;
585
+ const converted = items.map((item) => {
586
+ if (item.some((b) => b.t === 'Para'))
587
+ tight = false;
588
+ const children = blocks(ctx, item);
589
+ const node = { type: 'list_item', children };
590
+ stripTaskMarker(node);
591
+ return node;
592
+ });
593
+ return { items: converted, tight };
594
+ }
595
+ /** Detect the ballot-box prefix convert.ts (and pandoc's gfm reader) emit. */
596
+ function stripTaskMarker(item) {
597
+ const first = item.children[0];
598
+ if (first?.type !== 'paragraph')
599
+ return;
600
+ const children = first.children;
601
+ const lead = children[0];
602
+ if (lead?.type !== 'text')
603
+ return;
604
+ const value = String(lead.value);
605
+ const marker = value.startsWith('☒') ? true : value.startsWith('☐') ? false : null;
606
+ if (marker === null)
607
+ return;
608
+ item.checked = marker;
609
+ const rest = value.slice(1).replace(/^ /, '');
610
+ if (rest)
611
+ lead.value = rest;
612
+ else
613
+ children.shift();
614
+ }
615
+ function bulletList(ctx, c) {
616
+ const { items, tight } = listItems(ctx, c);
617
+ return { type: 'list', ordered: false, tight, items };
618
+ }
619
+ const OL_STYLE = {
620
+ LowerAlpha: 'a',
621
+ UpperAlpha: 'A',
622
+ LowerRoman: 'i',
623
+ UpperRoman: 'I',
624
+ };
625
+ function orderedList(ctx, c) {
626
+ const [[start, style, delim], rawItems] = c;
627
+ const { items, tight } = listItems(ctx, rawItems);
628
+ const node = { type: 'list', ordered: true, tight, items };
629
+ if (start !== 1)
630
+ node.start = start;
631
+ const olType = OL_STYLE[style.t];
632
+ if (olType)
633
+ node.olType = olType;
634
+ // Source-style metadata for AST consumers; renderCarve currently
635
+ // normalizes to `1.` regardless (fmt canonical form).
636
+ if (delim?.t === 'OneParen' || delim?.t === 'TwoParens')
637
+ node.delim = ')';
638
+ else if (delim?.t === 'Period')
639
+ node.delim = '.';
640
+ // Pandoc's `Example` list is `(@)` numbering: ONE counter shared by every
641
+ // example list in the document, and items can be labelled and referenced
642
+ // from prose. PART 12 section 6 admits `a`/`A`/`i`/`I` and decimal, so the
643
+ // list becomes an ordinary decimal one.
644
+ //
645
+ // What survives is the numbers: pandoc has already resolved the counter
646
+ // into `start`, so a second example list arrives with `start: 3` and still
647
+ // prints 3. What does not is the counter itself - editing the first list
648
+ // no longer renumbers the second - and the marker, which becomes `1)`.
649
+ // Pandoc always pairs `Example` with `TwoParens`, so this is the one and
650
+ // only diagnostic for that shape; a `(1)` marker on any other style gets
651
+ // its own below.
652
+ if (style.t === 'Example') {
653
+ warn(ctx, 'ordered list: pandoc\'s example-list numbering `(@)` has no Carve form '
654
+ + '- emitted as a decimal list. The resolved numbers are kept, the '
655
+ + 'document-wide counter is not, so the lists no longer renumber '
656
+ + 'each other');
657
+ }
658
+ else if (delim?.t === 'TwoParens') {
659
+ // `(1)` has no Carve spelling either - section 6 has `.` and `)` - so
660
+ // the closing paren is kept and the opening one goes. Reported because
661
+ // the marker the author chose is not the marker that comes out.
662
+ warn(ctx, 'ordered list: the `(1)` marker has no Carve form - emitted as `1)`, '
663
+ + 'without the opening parenthesis');
664
+ }
665
+ return node;
666
+ }
667
+ function definitionList(ctx, c) {
668
+ const rawItems = c;
669
+ const items = rawItems.map(([termInlines, defs]) => {
670
+ // convert.ts joins multiple Carve terms with LineBreak - split them back.
671
+ const terms = [];
672
+ let current = [];
673
+ for (const x of termInlines) {
674
+ if (x.t === 'LineBreak') {
675
+ terms.push(inlines(ctx, current));
676
+ current = [];
677
+ }
678
+ else {
679
+ current.push(x);
680
+ }
681
+ }
682
+ terms.push(inlines(ctx, current));
683
+ return {
684
+ terms,
685
+ definitions: defs.map((d) => blocks(ctx, d)),
686
+ };
687
+ });
688
+ return { type: 'definition_list', items };
689
+ }
690
+ // --- Tables ---
691
+ const ALIGN_BACK = {
692
+ AlignLeft: 'left',
693
+ AlignRight: 'right',
694
+ AlignCenter: 'center',
695
+ };
696
+ /**
697
+ * Whether a partition says anything the flat `rows` cannot.
698
+ *
699
+ * A single body with no intermediate header, no row-head columns, no attrs and
700
+ * no foot IS what every consumer derives from the rows themselves, so emitting
701
+ * it would add a field that carries no information. Absent means exactly that
702
+ * structure (PART 12 §15).
703
+ */
704
+ function carriesMoreThanFlatRows(groups) {
705
+ if (groups.footRows > 0)
706
+ return true;
707
+ if (groups.bodies.length > 1)
708
+ return true;
709
+ return groups.bodies.some((b) => b.headRows > 0 || (b.rowHeadColumns ?? 0) > 0 || b.attrs !== undefined);
710
+ }
711
+ /**
712
+ * Rebuild Carve's grid model: pandoc puts rowSpan/colSpan on the origin Cell
713
+ * and omits covered positions; Carve lists every position and marks covered
714
+ * ones as continuation cells (`span: "colspan" | "rowspan"`).
715
+ */
716
+ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
717
+ const [a, capt, colspecs, thead, tbodies, tfoot] = c;
718
+ // POLICY: a ColSpec's alignment is read, its ColWidth is dropped, and the
719
+ // drop is deliberate and silent. Carve's table model has no width slot at
720
+ // any level (nothing named `width` appears in `resources/ast-schema.json`)
721
+ // and PART 9 §16's pipe-table source cannot spell one, so there is nowhere
722
+ // to put the number and no syntax that could reproduce it. No diagnostic,
723
+ // because the width is usually not the author's: pandoc DERIVES a ColWidth
724
+ // for every grid and multiline table from the ASCII column widths, so a
725
+ // warning would fire on the ordinary case and report a value nobody chose.
726
+ const colAligns = colspecs.map((cs) => ALIGN_BACK[cs[0].t] ?? '');
727
+ const nCols = colspecs.length;
728
+ const headRaw = thead[1];
729
+ const footRaw = tfoot[1];
730
+ // Carve spells COLUMN alignment on the header cell marker (`|=>Name|`), so
731
+ // a table with no LEADING header row has nowhere to put it - and pandoc's
732
+ // grid tables are allowed to be headerless while still carrying alignment.
733
+ // The alignment is written onto every cell of the column instead, which
734
+ // renders the same and is the only slot left, and the move is reported
735
+ // because the column-level fact becomes a per-cell one.
736
+ //
737
+ // Only pandoc's `TableHead` counts. A body's INTERMEDIATE header row is a
738
+ // `|=` row in the middle of the table, and the engine reads that as a row
739
+ // header (`<th scope="row">`) whose alignment applies to itself alone -
740
+ // measured, not assumed. Counting it here would leave the surrounding body
741
+ // cells unaligned and suppress the diagnostic that says so.
742
+ const hasHeaderRow = headRaw.length > 0;
743
+ // The flat `rows` Carve carries, plus the counts that say where pandoc's
744
+ // sections were. A body's intermediate header rows are header rows too,
745
+ // which is why §15 states the head count instead of deriving it from the
746
+ // leading run.
747
+ const allRaw = [...headRaw];
748
+ const isHeaderRow = headRaw.map(() => true);
749
+ const groupBodies = [];
750
+ for (const body of tbodies) {
751
+ const bodyHead = body[2];
752
+ const bodyRows = body[3];
753
+ allRaw.push(...bodyHead, ...bodyRows);
754
+ isHeaderRow.push(...bodyHead.map(() => true), ...bodyRows.map(() => false));
755
+ const group = { headRows: bodyHead.length, bodyRows: bodyRows.length };
756
+ const rowHeadColumns = body[1];
757
+ if (typeof rowHeadColumns === 'number' && rowHeadColumns > 0) {
758
+ group.rowHeadColumns = rowHeadColumns;
759
+ }
760
+ const bodyAttrs = fromAttr(body[0]);
761
+ if (bodyAttrs)
762
+ group.attrs = bodyAttrs;
763
+ groupBodies.push(group);
764
+ }
765
+ allRaw.push(...footRaw);
766
+ isHeaderRow.push(...footRaw.map(() => false));
767
+ // Occupancy grid: pending[r][c] = continuation marker owed at that position.
768
+ // Decided BEFORE the grid walk: the pipe path flattens as it goes and warns
769
+ // while doing it, and that warning is wrong for a table that is about to be
770
+ // emitted as a list-table instead.
771
+ const hasBlockCells = allRaw.some((row) => row[1].some((raw) => {
772
+ const cellBlocks = raw[4];
773
+ return Array.isArray(cellBlocks) && !isInlineShaped(cellBlocks);
774
+ }));
775
+ // ROW-HEAD COLUMNS are the second reason to leave the pipe form, and the
776
+ // only one that is a straight gain: `header-cols=N` (extensions.md §5.1) IS
777
+ // pandoc's `RowHeadColumns`, so the list-table spells exactly what the pipe
778
+ // table drops. The other things `rowGroups` can carry - a foot, a second
779
+ // body, a body's intermediate header rows - have no list-table spelling
780
+ // either, so a table that has only those keeps the readable pipe form and
781
+ // `reportUnspellableGroups` names the loss instead.
782
+ //
783
+ // ONLY WHEN THE BODIES AGREE. `header-cols` is one number for the whole
784
+ // table, so a table whose bodies differ - and `1` beside a plain `0` is the
785
+ // ordinary way they differ - would come back with row headers ADDED to the
786
+ // bodies that had none. Widening the markup is worse than the flattening it
787
+ // was meant to avoid: dropping a row header renders a `td` where a `th`
788
+ // belonged, while inventing one asserts a heading the source never made.
789
+ // Such a table keeps the pipe form, where the loss is only a loss and is
790
+ // reported. A table forced onto the list-table path by its block cells has
791
+ // no such choice, and `listTable` reports the widening there instead.
792
+ const rowHeadCounts = new Set(groupBodies.map((b) => b.rowHeadColumns ?? 0));
793
+ const hasRowHeadColumns = ctx.target === 'source'
794
+ && rowHeadCounts.size === 1
795
+ && (groupBodies[0]?.rowHeadColumns ?? 0) > 0;
796
+ const useListTable = hasBlockCells || hasRowHeadColumns;
797
+ const pending = allRaw.map(() => Array(nCols));
798
+ const rows = [];
799
+ // The pandoc blocks that sat at each grid position, kept alongside the
800
+ // inline-flattened cells so the list-table fallback below can convert them
801
+ // as blocks without walking the occupancy grid a second time.
802
+ const cellBlocksAt = allRaw.map(() => []);
803
+ for (let r = 0; r < allRaw.length; r++) {
804
+ const isHeader = isHeaderRow[r] === true;
805
+ const cells = [];
806
+ const rawCells = allRaw[r][1];
807
+ let rawIdx = 0;
808
+ for (let col = 0; col < nCols; col++) {
809
+ if (pending[r][col]) {
810
+ cells.push({ type: 'table_cell', header: isHeader, children: [], span: 'rowspan' });
811
+ continue;
812
+ }
813
+ const raw = rawCells[rawIdx++];
814
+ if (!raw) {
815
+ cells.push({ type: 'table_cell', header: isHeader, children: [] });
816
+ continue;
817
+ }
818
+ const [cellAttr, cellAlign, rowSpan, colSpan, cellBlocks] = raw;
819
+ cellBlocksAt[r][col] = cellBlocks;
820
+ const cell = {
821
+ type: 'table_cell',
822
+ header: isHeader,
823
+ children: useListTable ? [] : cellInlines(ctx, cellBlocks),
824
+ };
825
+ const align = ALIGN_BACK[cellAlign.t]
826
+ ?? (isHeader || !hasHeaderRow ? colAligns[col] : '');
827
+ if (align)
828
+ cell.align = align;
829
+ const cellAttrs = fromAttr(cellAttr);
830
+ if (cellAttrs)
831
+ cell.attrs = cellAttrs;
832
+ cells.push(cell);
833
+ for (let j = 1; j < colSpan && col + j < nCols; j++) {
834
+ cells.push({ type: 'table_cell', header: isHeader, children: [], span: 'colspan' });
835
+ }
836
+ // A 2D block gets a rowspan continuation at EVERY covered column
837
+ // of the lower rows - that is how Carve's grid expresses it.
838
+ for (let k = 1; k < rowSpan && r + k < allRaw.length; k++) {
839
+ for (let j = 0; j < colSpan && col + j < nCols; j++) {
840
+ pending[r + k][col + j] = 'rowspan';
841
+ }
842
+ }
843
+ col += colSpan - 1;
844
+ }
845
+ const rowNode = { type: 'table_row', cells };
846
+ // Pandoc gives a Row its own Attr and Carve spells it after the closing
847
+ // pipe (`| a | b |{.cls}`), so it is the same slot, not a degradation.
848
+ const rowAttrs = fromAttr(allRaw[r][0]);
849
+ if (rowAttrs)
850
+ rowNode.attrs = rowAttrs;
851
+ rows.push(rowNode);
852
+ }
853
+ const captionInlinesFor = () => captionOverride ?? captionFromBlocks(ctx, capt[1]);
854
+ if (useListTable) {
855
+ const short = shortCaptionOverride ?? captionFromInlines(ctx, capt[0]);
856
+ if (short?.length) {
857
+ warn(ctx, 'list-table: the short caption is dropped - the extension has one quoted title and no second slot');
858
+ }
859
+ return listTable(ctx, {
860
+ rows,
861
+ cellBlocksAt,
862
+ headRows: headRaw.length,
863
+ footRows: footRaw.length,
864
+ bodies: groupBodies,
865
+ caption: captionInlinesFor(),
866
+ attrs: fromAttr(a),
867
+ colAligns,
868
+ reason: hasBlockCells ? 'block-cells' : 'row-head-columns',
869
+ });
870
+ }
871
+ if (!hasHeaderRow && colAligns.some((a) => a)) {
872
+ warn(ctx, 'table: column alignment on a table with no header row is written on '
873
+ + 'each cell instead - Carve spells column alignment on the header '
874
+ + 'marker (`|=> Name |`), and there is no header to carry it');
875
+ }
876
+ const node = { type: 'table', rows };
877
+ // The counts come from the same arrays `rows` was built from, one row
878
+ // pushed per raw row, so §15's sum holds by construction here. It is
879
+ // checked where it can actually fail instead: on a partition that arrived
880
+ // from outside (see readRowGroups).
881
+ const groups = { headRows: headRaw.length, bodies: groupBodies, footRows: footRaw.length };
882
+ if (carriesMoreThanFlatRows(groups)) {
883
+ node.rowGroups = groups;
884
+ reportUnspellableGroups(ctx, groups);
885
+ }
886
+ const attrs = fromAttr(a);
887
+ if (attrs)
888
+ node.attrs = attrs;
889
+ const captionInlines = captionInlinesFor();
890
+ if (captionInlines?.length)
891
+ node.caption = captionInlines;
892
+ const shortCaption = shortCaptionOverride
893
+ ?? captionFromInlines(ctx, capt[0]);
894
+ if (shortCaption?.length)
895
+ node.shortCaption = shortCaption;
896
+ return node;
897
+ }
898
+ /**
899
+ * What a `rowGroups` partition says that the PIPE form cannot say back.
900
+ *
901
+ * The partition itself is not a degradation - it reaches the exchange AST
902
+ * intact, and `pandocToCarveAst` hands it on whole. The loss happens one step
903
+ * later, in the source writer: PART 9 §16's pipe table spells a leading run of
904
+ * header rows and nothing else, so a foot, a second body group, a body's own
905
+ * intermediate header rows, its row-head columns and its attributes all come
906
+ * out as ordinary body rows. PART 12 §15 says so in as many words and asks for
907
+ * exactly this - "a canonical Carve writer loses it ... conversion APIs with
908
+ * diagnostics should report that loss".
909
+ *
910
+ * The wording follows the `shortCaption` precedent: it names where the value
911
+ * DOES survive, because on the `pandocToCarveAst` path nothing is lost at all
912
+ * and a bare "dropped" would be false there. The list-table path reports the
913
+ * same facts in its own vocabulary; this is the pipe path's half of it, which
914
+ * was silent.
915
+ */
916
+ function reportUnspellableGroups(ctx, groups) {
917
+ const lost = [];
918
+ if (groups.footRows > 0)
919
+ lost.push(`a foot of ${groups.footRows} row(s)`);
920
+ if (groups.bodies.length > 1)
921
+ lost.push(`${groups.bodies.length} body groups`);
922
+ if (groups.bodies.some((b) => b.headRows > 0))
923
+ lost.push("a body's intermediate header rows");
924
+ if (groups.bodies.some((b) => (b.rowHeadColumns ?? 0) > 0))
925
+ lost.push('row-head columns');
926
+ if (groups.bodies.some((b) => b.attrs !== undefined))
927
+ lost.push("a body group's attributes");
928
+ if (!lost.length)
929
+ return;
930
+ warn(ctx, `table: ${lost.join(', ')} - preserved in the Carve AST as \`rowGroups\`, `
931
+ + 'but a pipe table spells only a leading run of header rows, so the '
932
+ + 'emitted source flattens them into body rows');
933
+ }
934
+ /**
935
+ * Whether a cell's blocks fit Carve's pipe-table cell, which holds INLINES.
936
+ *
937
+ * One `Plain`/`Para` fits. Two do not: the joining soft break serializes as a
938
+ * literal newline inside the row, which splits the table at that line. Anything
939
+ * else (a list, a code block, a nested table) has no inline form at all.
940
+ */
941
+ function isInlineShaped(cellBlocks) {
942
+ if (cellBlocks.length === 0)
943
+ return true;
944
+ if (cellBlocks.length > 1)
945
+ return false;
946
+ const only = cellBlocks[0].t;
947
+ return only === 'Plain' || only === 'Para';
948
+ }
949
+ /**
950
+ * A pandoc table with block content in a cell, as `::: list-table`.
951
+ *
952
+ * PART 9 §16's pipe-table cell holds inlines, so a docx or LaTeX table with a
953
+ * list or two paragraphs in a cell has no pipe form. Flattening it was not a
954
+ * degradation but a loss: measured on a pandoc grid table, a `BulletList` cell
955
+ * emitted NOTHING (`stringifyBlocks` walks `{t, c}` nodes and a list's `c` is a
956
+ * list of block LISTS), and a two-paragraph cell put a literal newline inside
957
+ * the row, so the two-row table re-parsed as a one-row table plus a paragraph.
958
+ *
959
+ * The list-table extension (extensions.md §5) exists for exactly this shape:
960
+ * cells are list items, so they hold full block content. It is lossless in
961
+ * structure. Three things it cannot spell are reported rather than dropped
962
+ * quietly: per-column alignment (§5.5 leaves it out), a foot, and a body's
963
+ * intermediate header rows.
964
+ */
965
+ function listTable(ctx, input) {
966
+ const { rows, cellBlocksAt, headRows, footRows, bodies, caption, attrs, colAligns, reason } = input;
967
+ warn(ctx, reason === 'block-cells'
968
+ ? 'table: a cell holds block content, which a pipe table cannot spell - emitted as a `::: list-table` (structure preserved)'
969
+ : 'table: row-head columns, which a pipe table cannot spell - emitted as a `::: list-table` with `header-cols` (structure preserved)');
970
+ if (colAligns.some((a) => a)) {
971
+ warn(ctx, 'list-table: per-column alignment is dropped - the extension has no alignment marker (extensions.md §5.5)');
972
+ }
973
+ if (footRows > 0) {
974
+ warn(ctx, `list-table: the table's ${footRows} foot row(s) become ordinary body rows - the extension has head rows only`);
975
+ }
976
+ if (bodies.some((b) => b.headRows > 0)) {
977
+ warn(ctx, 'list-table: a body group\'s intermediate header rows become ordinary body rows - `header-rows` counts only the leading run');
978
+ }
979
+ if (bodies.length > 1) {
980
+ warn(ctx, `list-table: the table's ${bodies.length} body groups merge into one - the extension has no body boundary`);
981
+ }
982
+ if (bodies.some((b) => b.attrs)) {
983
+ warn(ctx, 'list-table: a body group\'s attributes are dropped - the extension has no body to hang them on');
984
+ }
985
+ // A body with NO row-head columns disagrees with one that has them, and it
986
+ // is the common way they disagree - so the zeros count here. Leaving them
987
+ // out made the one case that CHANGES the markup the one case that said
988
+ // nothing: `header-cols` applies to every row, so the bodies that had none
989
+ // come back with row headers they never had.
990
+ if (new Set(bodies.map((b) => b.rowHeadColumns ?? 0)).size > 1) {
991
+ warn(ctx, 'list-table: the body groups disagree on their row-head column count - `header-cols` is one number for the whole table, so the first non-zero count is applied to every row, and the rows that had none gain row headers');
992
+ }
993
+ const headerCols = bodies.find((b) => b.rowHeadColumns)?.rowHeadColumns ?? 0;
994
+ const rowItems = rows.map((row, r) => {
995
+ const cells = row.cells ?? [];
996
+ const cellItems = cells.map((cell, c) => {
997
+ // A covered position is a lone `^`/`<` item, the same markers the
998
+ // pipe grid uses (§5.1).
999
+ if (cell.span === 'rowspan')
1000
+ return listItem([paragraphOf('^')]);
1001
+ if (cell.span === 'colspan')
1002
+ return listItem([paragraphOf('<')]);
1003
+ const cellBlocks = cellBlocksAt[r]?.[c];
1004
+ const children = cellBlocks?.length ? blocks(ctx, cellBlocks) : [];
1005
+ return listItem(children.length ? children : [paragraphOf('')]);
1006
+ });
1007
+ return listItem([bulletListOf(cellItems)]);
1008
+ });
1009
+ const node = {
1010
+ type: 'admonition',
1011
+ kind: 'list-table',
1012
+ children: [bulletListOf(rowItems)],
1013
+ };
1014
+ if (caption?.length)
1015
+ node.title = caption;
1016
+ const keyValues = { ...(attrs?.keyValues ?? {}) };
1017
+ const order = [...(attrs?.order ?? [])];
1018
+ const addKey = (key, value) => {
1019
+ if (value <= 0)
1020
+ return;
1021
+ keyValues[key] = String(value);
1022
+ if (!order.includes('key'))
1023
+ order.push('key');
1024
+ };
1025
+ addKey('header-rows', headRows);
1026
+ addKey('header-cols', headerCols);
1027
+ const merged = {};
1028
+ if (attrs?.id)
1029
+ merged.id = attrs.id;
1030
+ if (attrs?.classes?.length)
1031
+ merged.classes = attrs.classes;
1032
+ if (Object.keys(keyValues).length)
1033
+ merged.keyValues = keyValues;
1034
+ if (Object.keys(merged).length) {
1035
+ merged.order = order.length ? order : Object.keys(keyValues).length ? ['key'] : [];
1036
+ node.attrs = merged;
1037
+ }
1038
+ return node;
1039
+ }
1040
+ const listItem = (children) => ({ type: 'list_item', children });
1041
+ const paragraphOf = (value) => ({
1042
+ type: 'paragraph',
1043
+ children: value ? [text(value)] : [],
1044
+ });
1045
+ /**
1046
+ * A list is tight when no item holds more than one block. fmt reads the flag to
1047
+ * decide whether to separate items with a blank line, and a cell holding two
1048
+ * paragraphs needs that line or the second paragraph joins the first. A single
1049
+ * nested list under an item does not - that is every row of a list-table.
1050
+ */
1051
+ const bulletListOf = (items) => ({
1052
+ type: 'list',
1053
+ ordered: false,
1054
+ tight: items.every((item) => (item.children ?? []).length <= 1),
1055
+ items,
1056
+ bulletChar: '-',
1057
+ });
1058
+ function cellInlines(ctx, cellBlocks) {
1059
+ const out = [];
1060
+ cellBlocks.forEach((b, i) => {
1061
+ if (b.t === 'Plain' || b.t === 'Para') {
1062
+ if (i > 0)
1063
+ out.push({ type: 'soft_break' });
1064
+ out.push(...inlines(ctx, b.c));
1065
+ }
1066
+ else {
1067
+ warn(ctx, `table: block-level cell content "${b.t}" flattened to text`);
1068
+ out.push(text(stringifyBlocks([b])));
1069
+ }
1070
+ });
1071
+ return mergeText(out);
1072
+ }
1073
+ function stringifyBlocks(xs) {
1074
+ return xs
1075
+ .map((b) => (Array.isArray(b.c) ? stringify(b.c) : ''))
1076
+ .join(' ')
1077
+ .trim();
1078
+ }
1079
+ function captionFromBlocks(ctx, capBlocks) {
1080
+ if (!capBlocks?.length)
1081
+ return null;
1082
+ const first = capBlocks[0];
1083
+ if (first.t === 'Plain' || first.t === 'Para') {
1084
+ return inlines(ctx, first.c);
1085
+ }
1086
+ return null;
1087
+ }
1088
+ function captionFromInlines(ctx, caption) {
1089
+ return caption?.length ? inlines(ctx, caption) : null;
1090
+ }
1091
+ // --- Figures and divs ---
1092
+ /**
1093
+ * The pandoc blocks a `figure` can wrap, which is `figure.target`'s own list in
1094
+ * `resources/ast-schema.json`: an image, a quote, a table, a code block, a
1095
+ * paragraph (a display-math figure is the paragraph case). Anything else has no
1096
+ * `figure` to become and is unwrapped below.
1097
+ *
1098
+ * This used to be PANEL_HOSTS and was consulted only for a §4c panel, because
1099
+ * outside a group a captioned quote was rerouted to a quote carrying an
1100
+ * attribution and every other single-block Figure fell to the unwrap. With
1101
+ * §4a withdrawn (carve#1213) a captioned host is a `figure` wherever it sits,
1102
+ * so the list is not panel-specific and there is no `asPanel` reading left.
1103
+ */
1104
+ const FIGURE_HOSTS = new Set(['Plain', 'Para', 'BlockQuote', 'Table', 'CodeBlock']);
1105
+ function figure(ctx, c) {
1106
+ const [a, capt, body] = c;
1107
+ const caption = captionFromBlocks(ctx, capt[1]);
1108
+ const shortCaption = captionFromInlines(ctx, capt[0]);
1109
+ const single = body.length === 1 ? body[0] : undefined;
1110
+ if (single?.t === 'Plain' || single?.t === 'Para') {
1111
+ const xs = single.c;
1112
+ if (xs.length === 1 && xs[0].t === 'Image') {
1113
+ const [img] = inline(ctx, xs[0]);
1114
+ const node = { type: 'figure', target: img };
1115
+ if (caption)
1116
+ node.caption = caption;
1117
+ if (shortCaption)
1118
+ node.shortCaption = shortCaption;
1119
+ const attrs = fromAttr(a);
1120
+ if (attrs)
1121
+ node.attrs = attrs;
1122
+ return [node];
1123
+ }
1124
+ }
1125
+ if (single?.t === 'Table') {
1126
+ // The wrapper and the Table collapse into ONE Carve node, so their
1127
+ // attrs merge rather than the inner one silently winning: pandoc's
1128
+ // readers put the label on the Figure, not on the Table it wraps, and
1129
+ // dropping it took the id a `</#id>` resolves against with it. The
1130
+ // outer id wins, classes union, key/values merge with the outer taking
1131
+ // precedence.
1132
+ //
1133
+ // A table is the only host that collapses this way, because it is the
1134
+ // only one Carve gives a caption of its own. Every other host keeps
1135
+ // both nodes - the wrapper stays a `figure` and the host's own attrs
1136
+ // ride on the target - which is what the Div arm below sorts out.
1137
+ //
1138
+ // It matters most for a §4c table PANEL, whose id is what resolves as
1139
+ // the group's number plus a letter.
1140
+ const node = table(ctx, single.c, caption, shortCaption);
1141
+ const outer = fromAttr(a);
1142
+ if (outer)
1143
+ node.attrs = mergeCAttrs(node.attrs, outer);
1144
+ return [node];
1145
+ }
1146
+ // A HOST THAT CARRIES ITS OWN ATTRIBUTES ARRIVES INSIDE A DIV. Pandoc's
1147
+ // BlockQuote, Para and CodeBlock have no Attr slot, so `block()` in the
1148
+ // forward direction wraps an attributed one in a Div. A `div` is not a
1149
+ // legal `figure.target` - `resources/ast-schema.json` lists an image, a
1150
+ // quote, a table, a code block and a paragraph - so a Figure holding one
1151
+ // is that wrapper rather than an authored container, and the attributes
1152
+ // belong on the target. Roundtrip mode marks the wrapper with
1153
+ // `carve-block`; plain mode cannot, which is why the schema's own target
1154
+ // list is what decides here.
1155
+ let host = single;
1156
+ let hostAttrs;
1157
+ if (single?.t === 'Div') {
1158
+ const [divAttr, divBody] = single.c;
1159
+ const only = divBody.length === 1 ? divBody[0] : undefined;
1160
+ if (only && FIGURE_HOSTS.has(only.t)) {
1161
+ host = only;
1162
+ hostAttrs = fromAttr([
1163
+ divAttr[0],
1164
+ divAttr[1],
1165
+ divAttr[2].filter(([k]) => k !== 'carve-block'),
1166
+ ]);
1167
+ }
1168
+ }
1169
+ if (host && FIGURE_HOSTS.has(host.t)) {
1170
+ // A single-host `Figure` is an ordinary `figure` around that host -
1171
+ // the generic captioned wrapper of PART 9 §4b, which is what the
1172
+ // forward direction emits for a captioned quote, code listing or
1173
+ // display-math block alike. A group PANEL is the same shape and used
1174
+ // to be the only way in here.
1175
+ const [target] = block(ctx, host);
1176
+ if (target) {
1177
+ if (hostAttrs) {
1178
+ target.attrs = mergeCAttrs(target.attrs, hostAttrs);
1179
+ }
1180
+ const node = { type: 'figure', target };
1181
+ if (caption)
1182
+ node.caption = caption;
1183
+ if (shortCaption)
1184
+ node.shortCaption = shortCaption;
1185
+ const attrs = fromAttr(a);
1186
+ if (attrs)
1187
+ node.attrs = attrs;
1188
+ return [node];
1189
+ }
1190
+ }
1191
+ if (body.some((b) => b.t === 'Figure' || b.t === 'Table')) {
1192
+ // A Figure whose blocks are themselves Figures (or Tables) is pandoc's
1193
+ // SUBFIGURE shape, and PART 9 §4c has the node for it: a `figure_group`
1194
+ // whose direct `figure`/`table` children are the panels, with any other
1195
+ // block preserved in place between them. Before this arm it fell to the
1196
+ // unwrap below, which dropped the grouping entirely and turned the
1197
+ // group caption into a trailing paragraph.
1198
+ //
1199
+ // The single-block Figures above keep their existing mapping on
1200
+ // purpose - a lone captioned image or table is one figure, not a group
1201
+ // of one, and the reader has no way to tell a subfigure-shaped document
1202
+ // from that.
1203
+ const children = body.flatMap((b) => b.t === 'Figure' ? figure(ctx, b.c) : block(ctx, b));
1204
+ const node = { type: 'figure_group', children };
1205
+ if (caption)
1206
+ node.caption = caption;
1207
+ if (shortCaption) {
1208
+ // PART 12 §16: "NO `shortCaption`, NO legends, NO label fields" on
1209
+ // the group - whether it ever reaches there is carve#1118's design
1210
+ // space, and inventing the field here would put a property on the
1211
+ // wire that the schema rejects on ingest (§11).
1212
+ warn(ctx, 'figure group: short caption dropped (a composite figure has no navigation-caption slot)');
1213
+ }
1214
+ const attrs = fromAttr(a);
1215
+ if (attrs)
1216
+ node.attrs = attrs;
1217
+ return [node];
1218
+ }
1219
+ warn(ctx, 'figure: general figure content unwrapped (caption kept as a trailing paragraph)');
1220
+ const out = blocks(ctx, body);
1221
+ if (caption)
1222
+ out.push({ type: 'paragraph', children: caption });
1223
+ return out;
1224
+ }
1225
+ function div(ctx, c) {
1226
+ const [a, body] = c;
1227
+ const [id, classes, rawKvs] = a;
1228
+ // Roundtrip mode carries a fenced div's grouping `[label]` as a kv rather
1229
+ // than a flattened caption; pull it back out and rebuild the label token.
1230
+ // The key's `.` cannot appear in a user-authored Carve attribute (the grammar
1231
+ // rejects dotted keys), so this never consumes real user data.
1232
+ const labelEntry = rawKvs.find(([k]) => k === 'carve.label');
1233
+ const label = labelEntry?.[1];
1234
+ const kvs = label !== undefined ? rawKvs.filter(([k]) => k !== 'carve.label') : rawKvs;
1235
+ // convert.ts's attr-wrapper marker: restore attrs onto the inner block.
1236
+ const marker = kvs.find(([k]) => k === 'carve-block');
1237
+ if (marker && body.length === 1) {
1238
+ const [child] = block(ctx, body[0]);
1239
+ if (child) {
1240
+ const attrs = fromAttr([id, classes, kvs.filter(([k]) => k !== 'carve-block')]);
1241
+ if (attrs)
1242
+ child.attrs = attrs;
1243
+ return [child];
1244
+ }
1245
+ }
1246
+ let kind;
1247
+ let rest = [];
1248
+ if (classes.includes('admonition')) {
1249
+ rest = classes.filter((x) => x !== 'admonition');
1250
+ kind = rest.shift();
1251
+ }
1252
+ else if (classes.length === 1 && KNOWN_ADMONITIONS.has(classes[0])) {
1253
+ kind = classes[0];
1254
+ }
1255
+ if (kind) {
1256
+ const node = { type: 'admonition', kind, children: [] };
1257
+ let children = body;
1258
+ // convert.ts emits the admonition title as a leading Para[Strong[..]].
1259
+ const first = body[0];
1260
+ if (classes.includes('admonition') && first?.t === 'Para') {
1261
+ const xs = first.c;
1262
+ if (xs.length === 1 && xs[0].t === 'Strong') {
1263
+ node.title = inlines(ctx, xs[0].c);
1264
+ children = body.slice(1);
1265
+ }
1266
+ }
1267
+ node.children = blocks(ctx, children);
1268
+ if (label !== undefined)
1269
+ node.label = label;
1270
+ const attrs = fromAttr([id, rest, kvs]);
1271
+ if (attrs)
1272
+ node.attrs = attrs;
1273
+ return [node];
1274
+ }
1275
+ const node = { type: 'div', children: blocks(ctx, body) };
1276
+ if (label !== undefined)
1277
+ node.label = label;
1278
+ const attrs = fromAttr([id, classes, kvs]);
1279
+ if (attrs)
1280
+ node.attrs = attrs;
1281
+ return [node];
1282
+ }
1283
+ // --- Metadata ---
1284
+ function metaToYaml(ctx, meta) {
1285
+ return mappingToYaml(ctx, meta, 0).join('\n');
1286
+ }
1287
+ function yamlScalar(s) {
1288
+ if (/^[A-Za-z0-9][A-Za-z0-9 ._\/-]*$/.test(s))
1289
+ return s;
1290
+ return JSON.stringify(s);
1291
+ }
1292
+ /**
1293
+ * A pandoc `Meta` map as YAML, to whatever depth it has.
1294
+ *
1295
+ * `MetaMap` and a `MetaList` of non-scalars used to hit the `default: return
1296
+ * null` arm and be dropped with a warning, so a `bibliography` map or the
1297
+ * `author: [ - name:, affiliation: ]` every pandoc template reads did not
1298
+ * survive an import at all. A list of plain scalars keeps the flow form
1299
+ * (`tags: [a, b]`), which is what frontmatter conventionally looks like.
1300
+ */
1301
+ function mappingToYaml(ctx, map, depth) {
1302
+ const pad = ' '.repeat(depth);
1303
+ const out = [];
1304
+ for (const [key, value] of Object.entries(map)) {
1305
+ const inline = scalarToYaml(value);
1306
+ if (inline !== null) {
1307
+ out.push(`${pad}${yamlKey(key)}: ${inline}`);
1308
+ continue;
1309
+ }
1310
+ if (value.t === 'MetaBlocks') {
1311
+ const body = blockScalarBody(ctx, value.c, depth + 1);
1312
+ if (body === null) {
1313
+ warn(ctx, `meta: key "${key}" (MetaBlocks) is empty - skipped`);
1314
+ continue;
1315
+ }
1316
+ out.push(`${pad}${yamlKey(key)}: |`, ...body);
1317
+ continue;
1318
+ }
1319
+ const nested = nestedToYaml(ctx, value, depth + 1, key);
1320
+ if (nested === null) {
1321
+ warn(ctx, `meta: key "${key}" (${value.t}) has no YAML form - skipped`);
1322
+ continue;
1323
+ }
1324
+ out.push(`${pad}${yamlKey(key)}:`, ...nested);
1325
+ }
1326
+ return out;
1327
+ }
1328
+ function yamlKey(key) {
1329
+ return /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(key) ? key : JSON.stringify(key);
1330
+ }
1331
+ /**
1332
+ * Block content in metadata as the lines UNDER a `key: |`.
1333
+ *
1334
+ * A YAML literal block scalar keeps every line and every blank line between
1335
+ * them, so the paragraph structure the value has is the paragraph structure it
1336
+ * arrives with - which is the objection that used to make this a skip. The
1337
+ * other half of that objection, that the markup inside "nothing on the reading
1338
+ * side parses", is answered by `parseMeta` reading the scalar back through the
1339
+ * same parser. Pandoc's markdown writer emits exactly this form for the same
1340
+ * value, and its reader turns exactly this form back into `MetaBlocks`.
1341
+ *
1342
+ * Written with the ENGINE's own serializer, so the value carries whatever
1343
+ * `carve fmt` guarantees and is not a second, ad-hoc Carve writer.
1344
+ */
1345
+ function blockScalarBody(ctx, pandocBlocks, depth) {
1346
+ const children = blocks(ctx, pandocBlocks ?? []);
1347
+ if (!children.length)
1348
+ return null;
1349
+ const source = renderCarve({ type: 'document', children });
1350
+ const pad = ' '.repeat(depth);
1351
+ const body = source.replace(/\n+$/, '').split('\n');
1352
+ if (!body.length)
1353
+ return null;
1354
+ // A blank line inside the scalar stays EMPTY rather than padded: trailing
1355
+ // whitespace on an otherwise blank line is what a stricter YAML reader
1356
+ // complains about, and the indentation is set by the non-blank lines.
1357
+ return body.map((line) => (line === '' ? '' : pad + line));
1358
+ }
1359
+ /**
1360
+ * The one-line form of a value, or null when it needs lines of its own.
1361
+ *
1362
+ * `MetaBlocks` deliberately returns null here and is reported by the caller
1363
+ * rather than serialized. This is decision D5(b) of the conversion tracker
1364
+ * (markup-carve/carve#1210): block content inside metadata has no honest YAML
1365
+ * string form - flattening `abstract: |` to one scalar throws away the
1366
+ * paragraph structure, and writing Carve source into a YAML value makes the
1367
+ * frontmatter carry markup that nothing on the reading side parses. The warn is
1368
+ * the honest outcome, and it is policy, not an omission.
1369
+ */
1370
+ function scalarToYaml(value) {
1371
+ switch (value.t) {
1372
+ case 'MetaString':
1373
+ return yamlScalar(String(value.c));
1374
+ case 'MetaBool':
1375
+ return value.c ? 'true' : 'false';
1376
+ case 'MetaInlines':
1377
+ return yamlScalar(stringify(value.c));
1378
+ case 'MetaList': {
1379
+ const items = value.c.map(scalarToYaml);
1380
+ if (items.some((x) => x === null))
1381
+ return null;
1382
+ return `[${items.join(', ')}]`;
1383
+ }
1384
+ case 'MetaMap':
1385
+ // An empty map has no block form - `key:` with nothing under it
1386
+ // reads back as an empty value, not an empty map - so it takes the
1387
+ // flow spelling. A populated one needs lines of its own.
1388
+ return Object.keys(value.c).length ? null : '{}';
1389
+ default:
1390
+ return null;
1391
+ }
1392
+ }
1393
+ function nestedToYaml(ctx, value, depth, key) {
1394
+ if (value.t === 'MetaMap') {
1395
+ const lines = mappingToYaml(ctx, value.c, depth);
1396
+ return lines.length ? lines : null;
1397
+ }
1398
+ if (value.t === 'MetaList')
1399
+ return listToYaml(ctx, value.c, depth, key);
1400
+ return null;
1401
+ }
1402
+ function listToYaml(ctx, items, depth, key) {
1403
+ const pad = ' '.repeat(depth);
1404
+ const out = [];
1405
+ for (const item of items) {
1406
+ const inline = scalarToYaml(item);
1407
+ if (inline !== null) {
1408
+ out.push(`${pad}- ${inline}`);
1409
+ continue;
1410
+ }
1411
+ const nested = nestedToYaml(ctx, item, depth + 1, key);
1412
+ if (nested === null) {
1413
+ warn(ctx, `meta: an item of "${key}" (${item.t}) has no YAML form - skipped`);
1414
+ continue;
1415
+ }
1416
+ // `- ` replaces the first two spaces of the child's own indent, so the
1417
+ // keys after it stay aligned under the first one.
1418
+ out.push(`${pad}- ${nested[0].slice(pad.length + 2)}`, ...nested.slice(1));
1419
+ }
1420
+ return out.length ? out : null;
1421
+ }
1422
+ // --- Entry point ---
1423
+ export function pandocToCarve(doc, target = 'source') {
1424
+ const ctx = {
1425
+ warnings: [],
1426
+ footnoteDefs: {},
1427
+ target,
1428
+ noteCounter: 0,
1429
+ abbrevDefs: new Map(),
1430
+ bibliographyWarned: false,
1431
+ quotedWarned: false,
1432
+ };
1433
+ const children = blocks(ctx, doc.blocks);
1434
+ if (containsShortCaption(children)) {
1435
+ warn(ctx, 'short caption: preserved in the Carve AST; Carve 0.1 source has no spelling for it');
1436
+ }
1437
+ for (const [abbr, expansion] of [...ctx.abbrevDefs].reverse()) {
1438
+ children.unshift({ type: 'abbreviation_def', abbr, expansion });
1439
+ }
1440
+ const ast = { type: 'document', children };
1441
+ if (Object.keys(ctx.footnoteDefs).length)
1442
+ ast.footnoteDefs = ctx.footnoteDefs;
1443
+ const yaml = metaToYaml(ctx, doc.meta ?? {});
1444
+ if (yaml)
1445
+ ast.frontmatter = { format: 'yaml', content: yaml };
1446
+ return { ast, warnings: ctx.warnings };
1447
+ }
1448
+ function containsShortCaption(value) {
1449
+ if (Array.isArray(value))
1450
+ return value.some(containsShortCaption);
1451
+ if (!value || typeof value !== 'object')
1452
+ return false;
1453
+ const record = value;
1454
+ if (Array.isArray(record.shortCaption) && record.shortCaption.length > 0)
1455
+ return true;
1456
+ return Object.values(record).some(containsShortCaption);
1457
+ }