@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,1816 @@
1
+ /**
2
+ * Carve AST -> Pandoc JSON AST.
3
+ *
4
+ * Walks the SERIALIZED AST that PART 12 of the Carve spec defines - the shape
5
+ * `spec/resources/ast-schema.json` pins, as written by any engine's
6
+ * `--to-json` - and emits a Pandoc document (api-version 1.23.1). It is not the
7
+ * runtime tree of any particular implementation: `src/ast-json.ts` maps that on
8
+ * the way in, so every node type and field name switched on below is spec
9
+ * surface. Anything that cannot be mapped faithfully degrades to a classed
10
+ * Span/Div and reports a warning - nothing degrades silently.
11
+ */
12
+ import * as P from './pandoc.js';
13
+ import { readRowGroups } from './row-groups.js';
14
+ function warn(ctx, msg) {
15
+ ctx.warnings.push(msg);
16
+ }
17
+ /**
18
+ * A warning carries enough of the dropped content to find it in the source,
19
+ * and not a whole fenced block.
20
+ */
21
+ function truncateForWarning(content) {
22
+ const flat = content.replace(/\s+/g, ' ').trim();
23
+ return flat.length > 80 ? `${flat.slice(0, 77)}...` : flat;
24
+ }
25
+ function toAttr(attrs) {
26
+ const a = (attrs ?? {});
27
+ const kvs = Object.entries(a.keyValues ?? {});
28
+ return P.attr(a.id, a.classes, kvs);
29
+ }
30
+ /**
31
+ * POLICY: Carve has no small-caps node, and `.smallcaps` is the span that
32
+ * stands in for one - in both directions. `reverse.ts` writes that class for a
33
+ * pandoc `SmallCaps`; this reads it back, so the construct survives
34
+ * Pandoc -> Carve -> Pandoc instead of arriving at the LaTeX/Typst/DOCX writers
35
+ * as a bare `Span` they render without any small-caps at all.
36
+ *
37
+ * The rule is pandoc's own, not an invention here: its markdown reader turns
38
+ * `[x]{.smallcaps}` into `SmallCaps`, strips the class, and keeps whatever
39
+ * other attributes the span had by wrapping the result in a `Span`. Matched
40
+ * exactly, case included (`.SmallCaps` is an ordinary class to pandoc too), so
41
+ * a document that goes through pandoc-flavored markdown and one that goes
42
+ * through this bridge produce the same tree.
43
+ */
44
+ function smallCapsOrSpan(a, xs) {
45
+ const [id, classes, kvs] = a;
46
+ if (!classes.includes('smallcaps'))
47
+ return P.Span(a, xs);
48
+ const rest = classes.filter((c) => c !== 'smallcaps');
49
+ const caps = P.SmallCaps(xs);
50
+ if (!id && rest.length === 0 && kvs.length === 0)
51
+ return caps;
52
+ return P.Span([id, rest, kvs], [caps]);
53
+ }
54
+ /** Carve's heading slug: case-preserving, GitHub-style. */
55
+ function slugify(text) {
56
+ return text
57
+ .trim()
58
+ .replace(/[^\p{L}\p{N}\s_-]/gu, '')
59
+ .replace(/\s+/g, '-');
60
+ }
61
+ function plainText(nodes) {
62
+ let out = '';
63
+ for (const n of nodes) {
64
+ if (typeof n.value === 'string')
65
+ out += n.value;
66
+ // An inline literal renders as visible prose (PART 9 SS27), so its
67
+ // verbatim content IS text and must reach heading slugs - carve-js
68
+ // folds it into its own heading ids for the same reason. Other
69
+ // `content`-carrying nodes stay excluded on purpose: a comment renders
70
+ // nothing at all, and math/raw are not plain prose.
71
+ else if (n.type === 'literal_inline' && typeof n.content === 'string')
72
+ out += n.content;
73
+ else if (Array.isArray(n.children))
74
+ out += plainText(n.children);
75
+ }
76
+ return out;
77
+ }
78
+ /** True when an attribute block carries anything worth preserving. */
79
+ function hasAttrs(a) {
80
+ return Boolean(a && (a.id || a.classes?.length || Object.keys(a.keyValues ?? {}).length));
81
+ }
82
+ function hasLabel(n) {
83
+ return typeof n.label === 'string' && n.label.length > 0;
84
+ }
85
+ /**
86
+ * A fenced div's grouping `[label]` rendered as a caption Para, or [] when the
87
+ * node has none. Normative graceful-degradation rule: a `[label]` that no group
88
+ * extension consumes (tabs/code-group are extensions; this bridge runs none)
89
+ * MUST survive as a visible caption, else a reader of the LaTeX/Typst/DOCX
90
+ * output cannot tell the panels apart. The reference HTML renderer emits
91
+ * `<p class="div-label">`; here it is a bold line - the same slot the quoted
92
+ * title uses, and title comes first when a block carries both.
93
+ *
94
+ * In roundtrip mode the label is preserved structurally instead (see
95
+ * `labelKv`), so the reverse importer can rebuild the `[label]` grouping token
96
+ * exactly; a flattened caption Para would be indistinguishable from a title.
97
+ */
98
+ function labelCaption(ctx, n) {
99
+ return !ctx.roundtrip && hasLabel(n) ? [P.Para([P.Strong(textInlines(n.label))])] : [];
100
+ }
101
+ /**
102
+ * Roundtrip-only kv that carries the grouping `[label]` for exact re-import.
103
+ * The `.` in the key is deliberate: Carve's attribute grammar rejects dotted
104
+ * keys, so a document can never carry a user-authored `carve.label` attribute
105
+ * that the reverse importer would mistake for this internal marker.
106
+ */
107
+ function labelKv(ctx, n) {
108
+ return ctx.roundtrip && hasLabel(n) ? [['carve.label', n.label]] : [];
109
+ }
110
+ /** Split text into Str/Space the way pandoc readers do. */
111
+ // Glyphs for the smart-typography kinds that do not carry one on the node.
112
+ // A quote's glyph is locale-dependent and is chosen during parsing, so the node
113
+ // records it; the rest are fixed and resolvable from the kind.
114
+ //
115
+ // Duplicated from carve-js, which exports SMART_PUNCTUATION_GLYPHS from ast.ts
116
+ // but not from the package root - see carve#355. Drop this table once the
117
+ // export lands.
118
+ const SMART_PUNCTUATION_GLYPHS = {
119
+ ellipsis: '\u2026',
120
+ em_dash: '\u2014',
121
+ en_dash: '\u2013',
122
+ left_right_arrow: '\u2194',
123
+ rightwards_arrow: '\u2192',
124
+ leftwards_arrow: '\u2190',
125
+ rightwards_double_arrow: '\u21D2',
126
+ less_than_or_equal: '\u2264',
127
+ greater_than_or_equal: '\u2265',
128
+ not_equal: '\u2260',
129
+ plus_minus: '\u00B1',
130
+ copyright: '\u00A9',
131
+ registered: '\u00AE',
132
+ trademark: '\u2122',
133
+ };
134
+ function smartPunctuationText(n) {
135
+ const glyph = n.glyph;
136
+ if (glyph)
137
+ return glyph;
138
+ const kind = String(n.kind ?? '');
139
+ return SMART_PUNCTUATION_GLYPHS[kind] ?? String(n.value ?? '');
140
+ }
141
+ /**
142
+ * The engines' SENTINEL for a no-break space the parser resolved - from an
143
+ * escaped space, or from a line block's preserved indentation - is U+E000, a
144
+ * PRIVATE-USE codepoint (markup-carve/carve#721). It is spec surface a consumer has to
145
+ * map: passing it through put a private-use character into Pandoc JSON, so
146
+ * every writer downstream - docx, LaTeX, HTML - rendered a tofu box where a
147
+ * no-break space belonged, and nothing warned.
148
+ *
149
+ * U+00A0 is the right target: Pandoc has no separate representation for a
150
+ * resolved space, and a literal no-break space is what the source means. A
151
+ * U+00A0 the author typed is published by the engines as itself and needs no
152
+ * mapping.
153
+ */
154
+ const RESOLVED_NBSP = /\uE000/g;
155
+ function resolvedSpaces(value) {
156
+ return value.replace(RESOLVED_NBSP, '\u00A0');
157
+ }
158
+ function textInlines(raw) {
159
+ const value = resolvedSpaces(raw);
160
+ const out = [];
161
+ const parts = value.split(/( +)/);
162
+ for (const part of parts) {
163
+ if (part === '')
164
+ continue;
165
+ if (/^ +$/.test(part))
166
+ out.push(P.Space);
167
+ else
168
+ out.push(P.Str(part));
169
+ }
170
+ return out;
171
+ }
172
+ // Like textInlines, but preserves the exact number of spaces by emitting one
173
+ // Space per space character instead of one per run. Prose collapsing is correct
174
+ // for ordinary text, but an inline literal captures its content VERBATIM, so
175
+ // `` !`a b` `` must not reach a writer as "a b".
176
+ function verbatimInlines(raw) {
177
+ const value = resolvedSpaces(raw);
178
+ const out = [];
179
+ for (const part of value.split(/( )/)) {
180
+ if (part === '')
181
+ continue;
182
+ if (part === ' ')
183
+ out.push(P.Space);
184
+ else
185
+ out.push(P.Str(part));
186
+ }
187
+ return out;
188
+ }
189
+ /**
190
+ * The label a caption numbers under - the word before its `#`.
191
+ *
192
+ * The engine keeps one sequence PER LABEL, not per element kind: `Figure #`,
193
+ * `Listing #`, `Figure #` on three figures numbers them Figure 1, Listing 1,
194
+ * Figure 2. Keying on figure-versus-table would have made that Listing 2.
195
+ */
196
+ function captionLabel(nodes) {
197
+ if (!Array.isArray(nodes))
198
+ return undefined;
199
+ const at = nodes.findIndex((x) => x?.type === 'caption_number');
200
+ if (at <= 0)
201
+ return undefined;
202
+ // ALL the text before the placeholder, markup included.
203
+ //
204
+ // docs-extensions.md: "the label is the text before it". Three narrower
205
+ // readings were wrong, each caught in review:
206
+ // - the element kind `Listing #` after `Figure #` numbered 2, not 1
207
+ // - the last word `Supplementary Figure` merged into `Figure`
208
+ // - only `text` nodes `*Figure*` fell to a generic counter, where
209
+ // the engine shares the `Figure` sequence and
210
+ // numbers it 2
211
+ // So the label is the flattened inline TEXT, which is what the resolver
212
+ // compares.
213
+ const label = nodes.slice(0, at).map(inlineText).join('').trim();
214
+ return label || undefined;
215
+ }
216
+ /**
217
+ * The text content of an inline node, markup flattened away.
218
+ *
219
+ * Carve's inline nodes do not agree on where their text lives: most use
220
+ * `value`, a literal inline uses `content`, a symbol carries its resolved
221
+ * glyph. Reading only `value` sent `!`Figure`` to a generic counter, where the
222
+ * engine shares the `Figure` sequence - the fourth narrowing this function
223
+ * needed, all four found in review.
224
+ */
225
+ function inlineText(n) {
226
+ if (!n || typeof n !== 'object')
227
+ return '';
228
+ for (const key of ['value', 'content', 'glyph', 'text']) {
229
+ const v = n[key];
230
+ if (typeof v === 'string')
231
+ return v;
232
+ }
233
+ const kids = n.children ?? [];
234
+ return kids.map(inlineText).join('');
235
+ }
236
+ // --- Inlines ---
237
+ function inlines(ctx, nodes) {
238
+ if (!nodes)
239
+ return [];
240
+ const out = [];
241
+ for (const n of pairQuotes(nodes))
242
+ out.push(...inline(ctx, n));
243
+ return joinAdjacentStr(out);
244
+ }
245
+ /**
246
+ * The synthetic node a matched pair of quote marks becomes, between
247
+ * {@link pairQuotes} and the `Quoted` it converts to. Not a Carve type and
248
+ * never serialized - the name is deliberately unspellable so it cannot collide
249
+ * with a node type an engine might add.
250
+ */
251
+ const QUOTED = '__pandocQuoted';
252
+ const QUOTE_OPENS = {
253
+ left_double_quote: 'DoubleQuote',
254
+ left_single_quote: 'SingleQuote',
255
+ };
256
+ const QUOTE_CLOSES = {
257
+ right_double_quote: 'DoubleQuote',
258
+ right_single_quote: 'SingleQuote',
259
+ };
260
+ const quoteRole = (n) => {
261
+ if (n?.type !== 'smart_punctuation')
262
+ return null;
263
+ const kind = String(n.kind ?? '');
264
+ const open = QUOTE_OPENS[kind];
265
+ if (open)
266
+ return [open, 'open'];
267
+ const close = QUOTE_CLOSES[kind];
268
+ return close ? [close, 'close'] : null;
269
+ };
270
+ /**
271
+ * Rebuild pandoc's WRAPPING quotation from Carve's two standalone marks.
272
+ *
273
+ * Carve's parser resolves `"` and `'` to `smart_punctuation` nodes carrying
274
+ * the kind (`left_double_quote`, `right_single_quote`, ...), which is strictly
275
+ * more information than the glyph - and exactly the information pandoc's
276
+ * `Quoted` needs. Without this a quotation crossed the bridge as three
277
+ * separate pieces and came back as `Str "“alpha”"`, so a document quoting
278
+ * anything could not survive pandoc -> Carve -> pandoc.
279
+ *
280
+ * WHAT IS DELIBERATELY NOT PAIRED. Only marks that are SIBLINGS pair, and only
281
+ * a close against a still-open mark of the same kind:
282
+ *
283
+ * - An APOSTROPHE (`it's`) resolves to a lone `right_single_quote` with no
284
+ * opener. It stays a mark, which is the whole reason the match runs from
285
+ * the closing side against a stack rather than pairing greedily.
286
+ * - An UNCLOSED `"` stays a mark too - a `Quoted` running to the end of the
287
+ * paragraph would assert a quotation the author never closed.
288
+ * - A quotation crossing an emphasis boundary (`"a /b" c/`) puts its two
289
+ * marks in different sibling arrays, where neither can see the other, and
290
+ * both stay marks.
291
+ *
292
+ * In all three the glyph still renders; the node just is not promoted.
293
+ */
294
+ function pairQuotes(nodes) {
295
+ const closerOf = new Map();
296
+ const kindOf = new Map();
297
+ const open = [];
298
+ for (let i = 0; i < nodes.length; i++) {
299
+ const role = quoteRole(nodes[i]);
300
+ if (!role)
301
+ continue;
302
+ const [kind, side] = role;
303
+ if (side === 'open') {
304
+ open.push({ at: i, kind });
305
+ continue;
306
+ }
307
+ // The NEAREST unclosed opener of the same kind, so nesting works and a
308
+ // stray closer of the other kind cannot consume it.
309
+ for (let j = open.length - 1; j >= 0; j--) {
310
+ if (open[j].kind !== kind)
311
+ continue;
312
+ closerOf.set(open[j].at, i);
313
+ kindOf.set(open[j].at, kind);
314
+ open.length = j;
315
+ break;
316
+ }
317
+ }
318
+ if (closerOf.size === 0)
319
+ return nodes;
320
+ // The pairs nest by construction, so one recursive walk builds the tree.
321
+ const build = (from, to) => {
322
+ const out = [];
323
+ for (let i = from; i < to;) {
324
+ const close = closerOf.get(i);
325
+ if (close !== undefined && close < to) {
326
+ out.push({ type: QUOTED, quote: kindOf.get(i), children: build(i + 1, close) });
327
+ i = close + 1;
328
+ continue;
329
+ }
330
+ out.push(nodes[i]);
331
+ i++;
332
+ }
333
+ return out;
334
+ };
335
+ return build(0, nodes.length);
336
+ }
337
+ /**
338
+ * Merge neighbouring `Str` nodes into one.
339
+ *
340
+ * Pandoc's own readers emit one `Str` per WORD, with `Space` between; two
341
+ * `Str` nodes touching is legal but not a shape pandoc produces, and a
342
+ * consumer doing word-level work sees three words where there is one.
343
+ *
344
+ * Carve's tree splits a word wherever a construct sits inside it, and that got
345
+ * more common when carve-js stopped substituting smart-punctuation characters
346
+ * and started publishing them as nodes: `Carve's` is now `text`,
347
+ * `smart_punctuation`, `text`, so a direct mapping yields Str("Carve"),
348
+ * Str("’"), Str("s"). The rendered text is identical - writers concatenate -
349
+ * but the AST is not the one pandoc would have built, and it is the AST this
350
+ * package exists to hand over.
351
+ */
352
+ function joinAdjacentStr(xs) {
353
+ const out = [];
354
+ for (const x of xs) {
355
+ const prev = out[out.length - 1];
356
+ if (x.t === 'Str' && prev?.t === 'Str') {
357
+ out[out.length - 1] = P.Str(String(prev.c) + String(x.c));
358
+ continue;
359
+ }
360
+ out.push(x);
361
+ }
362
+ return out;
363
+ }
364
+ function kids(ctx, n) {
365
+ return inlines(ctx, n.children);
366
+ }
367
+ /**
368
+ * A reference link or image whose label nothing defines.
369
+ *
370
+ * PART 12 section 3a publishes the resolution BESIDE the authored construct
371
+ * rather than in place of it: `[a][t]` is a `link` carrying `ref`/`rawRef`
372
+ * whether or not the document defines `t`, and `href` (`src` for an image) is
373
+ * empty ONLY where nothing resolved it. So the pair "a reference was written"
374
+ * and "the destination is empty" is the wire's statement that the reference
375
+ * did not resolve, and it is the only one - there is no way to author an empty
376
+ * destination on a definition line (`[t]: <>` defines the literal `<>`).
377
+ *
378
+ * Carve renders such a reference as the LITERAL SOURCE, so this returns
379
+ * `rawRef` as text. Emitting a `Link` with an empty target instead would
380
+ * invent a node the document does not contain, and downstream it renders as a
381
+ * broken anchor rather than as the text the reader is meant to see.
382
+ *
383
+ * And it warns, because the sibling path does. An unresolved footnote has said
384
+ * `footnote: missing definition for [^f]` all along; a missing link definition
385
+ * has no reason to be the quieter of the two, and a conversion that loses the
386
+ * author's meaning with a clean exit code is the failure this pairs with
387
+ * (markup-carve/pandoc-carve#91).
388
+ *
389
+ * Returns null when the node is not an unresolved reference, so the caller
390
+ * converts it normally.
391
+ */
392
+ function unresolvedReference(ctx, n, kind) {
393
+ if (n.ref === undefined)
394
+ return null;
395
+ const destination = String((kind === 'link' ? n.href : n.src) ?? '');
396
+ if (destination !== '')
397
+ return null;
398
+ const ref = String(n.ref ?? '');
399
+ warn(ctx, `${kind}: missing definition for [${ref}] - emitting the literal source`);
400
+ const raw = String(n.rawRef ?? '');
401
+ // rawRef is required beside ref, so the fallback is for a tree assembled by
402
+ // hand: keep the visible text rather than dropping the construct entirely.
403
+ if (raw === '')
404
+ return kind === 'link' ? kids(ctx, n) : textInlines(String(n.alt ?? ''));
405
+ // A reference may be written across lines. Its literal source is prose now,
406
+ // so the line break is a SoftBreak - a newline left inside a Str would
407
+ // reach every writer verbatim.
408
+ const out = [];
409
+ raw.split(/\r?\n/).forEach((line, at) => {
410
+ if (at > 0)
411
+ out.push(P.SoftBreak);
412
+ out.push(...textInlines(line));
413
+ });
414
+ return out;
415
+ }
416
+ function inline(ctx, n) {
417
+ switch (n.type) {
418
+ case 'text':
419
+ return textInlines(String(n.value ?? ''));
420
+ case 'soft_break':
421
+ return [P.SoftBreak];
422
+ case 'hard_break':
423
+ return [P.LineBreak];
424
+ case 'emphasis':
425
+ return [P.Emph(kids(ctx, n))];
426
+ case 'strong':
427
+ return [P.Strong(kids(ctx, n))];
428
+ case 'underline':
429
+ return [P.Underline(kids(ctx, n))];
430
+ case 'strike':
431
+ return [P.Strikeout(kids(ctx, n))];
432
+ case 'highlight':
433
+ return [P.Span(P.attr(undefined, ['mark']), kids(ctx, n))];
434
+ case 'subscript':
435
+ return [P.Subscript(kids(ctx, n))];
436
+ case 'superscript':
437
+ return [P.Superscript(kids(ctx, n))];
438
+ case 'code':
439
+ return [P.Code(toAttr(n.attrs), String(n.value ?? ''))];
440
+ case 'literal_inline': {
441
+ // PART 9 SS27: verbatim capture rendered as ORDINARY PROSE. The
442
+ // `<code>` wrapper is deliberately dropped, so Pandoc `Code` would
443
+ // invert the construct's entire purpose (it implies monospace/code
444
+ // styling - the exact thing the literal exists to avoid). Emit
445
+ // plain text, and wrap in a Span only when attributes need
446
+ // somewhere to live - mirroring carve-js, which emits a `<span>`
447
+ // only when the attribute block is present and bare text otherwise.
448
+ // verbatimInlines, not textInlines: the content is captured verbatim,
449
+ // so runs of spaces must survive rather than collapse to one Space.
450
+ const text = verbatimInlines(String(n.content ?? ''));
451
+ return hasAttrs(n.attrs)
452
+ ? [P.Span(toAttr(n.attrs), text)]
453
+ : text;
454
+ }
455
+ case 'link': {
456
+ const unresolved = unresolvedReference(ctx, n, 'link');
457
+ if (unresolved)
458
+ return unresolved;
459
+ return [
460
+ P.Link(toAttr(n.attrs), kids(ctx, n), [
461
+ String(n.href ?? ''),
462
+ String(n.title ?? ''),
463
+ ]),
464
+ ];
465
+ }
466
+ case 'autolink': {
467
+ const href = String(n.href ?? '');
468
+ const cls = href.startsWith('mailto:') ? 'email' : 'uri';
469
+ return [P.Link(P.attr(undefined, [cls]), [P.Str(String(n.text ?? href))], [href, ''])];
470
+ }
471
+ case 'image': {
472
+ const unresolved = unresolvedReference(ctx, n, 'image');
473
+ if (unresolved)
474
+ return unresolved;
475
+ return [
476
+ P.Image(toAttr(n.attrs), textInlines(String(n.alt ?? '')), [
477
+ String(n.src ?? ''),
478
+ String(n.title ?? ''),
479
+ ]),
480
+ ];
481
+ }
482
+ case 'caption_number': {
483
+ // The `#` in `^ Figure #: text`. It has no value in the tree - the
484
+ // renderer assigns one - so it was degrading to empty and the
485
+ // caption reached pandoc as `Figure : text`, silently unnumbered
486
+ // in every writer.
487
+ //
488
+ // Inside a panel it draws nothing and prints as the author wrote
489
+ // it (PART 9 §4c, corpus 318-composite-figures-8's sibling lint
490
+ // `figure-group-panel-number`).
491
+ if (ctx.inPanel)
492
+ return [P.Str('#')];
493
+ const kind = ctx.captionKind ?? 'caption';
494
+ const next = (ctx.captionCounts.get(kind) ?? 0) + 1;
495
+ ctx.captionCounts.set(kind, next);
496
+ return [P.Str(String(next))];
497
+ }
498
+ case 'heading_ref': {
499
+ // Nested inside a crossref's own resolution: the engine emits
500
+ // nothing for it, so `# A </#a>` yields the link text `A ` rather
501
+ // than recurring. Matches corpus 118.
502
+ if (ctx.inCrossref)
503
+ return [];
504
+ const target = String(n.target ?? '');
505
+ const found = ctx.crossrefTargets.get(target) ??
506
+ ctx.crossrefTargets.get(target.toLowerCase()) ??
507
+ findCaseInsensitive(ctx.crossrefTargets, target);
508
+ if (found) {
509
+ ctx.inCrossref = true;
510
+ try {
511
+ return [
512
+ P.Link(P.attr(undefined, ['crossref']), inlines(ctx, found), [
513
+ `#${target}`,
514
+ '',
515
+ ]),
516
+ ];
517
+ }
518
+ finally {
519
+ ctx.inCrossref = false;
520
+ }
521
+ }
522
+ warn(ctx, `crossref: unresolved target "${target}" - emitting target text`);
523
+ return [P.Link(P.attr(undefined, ['crossref', 'unresolved']), [P.Str(target)], [`#${target}`, ''])];
524
+ }
525
+ // The wire has two nodes here, split so that a profile can deny one
526
+ // without the other (markup-carve/carve#405): `footnote_ref` (`[^a]`)
527
+ // and `inline_footnote` (`^[…]`). The pre-split spelling a pinned
528
+ // engine still emits is folded onto these two in `src/ast-json.ts`, so
529
+ // this switch never has to know it existed.
530
+ case 'footnote_ref':
531
+ case 'inline_footnote': {
532
+ // The note's own content is converted BEFORE the counter moves, so
533
+ // a citation inside the note carries that note's number rather than
534
+ // the next one - which is what pandoc's markdown reader produces.
535
+ if (Array.isArray(n.inline)) {
536
+ const note = P.Note([P.Para(inlines(ctx, n.inline))]);
537
+ ctx.noteCount++;
538
+ return [note];
539
+ }
540
+ const id = String(n.id ?? '');
541
+ const def = ctx.footnoteDefs[id];
542
+ if (!def) {
543
+ warn(ctx, `footnote: missing definition for [^${id}]`);
544
+ return [P.Superscript([P.Str(id)])];
545
+ }
546
+ const note = P.Note(blocks(ctx, def));
547
+ ctx.noteCount++;
548
+ return [note];
549
+ }
550
+ case 'math':
551
+ return [
552
+ n.display
553
+ ? P.MathDisplay(String(n.content ?? ''))
554
+ : P.MathInline(String(n.content ?? '')),
555
+ ];
556
+ case 'raw_inline':
557
+ return [P.RawInline(String(n.format ?? ''), String(n.content ?? ''))];
558
+ case 'mention': {
559
+ const user = String(n.user ?? '');
560
+ return [P.Span(P.attr(undefined, ['mention'], [['data-user', user]]), [P.Str(`@${user}`)])];
561
+ }
562
+ case 'tag': {
563
+ const name = String(n.name ?? '');
564
+ return [P.Span(P.attr(undefined, ['tag'], [['data-tag', name]]), [P.Str(`#${name}`)])];
565
+ }
566
+ case 'symbol': {
567
+ const name = String(n.name ?? '');
568
+ const mapped = ctx.symbols[name];
569
+ if (mapped !== undefined)
570
+ return textInlines(mapped);
571
+ warn(ctx, `symbol: :${name}: has no entry in the symbols map - emitting literal source form`);
572
+ return [P.Span(P.attr(undefined, ['symbol'], [['data-symbol', name]]), [P.Str(`:${name}:`)])];
573
+ }
574
+ case 'abbreviation': {
575
+ const abbr = String(n.abbr ?? '');
576
+ const expansion = String(n.expansion ?? '');
577
+ return [P.Span(P.attr(undefined, ['abbr'], [['title', expansion]]), [P.Str(abbr)])];
578
+ }
579
+ case 'inline_extension': {
580
+ const name = String(n.name ?? '');
581
+ const content = Array.isArray(n.content)
582
+ ? inlines(ctx, n.content)
583
+ : textInlines(String(n.content ?? ''));
584
+ warn(ctx, `extension: :${name}[..] degraded to a Span with class "ext-${name}"`);
585
+ return [P.Span(P.attr(undefined, [`ext-${name}`]), content)];
586
+ }
587
+ case 'citation_group':
588
+ return [citationGroup(ctx, n)];
589
+ case 'span':
590
+ return [smallCapsOrSpan(toAttr(n.attrs), kids(ctx, n))];
591
+ case 'insert':
592
+ return [P.Span(P.attr(undefined, ['insertion']), kids(ctx, n))];
593
+ case 'delete':
594
+ return [P.Span(P.attr(undefined, ['deletion']), kids(ctx, n))];
595
+ case 'substitution': {
596
+ const oldText = textInlines(String(n.oldText ?? ''));
597
+ const newText = textInlines(String(n.newText ?? ''));
598
+ return [
599
+ P.Span(P.attr(undefined, ['substitution']), [
600
+ P.Span(P.attr(undefined, ['deletion']), oldText),
601
+ P.Str('→'),
602
+ P.Span(P.attr(undefined, ['insertion']), newText),
603
+ ]),
604
+ ];
605
+ }
606
+ // `critic_comment` is the schema's spelling and the only one the wire
607
+ // carries; the hyphenated name a pinned engine still emits internally
608
+ // is folded onto it in `src/ast-json.ts`.
609
+ case 'critic_comment':
610
+ return [P.Span(P.attr(undefined, ['comment-annotation']), textInlines(String(n.text ?? '')))];
611
+ case 'comment':
612
+ // Pandoc's AST has no comment node, so dropping is the conversion -
613
+ // but a silent drop is not (markup-carve/pandoc-carve#75). The
614
+ // warning names the content, so a migration can see what did not
615
+ // make the trip.
616
+ warn(ctx, `comment: dropped - Pandoc's AST has no comment node: ${truncateForWarning(String(n.content ?? ''))}`);
617
+ return [];
618
+ case QUOTED:
619
+ return [
620
+ P.Quoted(n.quote ?? 'DoubleQuote', inlines(ctx, n.children)),
621
+ ];
622
+ case 'smart_punctuation':
623
+ // The resolved glyph, not the author's source run. Pandoc applies
624
+ // its own smart punctuation when READING markdown, not when
625
+ // consuming a JSON AST, so handing it `--` yields a literal double
626
+ // hyphen downstream. Carve already made the decision; keeping it is
627
+ // what carries the typography into LaTeX, DOCX and the rest.
628
+ return [P.Str(smartPunctuationText(n))];
629
+ case 'escaped_text':
630
+ // The character the author escaped. The backslash is authoring
631
+ // syntax and does not survive into pandoc's AST; the character does,
632
+ // and stays literal because pandoc will not re-smarten AST input -
633
+ // which is exactly what the escape asked for.
634
+ return [P.Str(String(n.value ?? ''))];
635
+ default:
636
+ warn(ctx, `inline: unknown node type "${n.type}" degraded to its text content`);
637
+ return textInlines(plainText([n]));
638
+ }
639
+ }
640
+ /**
641
+ * PART 9 §22 citation group -> pandoc `Cite`.
642
+ *
643
+ * Three things do not line up, and each is handled the way citeproc itself
644
+ * does it rather than by inventing a convention:
645
+ *
646
+ * - **Mode.** Carve's integral `+` is a property of the whole cluster;
647
+ * pandoc's mode is per item. Integral maps to `AuthorInText` on each item.
648
+ * An item's own `-` (suppress author) is narrower than the group's mode and
649
+ * wins, because dropping it would print an author the source asked to hide.
650
+ * - **Locator.** `Citation` has no locator slot at all - pandoc's markdown
651
+ * reader puts `, p. 33` in `citationSuffix` and citeproc parses it back out
652
+ * there. So the locator TEXT crosses intact and citeproc re-derives the same
653
+ * `{label, value}` pair Carve had parsed; only the typing is lost, which is
654
+ * what the warning says.
655
+ * - **Content.** The Cite's second field is the verbatim source, which is
656
+ * exactly what `citation_group.raw` holds. Every non-citeproc writer prints
657
+ * that, so a document converted without `--citeproc` still reads correctly.
658
+ */
659
+ function citationGroup(ctx, n) {
660
+ const integral = n.mode === 'integral';
661
+ const items = n.items ?? [];
662
+ const citations = items.map((item) => {
663
+ const key = String(item.key ?? '');
664
+ let mode = integral ? 'AuthorInText' : 'NormalCitation';
665
+ if (item.suppressAuthor) {
666
+ if (integral) {
667
+ warn(ctx, `citation: @${key} suppresses its author inside an integral group - pandoc's mode is per item, so the item keeps SuppressAuthor`);
668
+ }
669
+ mode = 'SuppressAuthor';
670
+ }
671
+ if (item.locatorLabel) {
672
+ warn(ctx, `citation: @${key}'s typed locator (${item.locatorLabel}) is serialized into the pandoc citation suffix - pandoc's Citation has no locator field`);
673
+ }
674
+ return P.citation(key, mode, item.prefix?.length ? inlines(ctx, item.prefix) : [], citationSuffix(ctx, item), ctx.noteCount + 1);
675
+ });
676
+ return P.Cite(citations, textInlines(String(n.raw ?? '')));
677
+ }
678
+ /**
679
+ * The suffix citeproc reads: the locator text after its `, ` separator, which
680
+ * is the whole post-comma run - Carve's `suffix` is the tail of `locator`, not
681
+ * a sibling of it, so emitting both would print the tail twice.
682
+ */
683
+ function citationSuffix(ctx, item) {
684
+ if (item.locator?.length)
685
+ return [P.Str(','), P.Space, ...inlines(ctx, item.locator)];
686
+ if (item.suffix?.length)
687
+ return [P.Space, ...inlines(ctx, item.suffix)];
688
+ return [];
689
+ }
690
+ /**
691
+ * A `[@key]: entry` bibliography line (PART 12 section 18) as citeproc's own
692
+ * bibliography entry: `Div ("ref-<key>", ["csl-entry"], ...)`.
693
+ *
694
+ * There was no arm for this node at all, so a definition fell to the generic
695
+ * "unknown node type" path and left as a paragraph of its text - the entry
696
+ * printed in the body of the document, where Carve renders nothing, and the
697
+ * key that binds it to its citations was dropped on the floor.
698
+ *
699
+ * `csl-entry` under `ref-<key>` is not a shape invented here. It is what
700
+ * `pandoc --citeproc` writes for a resolved bibliography entry and what
701
+ * pandoc's markdown reader reads back, so the mapping lands in the vocabulary
702
+ * the rest of the pandoc ecosystem already keys on: a filter or template that
703
+ * styles a bibliography finds one.
704
+ *
705
+ * The `{author= year=}` metadata rides along as the Div's key-values. Pandoc
706
+ * has no slot for it - citeproc takes those from the CSL data, not from the
707
+ * document - but dropping a field the node carries is the thing this bridge
708
+ * does not do, and an attribute pandoc preserves costs nothing to keep.
709
+ */
710
+ function citationDefinition(ctx, n) {
711
+ const key = String(n.key ?? '');
712
+ const [id, classes, kvs] = toAttr(n.attrs);
713
+ const entry = inlines(ctx, n.children);
714
+ return P.Div([id || (key ? `ref-${key}` : ''), ['csl-entry', ...classes], kvs],
715
+ // Section 18 allows an empty entry, and an empty `Para` is not a shape
716
+ // pandoc's own readers produce.
717
+ entry.length ? [P.Para(entry)] : []);
718
+ }
719
+ function findCaseInsensitive(map, target) {
720
+ const lower = target.toLowerCase();
721
+ for (const [k, v] of map) {
722
+ if (k.toLowerCase() === lower)
723
+ return v;
724
+ }
725
+ return undefined;
726
+ }
727
+ // --- Blocks ---
728
+ function blocks(ctx, nodes) {
729
+ if (!nodes)
730
+ return [];
731
+ const out = [];
732
+ for (const n of nodes)
733
+ out.push(...block(ctx, n));
734
+ return out;
735
+ }
736
+ /** Block types whose Pandoc form carries the Attr itself. */
737
+ const ATTR_CARRYING = new Set([
738
+ 'heading',
739
+ 'code_block',
740
+ 'table',
741
+ 'figure',
742
+ 'figure_group',
743
+ 'div',
744
+ 'admonition',
745
+ // Its Div IS the entry, and its attrs are the `{author= year=}` block that
746
+ // belongs on it. Left out, the wrapper below adds a SECOND Div carrying the
747
+ // same key-values around the one that already has them.
748
+ 'citation_definition',
749
+ ]);
750
+ function block(ctx, n) {
751
+ const result = blockInner(ctx, n);
752
+ // A block-attribute line can attach attrs to ANY block. Pandoc's Para,
753
+ // BlockQuote, lists etc. have no Attr slot - preserve via a Div wrapper.
754
+ const a = n.attrs;
755
+ if (!ATTR_CARRYING.has(n.type) && hasAttrs(a)) {
756
+ // In roundtrip mode the carve-block marker lets the reverse direction
757
+ // restore the attrs onto the inner block instead of keeping a wrapper.
758
+ const [id, classes, kvs] = toAttr(a);
759
+ const marked = ctx.roundtrip
760
+ ? [...kvs, ['carve-block', n.type]]
761
+ : kvs;
762
+ return [P.Div([id, classes, marked], result)];
763
+ }
764
+ return result;
765
+ }
766
+ /**
767
+ * True for the div form of a line block: the `line-block` class and nothing else
768
+ * to preserve. A div that ALSO carries an id, other classes or key/values is a
769
+ * div the author attributed, and Pandoc's LineBlock has no attribute slot to put
770
+ * them in - so those stay a Div rather than lose the attributes.
771
+ */
772
+ function isLineBlockDiv(n, classes) {
773
+ if (!classes.includes('line-block'))
774
+ return false;
775
+ const [id, , kvs] = toAttr(n.attrs);
776
+ return classes.length === 1 && id === '' && kvs.length === 0;
777
+ }
778
+ /**
779
+ * A line block's stanzas are its child paragraphs; within a stanza the lines are
780
+ * separated by hard breaks. Pandoc's LineBlock is a flat list of lines, and a
781
+ * blank line between stanzas is an EMPTY line - the same shape pandoc's own
782
+ * markdown reader produces for a `|` line with nothing after it.
783
+ */
784
+ function lineBlock(ctx, n) {
785
+ const lines = [];
786
+ const stanzas = n.children ?? [];
787
+ stanzas.forEach((stanza, i) => {
788
+ if (i > 0)
789
+ lines.push([]);
790
+ const kids = stanza.children ?? [stanza];
791
+ let current = [];
792
+ for (const kid of kids) {
793
+ if (kid.type === 'hard_break' || kid.type === 'soft_break') {
794
+ lines.push(inlines(ctx, current));
795
+ current = [];
796
+ continue;
797
+ }
798
+ current.push(kid);
799
+ }
800
+ lines.push(inlines(ctx, current));
801
+ });
802
+ return P.LineBlock(lines);
803
+ }
804
+ function blockInner(ctx, n) {
805
+ switch (n.type) {
806
+ case 'paragraph': {
807
+ const xs = kids(ctx, n);
808
+ return [ctx.tight ? P.Plain(xs) : P.Para(xs)];
809
+ }
810
+ case 'heading':
811
+ return [P.Header(Number(n.level ?? 1), toAttr(n.attrs), kids(ctx, n))];
812
+ case 'block_quote':
813
+ return [P.BlockQuote(untight(ctx, () => blocks(ctx, n.children)))];
814
+ case 'code_block': {
815
+ const lang = n.lang ? [String(n.lang)] : [];
816
+ const a = (n.attrs ?? {});
817
+ const kvs = Object.entries(a.keyValues ?? {});
818
+ return [
819
+ P.CodeBlock(P.attr(a.id, [...lang, ...(a.classes ?? [])], kvs), String(n.content ?? '')),
820
+ ];
821
+ }
822
+ case 'raw_block':
823
+ return [P.RawBlock(String(n.format ?? ''), String(n.content ?? ''))];
824
+ case 'citation_definition':
825
+ return [citationDefinition(ctx, n)];
826
+ case 'thematic_break':
827
+ return [P.HorizontalRule];
828
+ case 'list':
829
+ return [list(ctx, n)];
830
+ case 'definition_list':
831
+ return [definitionList(ctx, n)];
832
+ case 'table':
833
+ return [table(ctx, n, null)];
834
+ case 'figure':
835
+ return figure(ctx, n);
836
+ case 'figure_group':
837
+ return figureGroup(ctx, n);
838
+ case 'admonition': {
839
+ const kind = String(n.kind ?? 'note');
840
+ if (kind === 'list-table' && ctx.listTable) {
841
+ const converted = listTableToTable(ctx, n);
842
+ if (converted)
843
+ return [converted];
844
+ warn(ctx, 'list-table: structure not table-shaped - kept as the degraded Div (content preserved)');
845
+ }
846
+ const title = Array.isArray(n.title)
847
+ ? [P.Para([P.Strong(inlines(ctx, n.title))])]
848
+ : [];
849
+ const body = untight(ctx, () => blocks(ctx, n.children));
850
+ const [id, classes, kvs] = toAttr(n.attrs);
851
+ return [
852
+ P.Div([id, ['admonition', kind, ...classes], [...kvs, ...labelKv(ctx, n)]], [...title, ...labelCaption(ctx, n), ...body]),
853
+ ];
854
+ }
855
+ // A LINE BLOCK (`::: |`, PART 9 SS23) is verse: each newline is a line of
856
+ // its own and the leading whitespace is preserved. Pandoc has `LineBlock`
857
+ // for exactly that.
858
+ //
859
+ // BOTH spellings are handled, because the arm for the node type alone was
860
+ // unreachable: the PINNED published engine models `::: |` as a div
861
+ // carrying the `line-block` class, and only carve-js main emits a
862
+ // dedicated `line_block` node. So every line block a user could actually
863
+ // produce fell through to the Div branch and reached the writers as a
864
+ // classed paragraph, while the code that would have handled it sat
865
+ // waiting for a pin bump.
866
+ case 'line_block':
867
+ return [lineBlock(ctx, n)];
868
+ case 'div': {
869
+ const [id, classes, kvs] = toAttr(n.attrs);
870
+ if (isLineBlockDiv(n, classes))
871
+ return [lineBlock(ctx, n)];
872
+ return [
873
+ P.Div([id, classes, [...kvs, ...labelKv(ctx, n)]], [
874
+ ...labelCaption(ctx, n),
875
+ ...untight(ctx, () => blocks(ctx, n.children)),
876
+ ]),
877
+ ];
878
+ }
879
+ case 'image':
880
+ // A sole image on its own line is a block-level node in Carve.
881
+ return [P.Para(inline(ctx, n))];
882
+ case 'comment':
883
+ // Unlike the definitions below, a dropped comment IS authored
884
+ // content leaving the document - so it is named rather than
885
+ // silent (markup-carve/pandoc-carve#75).
886
+ warn(ctx, `comment: dropped - Pandoc's AST has no comment node: ${truncateForWarning(String(n.content ?? ''))}`);
887
+ return [];
888
+ case 'abbreviation_def':
889
+ case 'link_reference_definition':
890
+ // Definitions are document metadata, not output blocks. PART 12
891
+ // section 3a puts the resolved destination and title directly on
892
+ // each link/image node, so dropping the definition here loses no
893
+ // information from the Pandoc document.
894
+ return [];
895
+ default: {
896
+ // An inline node at block level (defensive) or an unknown block.
897
+ warn(ctx, `block: unknown node type "${n.type}" degraded to a paragraph of its text`);
898
+ return [P.Para(textInlines(plainText([n])))];
899
+ }
900
+ }
901
+ }
902
+ function untight(ctx, fn) {
903
+ const prev = ctx.tight;
904
+ ctx.tight = false;
905
+ const result = fn();
906
+ ctx.tight = prev;
907
+ return result;
908
+ }
909
+ // --- Lists ---
910
+ const OL_TYPE = {
911
+ a: 'LowerAlpha',
912
+ A: 'UpperAlpha',
913
+ i: 'LowerRoman',
914
+ I: 'UpperRoman',
915
+ };
916
+ function list(ctx, n) {
917
+ const tight = Boolean(n.tight);
918
+ const items = n.items ?? [];
919
+ const converted = items.map((item) => {
920
+ const prev = ctx.tight;
921
+ ctx.tight = tight;
922
+ let itemBlocks = blocks(ctx, item.children);
923
+ ctx.tight = prev;
924
+ if (typeof item.checked === 'boolean') {
925
+ itemBlocks = prefixTaskMarker(itemBlocks, item.checked);
926
+ }
927
+ return itemBlocks;
928
+ });
929
+ if (n.ordered) {
930
+ const style = OL_TYPE[String(n.olType ?? '')] ?? 'Decimal';
931
+ // `delim` lands in the Carve AST with carve-js PR 342; older parsers
932
+ // simply leave it undefined and we emit pandoc's default.
933
+ const delim = n.delim === ')' ? 'OneParen' : n.delim === '.' ? 'Period' : 'DefaultDelim';
934
+ return P.OrderedList(Number(n.start ?? 1), style, converted, delim);
935
+ }
936
+ return P.BulletList(converted);
937
+ }
938
+ /** GFM-style task markers: pandoc renders `- [x]` as a leading ballot-box Str. */
939
+ function prefixTaskMarker(itemBlocks, checked) {
940
+ const marker = [P.Str(checked ? '☒' : '☐'), P.Space];
941
+ const first = itemBlocks[0];
942
+ if (first && (first.t === 'Plain' || first.t === 'Para')) {
943
+ return [
944
+ { t: first.t, c: [...marker, ...first.c] },
945
+ ...itemBlocks.slice(1),
946
+ ];
947
+ }
948
+ return [P.Plain(marker), ...itemBlocks];
949
+ }
950
+ /**
951
+ * A definition list, from the FLAT wire sequence (PART 12): `definition_term`
952
+ * and `definition_description` nodes in document order, exactly as `<dt>` and
953
+ * `<dd>` appear in the rendered list.
954
+ *
955
+ * Pandoc's `DefinitionList` is grouped, so the grouping is recovered by the
956
+ * rule the renderers already use and the engines agree on: a run of
957
+ * descriptions belongs to the run of terms before it.
958
+ */
959
+ function definitionList(ctx, n) {
960
+ const entries = n.items ?? [];
961
+ const converted = [];
962
+ let terms = [];
963
+ let defs = [];
964
+ const flush = () => {
965
+ if (!terms.length && !defs.length)
966
+ return;
967
+ // Pandoc has one term per item; multiple Carve terms join with LineBreak.
968
+ const term = terms.length
969
+ ? terms.reduce((acc, t) => (acc.length ? [...acc, P.LineBreak, ...t] : t), [])
970
+ : [];
971
+ converted.push([term, defs]);
972
+ terms = [];
973
+ defs = [];
974
+ };
975
+ for (const entry of entries) {
976
+ if (entry?.type === 'definition_term') {
977
+ // A term after a description starts the next entry.
978
+ if (defs.length)
979
+ flush();
980
+ terms.push(inlines(ctx, entry.children));
981
+ }
982
+ else if (entry?.type === 'definition_description') {
983
+ defs.push(untight(ctx, () => blocks(ctx, entry.children)));
984
+ }
985
+ else {
986
+ warn(ctx, `definition list: unknown entry type "${String(entry?.type)}" - skipped`);
987
+ }
988
+ }
989
+ flush();
990
+ return P.DefinitionList(converted);
991
+ }
992
+ const ALIGN = {
993
+ left: 'AlignLeft',
994
+ right: 'AlignRight',
995
+ center: 'AlignCenter',
996
+ };
997
+ /**
998
+ * Convert a Carve table.
999
+ *
1000
+ * Carve marks *continuation* cells (`span: "colspan" | "rowspan"` on the cell
1001
+ * that is covered); pandoc puts rowSpan/colSpan counts on the origin Cell and
1002
+ * omits covered positions. We walk the grid, resolve each continuation to its
1003
+ * origin transitively, bump the origin's span, and emit only origin cells.
1004
+ */
1005
+ function table(ctx, n, caption, shortCaption = null) {
1006
+ if (!caption && Array.isArray(n.caption)) {
1007
+ ctx.captionKind = captionLabel(n.caption);
1008
+ caption = inlines(ctx, n.caption);
1009
+ ctx.captionKind = undefined;
1010
+ }
1011
+ if (!shortCaption && Array.isArray(n.shortCaption)) {
1012
+ shortCaption = inlines(ctx, n.shortCaption);
1013
+ }
1014
+ const rows = (n.rows ?? []).map((r) => r.cells ?? []);
1015
+ // PART 12 §15: an explicit partition of `rows` into head, body groups and
1016
+ // foot. Absent means the implicit structure below, which is what every
1017
+ // renderer derives anyway.
1018
+ const read = readRowGroups(n.rowGroups, rows.length);
1019
+ if (read.error) {
1020
+ warn(ctx, `table: ${read.error} - converted with the implicit head/body split instead`);
1021
+ }
1022
+ const groups = read.groups;
1023
+ // Split leading all-header rows into the table head.
1024
+ let headCount = 0;
1025
+ while (headCount < rows.length && rows[headCount].length > 0 && rows[headCount].every((c) => c.header)) {
1026
+ headCount++;
1027
+ }
1028
+ if (groups)
1029
+ headCount = groups.headRows;
1030
+ // Which pandoc row list each row belongs to. A cell may not span across
1031
+ // two of them: head, a body group's intermediate header, that group's
1032
+ // rows and the foot are separate `[Row]` lists in pandoc's model.
1033
+ const sectionOf = Array(rows.length).fill(0);
1034
+ {
1035
+ let at = 0;
1036
+ let section = 0;
1037
+ const take = (howMany) => {
1038
+ for (let i = 0; i < howMany && at < rows.length; i++)
1039
+ sectionOf[at++] = section;
1040
+ section++;
1041
+ };
1042
+ take(headCount);
1043
+ if (groups) {
1044
+ for (const body of groups.bodies) {
1045
+ take(body.headRows);
1046
+ take(body.bodyRows);
1047
+ }
1048
+ take(groups.footRows);
1049
+ }
1050
+ else {
1051
+ take(rows.length - headCount);
1052
+ }
1053
+ }
1054
+ // Column alignment comes from the first row's cells, but ONLY when that row
1055
+ // is the head. Carve spells column alignment on the header marker
1056
+ // (`|=> Name |`), and a marker on a body cell aligns that cell alone -
1057
+ // measured on the engine: `|>a| b |` styles `a` and leaves the cell below
1058
+ // it untouched. Reading a body row's markers as the column's therefore
1059
+ // aligned cells the author had not aligned, in every pandoc writer, because
1060
+ // a cell with `AlignDefault` inherits its ColSpec.
1061
+ //
1062
+ // The head case moves the alignment rather than copying it: the marker on a
1063
+ // head cell IS the column's alignment, so it becomes the ColSpec and the
1064
+ // cell keeps `AlignDefault`. That is pandoc's own model, and it makes the
1065
+ // crossing exact in both directions instead of adding a per-cell override
1066
+ // that was never in the source.
1067
+ const firstRowIsHead = headCount > 0;
1068
+ const firstRow = rows[0] ?? [];
1069
+ const colAligns = firstRow.map((c) => (firstRowIsHead ? ALIGN[c.align ?? ''] ?? 'AlignDefault' : 'AlignDefault'));
1070
+ const origin = rows.map(() => []);
1071
+ const emitted = rows.map((row) => row.map(() => null));
1072
+ for (let r = 0; r < rows.length; r++) {
1073
+ const row = rows[r];
1074
+ for (let c = 0; c < row.length; c++) {
1075
+ const cc = row[c];
1076
+ // Pandoc omits covered positions entirely, so a continuation that
1077
+ // RESOLVES leaves nowhere to hang its attributes. The grammar
1078
+ // cannot produce that shape (a cell carrying attributes is never a
1079
+ // bare span cell); a wire AST can. An ORPHAN continuation is a
1080
+ // different story - it falls through and becomes a real cell below,
1081
+ // which keeps them, so the warning belongs on the resolving paths
1082
+ // only.
1083
+ const droppedAttrs = () => {
1084
+ if (hasAttrs(cc.attrs)) {
1085
+ warn(ctx, `table: attributes on the continuation cell at row ${r + 1}, col ${c + 1} are dropped - pandoc omits covered positions`);
1086
+ }
1087
+ };
1088
+ if (cc.span === 'colspan') {
1089
+ const org = origin[r][c - 1];
1090
+ if (org) {
1091
+ // max() keeps 2D blocks correct: interior continuations of
1092
+ // a lower row must not widen the origin again.
1093
+ if (r === org.row)
1094
+ org.cell.colSpan = Math.max(org.cell.colSpan, c - org.col + 1);
1095
+ origin[r][c] = org;
1096
+ droppedAttrs();
1097
+ continue;
1098
+ }
1099
+ warn(ctx, `table: colspan continuation at row ${r + 1}, col ${c + 1} has no origin - emitting empty cell`);
1100
+ }
1101
+ else if (cc.span === 'rowspan') {
1102
+ const org = r > 0 ? origin[r - 1][c] : undefined;
1103
+ if (org) {
1104
+ // POLICY: clip, do not restructure. Carve is the richer
1105
+ // model here - its rows are one flat list, so a rowspan may
1106
+ // start in the head and continue into the body; pandoc's
1107
+ // TableHead and TableBody hold separate row lists and a
1108
+ // Cell's rowSpan is confined to the section that owns it,
1109
+ // so the covering cell has no shape on that side. The
1110
+ // alternatives both lie about the source: moving the
1111
+ // boundary so the span fits silently reclassifies a header
1112
+ // row as a body row (or the reverse), and duplicating the
1113
+ // origin's content into the body invents a second cell the
1114
+ // author never wrote. Clipping to an empty body cell keeps
1115
+ // the grid the right size, and the warning below says what
1116
+ // was lost.
1117
+ if (sectionOf[org.row] !== sectionOf[r]) {
1118
+ warn(ctx, `table: rowspan crossing the header/body boundary at row ${r + 1}, col ${c + 1} - clipped to an empty body cell (pandoc cannot represent it)`);
1119
+ }
1120
+ else {
1121
+ // max(): a 2D block has one rowspan continuation per
1122
+ // covered column - count rows, not continuations.
1123
+ org.cell.rowSpan = Math.max(org.cell.rowSpan, r - org.row + 1);
1124
+ origin[r][c] = org;
1125
+ droppedAttrs();
1126
+ continue;
1127
+ }
1128
+ }
1129
+ else {
1130
+ warn(ctx, `table: rowspan continuation at row ${r + 1}, col ${c + 1} has no origin - emitting empty cell`);
1131
+ }
1132
+ }
1133
+ const cellBlocks = cc.children?.length
1134
+ ? [P.Plain(untight(ctx, () => inlines(ctx, cc.children)))]
1135
+ : [];
1136
+ // A head cell's marker became the ColSpec above, so repeating it
1137
+ // here would emit an override pandoc does not need and the source
1138
+ // never had.
1139
+ const cellAlign = firstRowIsHead && r === 0
1140
+ ? 'AlignDefault'
1141
+ : ALIGN[cc.align ?? ''] ?? 'AlignDefault';
1142
+ const pc = P.cell(cellBlocks, cellAlign, toAttr(cc.attrs));
1143
+ origin[r][c] = { cell: pc, row: r, col: c };
1144
+ emitted[r][c] = pc;
1145
+ }
1146
+ }
1147
+ const toRows = (from, to) => {
1148
+ const out = [];
1149
+ for (let r = from; r < to; r++) {
1150
+ const cells = emitted[r].filter((x) => x !== null);
1151
+ const rowAttrs = n.rows[r]?.attrs;
1152
+ out.push(P.row(cells, hasAttrs(rowAttrs) ? toAttr(rowAttrs) : undefined));
1153
+ }
1154
+ return out;
1155
+ };
1156
+ let bodies;
1157
+ let footRows = [];
1158
+ if (groups) {
1159
+ let at = headCount;
1160
+ bodies = groups.bodies.map((body) => {
1161
+ const headTo = at + body.headRows;
1162
+ const bodyTo = headTo + body.bodyRows;
1163
+ const converted = {
1164
+ attr: toAttr(body.attrs),
1165
+ headRows: toRows(at, headTo),
1166
+ bodyRows: toRows(headTo, bodyTo),
1167
+ };
1168
+ if (body.rowHeadColumns)
1169
+ converted.rowHeadColumns = body.rowHeadColumns;
1170
+ at = bodyTo;
1171
+ return converted;
1172
+ });
1173
+ footRows = toRows(at, at + groups.footRows);
1174
+ }
1175
+ else {
1176
+ // The implicit structure: everything after the head is one body.
1177
+ bodies = [{ bodyRows: toRows(headCount, rows.length) }];
1178
+ }
1179
+ return P.Table(toAttr(n.attrs), caption, colAligns, toRows(0, headCount), bodies, footRows, shortCaption);
1180
+ }
1181
+ // --- List tables (listTable extension semantics) ---
1182
+ /** Sole-content span marker of a list-table cell: `<` joins left, `^` joins up. */
1183
+ function spanMarker(cellBlocks) {
1184
+ if (cellBlocks.length !== 1)
1185
+ return null;
1186
+ const only = cellBlocks[0];
1187
+ if (only.type !== 'paragraph')
1188
+ return null;
1189
+ const children = only.children ?? [];
1190
+ if (children.length !== 1)
1191
+ return null;
1192
+ const t = children[0];
1193
+ if (t.type !== 'text')
1194
+ return null;
1195
+ const v = String(t.value).trim();
1196
+ return v === '<' ? 'colspan' : v === '^' ? 'rowspan' : null;
1197
+ }
1198
+ /**
1199
+ * Resolve `header-rows` like the canonical listTable extension: absent -> 0,
1200
+ * boolean form `{header-rows}` (stored as "") -> 1, explicit number -> count.
1201
+ */
1202
+ function headerCount(value) {
1203
+ if (value === undefined)
1204
+ return 0;
1205
+ if (value.trim() === '')
1206
+ return 1;
1207
+ const parsed = parseInt(value, 10);
1208
+ return Number.isNaN(parsed) ? 0 : Math.max(0, parsed);
1209
+ }
1210
+ /**
1211
+ * Returns null when the block is not strictly table-shaped (extra siblings
1212
+ * around the outer list, or a row item that is not exactly one nested list) -
1213
+ * mirroring the canonical extension, which defers those to the degraded Div
1214
+ * so authored content is never dropped.
1215
+ */
1216
+ function listTableToTable(ctx, n) {
1217
+ const a = (n.attrs ?? {});
1218
+ const headerRows = headerCount(a.keyValues?.['header-rows']);
1219
+ // `header-cols` promotes the first N cells of every row to row headers
1220
+ // (extensions.md §5.1), which is exactly pandoc's `RowHeadColumns`. It was
1221
+ // read by nobody and left behind as an ordinary table attribute.
1222
+ const headerCols = headerCount(a.keyValues?.['header-cols']);
1223
+ const caption = Array.isArray(n.title) ? inlines(ctx, n.title) : null;
1224
+ // Strict shape: exactly one child, the outer list; every row item holds
1225
+ // exactly one child, the inner cell list.
1226
+ const children = n.children ?? [];
1227
+ if (children.length !== 1 || children[0].type !== 'list')
1228
+ return null;
1229
+ const rowItems = children[0].items ?? [];
1230
+ const grid = [];
1231
+ for (const rowItem of rowItems) {
1232
+ const rowChildren = rowItem.children ?? [];
1233
+ if (rowChildren.length !== 1 || rowChildren[0].type !== 'list')
1234
+ return null;
1235
+ grid.push((rowChildren[0].items ?? []).map((cellItem) => cellItem.children ?? []));
1236
+ }
1237
+ const nCols = Math.max(0, ...grid.map((r) => r.length));
1238
+ const origin = grid.map(() => []);
1239
+ const emitted = grid.map((r) => r.map(() => null));
1240
+ for (let r = 0; r < grid.length; r++) {
1241
+ for (let c = 0; c < grid[r].length; c++) {
1242
+ const cellBlocks = grid[r][c];
1243
+ const marker = spanMarker(cellBlocks);
1244
+ if (marker === 'colspan' && origin[r][c - 1]) {
1245
+ const org = origin[r][c - 1];
1246
+ if (r === org.row)
1247
+ org.cell.colSpan = Math.max(org.cell.colSpan, c - org.col + 1);
1248
+ origin[r][c] = org;
1249
+ continue;
1250
+ }
1251
+ if (marker === 'rowspan' && r > 0 && origin[r - 1][c]) {
1252
+ const org = origin[r - 1][c];
1253
+ // The list-table half of the clip-do-not-restructure policy the
1254
+ // pipe-table path states in full; same reason, same outcome.
1255
+ const crossesHead = org.row < headerRows && r >= headerRows;
1256
+ if (!crossesHead) {
1257
+ org.cell.rowSpan = Math.max(org.cell.rowSpan, r - org.row + 1);
1258
+ origin[r][c] = org;
1259
+ continue;
1260
+ }
1261
+ warn(ctx, `list-table: rowspan crossing the header/body boundary at row ${r + 1} - clipped`);
1262
+ }
1263
+ const pc = P.cell(untight(ctx, () => blocks(ctx, cellBlocks)));
1264
+ origin[r][c] = { cell: pc, row: r, col: c };
1265
+ emitted[r][c] = pc;
1266
+ }
1267
+ }
1268
+ const toRows = (from, to) => {
1269
+ const out = [];
1270
+ for (let r = from; r < to; r++) {
1271
+ out.push(P.row(emitted[r].filter((x) => x !== null)));
1272
+ }
1273
+ return out;
1274
+ };
1275
+ const head = Math.min(headerRows, grid.length);
1276
+ const [id, classes, kvs] = toAttr(a);
1277
+ const body = { bodyRows: toRows(head, grid.length) };
1278
+ if (headerCols > 0)
1279
+ body.rowHeadColumns = Math.min(headerCols, nCols);
1280
+ return P.Table([id, classes, kvs.filter(([k]) => k !== 'header-rows' && k !== 'header-cols')], caption, Array(nCols).fill('AlignDefault'), toRows(0, head), [body]);
1281
+ }
1282
+ // --- Figures ---
1283
+ /**
1284
+ * A captioned host, which is pandoc's `Figure` - the generic captioned wrapper
1285
+ * on both sides (PART 9 §4b).
1286
+ *
1287
+ * A QUOTE IS NOT A SPECIAL HOST. carve#1161 briefly made a caption on a quote
1288
+ * an `attribution` rendered inside the `<blockquote>`, and this function
1289
+ * rerouted a quote target into that shape; the clause is withdrawn
1290
+ * (carve#1213), and PART 9 §4b now cites the HTML Standard in the other
1291
+ * direction - attribution "must be placed outside the `blockquote` element",
1292
+ * with a `<figure>` + `<figcaption>` given as the way to relate the two. So a
1293
+ * captioned quote takes the same path as a captioned code listing or a
1294
+ * captioned equation, and corpus 07-blockquote-with-attribution pins the
1295
+ * `<figure><blockquote>…<figcaption>` bytes.
1296
+ *
1297
+ * WHAT THE REROUTE BOUGHT, AND WHY IT IS NOT ENOUGH. Pandoc's plain and rst
1298
+ * writers drop a `Figure` caption wholesale, and the latex writer floats it -
1299
+ * measured on pandoc 3.5, and the reason §4a was implemented here. But they do
1300
+ * that to EVERY figure: a captioned code block loses its caption in both
1301
+ * writers today. That is a degradation of those two writers, one level below
1302
+ * the AST this bridge produces, and paying for it with a node shape the spec
1303
+ * denies would put the attribution in the one place the HTML Standard names as
1304
+ * wrong.
1305
+ */
1306
+ function figure(ctx, n) {
1307
+ const target = n.target;
1308
+ ctx.captionKind = captionLabel(n.caption);
1309
+ const caption = Array.isArray(n.caption) ? inlines(ctx, n.caption) : null;
1310
+ const shortCaption = Array.isArray(n.shortCaption)
1311
+ ? inlines(ctx, n.shortCaption)
1312
+ : null;
1313
+ ctx.captionKind = undefined;
1314
+ if (!target)
1315
+ return [];
1316
+ if (target.type === 'table') {
1317
+ // Pandoc tables carry a native caption; no Figure wrapper needed.
1318
+ return [table(ctx, target, caption, shortCaption)];
1319
+ }
1320
+ if (target.type === 'image') {
1321
+ const img = inline(ctx, target);
1322
+ return [P.Figure(toAttr(n.attrs), caption, [P.Plain(img)], shortCaption)];
1323
+ }
1324
+ // Any other captionable target, a QUOTE INCLUDED - this is the arm a
1325
+ // captioned quote takes, at document level and as a §4c panel alike. It
1326
+ // used to be unreachable for a quote outside a group, because §4a rerouted
1327
+ // that case; nothing reroutes it now.
1328
+ return [P.Figure(toAttr(n.attrs), caption, untight(ctx, () => block(ctx, target)), shortCaption)];
1329
+ }
1330
+ /** The §4c panels of a group: its `figure` and `table` children, in order. */
1331
+ function isPanel(n) {
1332
+ return n?.type === 'figure' || n?.type === 'table';
1333
+ }
1334
+ /**
1335
+ * A composite figure (PART 9 §4c, carve#1122): a bare `::: figure` container
1336
+ * is ONE figure of ordered panels, and Pandoc has that model natively - a
1337
+ * `Figure` whose blocks are themselves `Figure`s is its subfigure shape. So
1338
+ * the group becomes the outer `Figure`, its caption the outer caption, and its
1339
+ * children the blocks, with the panels among them lowering to nested Figures.
1340
+ *
1341
+ * Before the engine pin carried the node, `::: figure` parsed as a generic
1342
+ * admonition and crossed as `Div ["admonition","figure"]`. That Div is gone;
1343
+ * a filter keyed on it has to key on the Figure nesting instead.
1344
+ *
1345
+ * THE NUMBER IS DRAWN AT THE OPENING FENCE, which is why the caption is
1346
+ * converted BEFORE the children even though its `^ ` line is the construct's
1347
+ * last: corpus 318-composite-figures-11 numbers the group "Figure 1" and a
1348
+ * captioned figure nested deeper inside it "Figure 2".
1349
+ *
1350
+ * PANELS ARE THE DIRECT `figure`/`table` CHILDREN, and everything else is
1351
+ * plain group content preserved IN PLACE between them (§4c, corpus
1352
+ * 318-composite-figures-5) - so the children convert in source order and
1353
+ * nothing is re-sorted into a panel array.
1354
+ *
1355
+ * A DIRECT-CHILD CAPTIONED QUOTE IS A PANEL like any other captioned host -
1356
+ * "the quote is not a special host inside the group either" (corpus
1357
+ * 318-composite-figures-10, where it renders as a panel with a figcaption).
1358
+ * It reads that way because a captioned quote is a `figure` EVERYWHERE now,
1359
+ * not because the group holds a reroute the rest of the document does not; the
1360
+ * §4a attribution that once needed one is withdrawn (carve#1213).
1361
+ */
1362
+ function figureGroup(ctx, n) {
1363
+ ctx.captionKind = captionLabel(n.caption);
1364
+ const caption = Array.isArray(n.caption) ? inlines(ctx, n.caption) : null;
1365
+ ctx.captionKind = undefined;
1366
+ const children = n.children ?? [];
1367
+ const body = untight(ctx, () => children.flatMap((child) => {
1368
+ if (!isPanel(child))
1369
+ return block(ctx, child);
1370
+ const prev = ctx.inPanel;
1371
+ ctx.inPanel = true;
1372
+ try {
1373
+ return child.type === 'figure' ? figure(ctx, child) : block(ctx, child);
1374
+ }
1375
+ finally {
1376
+ ctx.inPanel = prev;
1377
+ }
1378
+ }));
1379
+ return [P.Figure(toAttr(n.attrs), caption, body)];
1380
+ }
1381
+ // --- Metadata (frontmatter) ---
1382
+ /**
1383
+ * The YAML subset typical frontmatter uses, read to the depth pandoc's `Meta`
1384
+ * has: nested maps, block and flow sequences, scalars.
1385
+ *
1386
+ * Not a YAML implementation. Anchors, tags, multi-document streams, block
1387
+ * scalars and flow maps are out; a line that does not fit is reported and
1388
+ * skipped rather than guessed at. What IS in is the shape a real document
1389
+ * carries - `author: [ - name:, affiliation: ]`, `keywords:` as a block list,
1390
+ * a `bibliography` map - which used to end as an EMPTY `MetaInlines` under the
1391
+ * parent key, with one "line not understood" per child line and nothing said
1392
+ * about the parent having been emptied.
1393
+ */
1394
+ function parseMeta(ctx, frontmatter) {
1395
+ const fm = frontmatter;
1396
+ if (!fm?.content)
1397
+ return {};
1398
+ if (fm.format && fm.format !== 'yaml') {
1399
+ warn(ctx, `frontmatter: format "${fm.format}" not supported - skipped`);
1400
+ return {};
1401
+ }
1402
+ const lines = [];
1403
+ // Block scalars come out FIRST, while the blank lines inside them are still
1404
+ // there: the line reader below drops blank lines (they carry no structure
1405
+ // in a mapping), and a blank line inside a literal scalar is a paragraph
1406
+ // break. Each becomes one sentinel scalar the ordinary reader can carry.
1407
+ for (const raw of liftBlockScalars(ctx, fm.content).split('\n')) {
1408
+ if (!raw.trim() || raw.trim().startsWith('#'))
1409
+ continue;
1410
+ lines.push({ indent: raw.length - raw.trimStart().length, text: raw.trim() });
1411
+ }
1412
+ const reader = { ctx, lines, at: 0 };
1413
+ const map = readMapping(reader, lines[0]?.indent ?? 0);
1414
+ // A line the mapping reader could not use never advances past it; the guard
1415
+ // is what keeps a malformed document from spinning here.
1416
+ while (reader.at < lines.length) {
1417
+ warn(ctx, `frontmatter: line not understood, skipped: ${lines[reader.at].text}`);
1418
+ reader.at++;
1419
+ }
1420
+ return map;
1421
+ }
1422
+ /**
1423
+ * A `key: |` line and the lines indented under it, replaced by one sentinel.
1424
+ *
1425
+ * The literal block scalar is the ONE YAML form that carries block content,
1426
+ * and it is the form pandoc's own markdown writer emits for `MetaBlocks` and
1427
+ * its reader turns back into `MetaBlocks` - key-agnostically, so `abstract`
1428
+ * gets no special case here either. Lifting it out before the line reader runs
1429
+ * keeps two things that would otherwise fight: the reader drops blank lines,
1430
+ * and a blank line inside the scalar is a paragraph break.
1431
+ *
1432
+ * Only the plain `|` is read. `|-`, `|+`, `>` and an explicit indentation
1433
+ * indicator are not what the writer emits, and this file's rule for the rest of
1434
+ * YAML applies to them too: a line that does not fit a known shape is reported
1435
+ * and skipped, never guessed at.
1436
+ */
1437
+ const BLOCK_SCALAR_OPEN = /^(\s*)("[^"]*"|'[^']*'|[^:"'#][^:]*|[^:"'#]):[ \t]*\|[ \t]*$/;
1438
+ function liftBlockScalars(ctx, content) {
1439
+ if (!content.includes('|'))
1440
+ return content;
1441
+ const lines = content.split('\n');
1442
+ const out = [];
1443
+ for (let i = 0; i < lines.length; i++) {
1444
+ const open = BLOCK_SCALAR_OPEN.exec(lines[i]);
1445
+ if (!open) {
1446
+ out.push(lines[i]);
1447
+ continue;
1448
+ }
1449
+ const keyIndent = open[1].length;
1450
+ const body = [];
1451
+ let j = i + 1;
1452
+ for (; j < lines.length; j++) {
1453
+ const line = lines[j];
1454
+ const deeper = line.trim() !== '' && line.length - line.trimStart().length > keyIndent;
1455
+ if (!deeper && line.trim() !== '')
1456
+ break;
1457
+ body.push(line);
1458
+ }
1459
+ // Trailing blank lines belong to whatever follows, not to the scalar.
1460
+ while (body.length && body[body.length - 1].trim() === '')
1461
+ body.pop();
1462
+ if (!body.length) {
1463
+ out.push(lines[i]);
1464
+ continue;
1465
+ }
1466
+ const strip = Math.min(...body.filter((l) => l.trim() !== '').map((l) => l.length - l.trimStart().length));
1467
+ // A NUL cannot occur in the frontmatter of a real document and is
1468
+ // not whitespace, so the sentinel survives the reader's `trim()`
1469
+ // and can never be confused with a scalar the author wrote.
1470
+ const sentinel = `\u0000block${ctx.blockScalars.size}\u0000`;
1471
+ ctx.blockScalars.set(sentinel, body.map((l) => l.slice(strip)).join('\n') + '\n');
1472
+ out.push(`${open[1]}${open[2]}: ${sentinel}`);
1473
+ i = j - 1;
1474
+ }
1475
+ return out.join('\n');
1476
+ }
1477
+ /**
1478
+ * The Carve markup inside a literal block scalar, as `MetaBlocks`.
1479
+ *
1480
+ * This is the half of the round trip that used to be missing, and its absence
1481
+ * was the whole argument for dropping `MetaBlocks` on the way out: the value
1482
+ * "makes the frontmatter carry markup that nothing on the reading side
1483
+ * parses". Something does now, so the value survives both ways - the same
1484
+ * bargain pandoc's own markdown reader and writer strike over `abstract: |`.
1485
+ */
1486
+ function blockScalarValue(ctx, key, source) {
1487
+ if (!ctx.parseBlocks) {
1488
+ // The AST entry points supply the parser; a caller reaching `convert`
1489
+ // directly has no engine to lend, and the old skip is still the honest
1490
+ // answer there.
1491
+ warn(ctx, `frontmatter: block content under "${key}" needs a Carve parser to read - `
1492
+ + 'use carveToPandoc / carveAstToPandoc, which supply one; skipped');
1493
+ return P.MetaInlines([]);
1494
+ }
1495
+ const children = ctx.parseBlocks(source);
1496
+ return P.MetaBlocks(blocks(ctx, children));
1497
+ }
1498
+ /**
1499
+ * A `key:` line. The unquoted alternative must not OPEN with a quote, or a
1500
+ * quoted scalar holding a colon (`- "scope: local"`) matches it and becomes a
1501
+ * map whose key is `"scope` - pandoc reads that item as one string.
1502
+ */
1503
+ const KEY_LINE = /^("[^"]*"|'[^']*'|[^:"'][^:]*|[^:"']):(?:\s+(.*))?$/;
1504
+ /** A mapping runs while lines sit at exactly `indent` and look like keys. */
1505
+ function readMapping(r, indent) {
1506
+ const out = {};
1507
+ while (r.at < r.lines.length) {
1508
+ const line = r.lines[r.at];
1509
+ if (line.indent !== indent || line.text.startsWith('- ') || line.text === '-')
1510
+ break;
1511
+ const m = KEY_LINE.exec(line.text);
1512
+ if (!m)
1513
+ break;
1514
+ const key = unquoteYaml(m[1]);
1515
+ const inline = (m[2] ?? '').trim();
1516
+ r.at++;
1517
+ out[key] = inline !== '' ? scalarValue(r.ctx, key, inline) : readChild(r, indent, key);
1518
+ }
1519
+ return out;
1520
+ }
1521
+ /** A sequence runs while lines sit at exactly `indent` and open with `-`. */
1522
+ function readSequence(r, indent) {
1523
+ const out = [];
1524
+ while (r.at < r.lines.length) {
1525
+ const line = r.lines[r.at];
1526
+ if (line.indent !== indent || !(line.text === '-' || line.text.startsWith('- ')))
1527
+ break;
1528
+ const rest = line.text.slice(1).trim();
1529
+ if (rest === '') {
1530
+ r.at++;
1531
+ out.push(readChild(r, indent, ''));
1532
+ continue;
1533
+ }
1534
+ const m = KEY_LINE.exec(rest);
1535
+ if (m) {
1536
+ // `- name: Ada` opens a map whose later keys align under `name`, so
1537
+ // the item's own indent is where that key starts, not the dash.
1538
+ const keyIndent = indent + (line.text.length - rest.length);
1539
+ r.lines[r.at] = { indent: keyIndent, text: rest };
1540
+ out.push(P.MetaMap(readMapping(r, keyIndent)));
1541
+ continue;
1542
+ }
1543
+ r.at++;
1544
+ out.push(scalarValue(r.ctx, '', rest));
1545
+ }
1546
+ return out;
1547
+ }
1548
+ /**
1549
+ * The value written under a key that had none on its own line: whatever sits at
1550
+ * the next deeper indent. Nothing deeper means an empty value, which is YAML's
1551
+ * own reading of `key:` alone.
1552
+ */
1553
+ function readChild(r, indent, key) {
1554
+ const next = r.lines[r.at];
1555
+ if (!next || next.indent <= indent)
1556
+ return P.MetaInlines([]);
1557
+ if (next.text === '-' || next.text.startsWith('- ')) {
1558
+ return P.MetaList(readSequence(r, next.indent));
1559
+ }
1560
+ const map = readMapping(r, next.indent);
1561
+ if (Object.keys(map).length)
1562
+ return P.MetaMap(map);
1563
+ // Nothing consumed at that indent: the reader would not advance, so report
1564
+ // it here rather than leaving the caller to spin.
1565
+ warn(r.ctx, `frontmatter: value under "${key}" not understood, skipped: ${next.text}`);
1566
+ r.at++;
1567
+ return P.MetaInlines([]);
1568
+ }
1569
+ function unquoteYaml(s) {
1570
+ const t = s.trim();
1571
+ if ((t.startsWith('"') && t.endsWith('"') && t.length > 1)
1572
+ || (t.startsWith("'") && t.endsWith("'") && t.length > 1)) {
1573
+ return t.slice(1, -1);
1574
+ }
1575
+ return t;
1576
+ }
1577
+ /**
1578
+ * The boolean and null spellings YAML resolves as such, matching what pandoc's
1579
+ * own frontmatter reader does - it reads the 1.1 set, so `on`, `yes` and a bare
1580
+ * `y` are booleans there too, not strings.
1581
+ *
1582
+ * Matching that set exactly is the point. A `draft: true` arriving as
1583
+ * `MetaInlines "true"` is not a near miss: every pandoc template and filter
1584
+ * tests metadata for truthiness, and a non-empty string is true, so
1585
+ * `draft: false` written in Carve turns the draft flag ON once it crosses the
1586
+ * bridge. The value only has to survive a `MetaBool` on the way out (the writer
1587
+ * has always emitted `true`/`false`) for the round-trip to keep the type.
1588
+ *
1589
+ * A QUOTED scalar is never a boolean, which is why the test runs on the raw
1590
+ * text before {@link unquoteYaml} sees it.
1591
+ */
1592
+ const YAML_TRUE = /^(y|Y|yes|Yes|YES|true|True|TRUE|on|On|ON)$/;
1593
+ const YAML_FALSE = /^(n|N|no|No|NO|false|False|FALSE|off|Off|OFF)$/;
1594
+ const YAML_NULL = /^(~|null|Null|NULL)$/;
1595
+ function scalarValue(ctx, key, raw) {
1596
+ const blockScalar = ctx.blockScalars.get(raw);
1597
+ if (blockScalar !== undefined)
1598
+ return blockScalarValue(ctx, key, blockScalar);
1599
+ // `{}` is the only flow map read: an EMPTY one, which is what the writer
1600
+ // emits for a `MetaMap` with no entries. A populated flow map is not in the
1601
+ // subset and falls through to being read as a string, same as before.
1602
+ if (raw.replace(/\s+/g, '') === '{}')
1603
+ return P.MetaMap({});
1604
+ if (raw.startsWith('[') && raw.endsWith(']')) {
1605
+ const items = raw
1606
+ .slice(1, -1)
1607
+ .split(',')
1608
+ .map((s) => s.trim())
1609
+ .filter((s) => s !== '' && unquoteYaml(s) !== '');
1610
+ // Items are typed by the same rules as a scalar on its own line, so a
1611
+ // `[true, draft]` list keeps the boolean and the word apart.
1612
+ return P.MetaList(items.map((s) => scalarValue(ctx, '', s)));
1613
+ }
1614
+ if (YAML_TRUE.test(raw))
1615
+ return P.MetaBool(true);
1616
+ if (YAML_FALSE.test(raw))
1617
+ return P.MetaBool(false);
1618
+ // Pandoc reads a null scalar as the empty string rather than dropping the
1619
+ // key, so the key stays present and empty here too.
1620
+ if (YAML_NULL.test(raw))
1621
+ return P.MetaString('');
1622
+ const value = unquoteYaml(raw);
1623
+ // Lists where pandoc conventionally expects lists, inlines elsewhere.
1624
+ if (key === 'author' || key === 'authors') {
1625
+ return P.MetaList([P.MetaInlines(textInlines(value))]);
1626
+ }
1627
+ return P.MetaInlines(textInlines(value));
1628
+ }
1629
+ // --- Entry point ---
1630
+ export function convert(ast, options = {}) {
1631
+ // PART 12 section 7: frontmatter and footnote DEFINITIONS are block nodes
1632
+ // in `children`, not fields on the root. They are lifted out here rather
1633
+ // than converted in place - frontmatter becomes pandoc `meta`, and a
1634
+ // definition is emitted at each reference, inside the Note.
1635
+ const children = ast.children ?? [];
1636
+ const footnoteDefs = {};
1637
+ let frontmatter;
1638
+ const body = [];
1639
+ for (const child of children) {
1640
+ if (child?.type === 'frontmatter' && frontmatter === undefined) {
1641
+ frontmatter = child;
1642
+ }
1643
+ else if (child?.type === 'footnote' && typeof child.label === 'string') {
1644
+ // First definition wins, matching the engine's own resolution.
1645
+ footnoteDefs[child.label] ??= child.children ?? [];
1646
+ }
1647
+ else {
1648
+ body.push(child);
1649
+ }
1650
+ }
1651
+ const ctx = {
1652
+ warnings: [],
1653
+ footnoteDefs,
1654
+ crossrefTargets: new Map(),
1655
+ tight: false,
1656
+ inCrossref: false,
1657
+ captionCounts: new Map(),
1658
+ captionKind: undefined,
1659
+ inPanel: false,
1660
+ roundtrip: options.roundtrip ?? false,
1661
+ symbols: options.symbols ?? {},
1662
+ listTable: options.listTable ?? true,
1663
+ noteCount: 0,
1664
+ parseBlocks: options.parseBlocks,
1665
+ blockScalars: new Map(),
1666
+ };
1667
+ // Pass 1: collect crossref targets - heading ids (explicit, plus computed
1668
+ // slugs) and numbered figure/table caption ids - before any block is
1669
+ // converted, so `heading_ref` resolves regardless of where a crossref
1670
+ // sits relative to its target.
1671
+ collectCrossrefTargets(ctx, body, new Map());
1672
+ const meta = parseMeta(ctx, frontmatter);
1673
+ return {
1674
+ doc: {
1675
+ 'pandoc-api-version': [...P.PANDOC_API_VERSION],
1676
+ meta,
1677
+ blocks: blocks(ctx, body),
1678
+ },
1679
+ warnings: ctx.warnings,
1680
+ };
1681
+ }
1682
+ /**
1683
+ * Pass 1: walk the whole document before any block is converted, registering
1684
+ * every target a `heading_ref` (`</#id>`) can resolve to.
1685
+ *
1686
+ * Two kinds of target:
1687
+ * - a heading's own id (explicit, or slugged from its text) -> its inline
1688
+ * children, rendered verbatim as the crossref's link text.
1689
+ * - a numbered figure/table caption's id (the `{#id}` attached to the
1690
+ * figure or table itself) -> the computed "Label N" text, e.g.
1691
+ * "Figure 1" - docs-extensions.md: "`</#id>` to the element resolves to
1692
+ * label + number".
1693
+ *
1694
+ * The caption number is assigned per LABEL in document order (`captionLabel`
1695
+ * plus a running count) - exactly like `ctx.captionCounts` in pass 2 (see the
1696
+ * `caption_number` case in `inline()`). Pass 1 runs before pass 2 and cannot
1697
+ * read its counts, so it keeps its OWN counter here (`captionCounts`,
1698
+ * threaded through the recursion), walking captioned nodes in the same order
1699
+ * pass 2 will. The two counters land on identical numbers only because both
1700
+ * walk the same document the same way - a captioned element with no `{#id}`
1701
+ * still has to bump the counter (it consumes a number in pass 2 even though
1702
+ * nothing can ever crossref it), or a LATER captioned element's number here
1703
+ * would drift out of sync with what pass 2 actually renders.
1704
+ *
1705
+ * A `figure`/`table` node's own `caption` field is read directly - the same
1706
+ * field `figure()`/`table()` read in pass 2 - which also handles a table
1707
+ * nested under a figure with no caption of its own: the recursion below
1708
+ * reaches that inner `table` node too, and its own `caption`/`attrs.id` are
1709
+ * picked up there instead, mirroring `table()`'s fallback. Table cells are
1710
+ * not recursed into, matching the pre-existing limitation for headings
1711
+ * nested inside a table.
1712
+ *
1713
+ * `inPanel` is the third kind of target and the §4c suppression in one flag:
1714
+ * inside a composite figure's panel nothing draws a number, so nothing there
1715
+ * registers either - see `figureGroupTargets`.
1716
+ */
1717
+ function collectCrossrefTargets(ctx, nodes, captionCounts, inPanel = false) {
1718
+ for (const n of nodes) {
1719
+ if (n.type === 'figure_group') {
1720
+ figureGroupTargets(ctx, n, captionCounts, inPanel);
1721
+ continue;
1722
+ }
1723
+ if (n.type === 'heading') {
1724
+ const children = n.children ?? [];
1725
+ const a = (n.attrs ?? {});
1726
+ const id = a.id ?? slugify(plainText(children));
1727
+ if (id && !ctx.crossrefTargets.has(id))
1728
+ ctx.crossrefTargets.set(id, children);
1729
+ }
1730
+ else if (!inPanel && (n.type === 'figure' || n.type === 'table')) {
1731
+ // Every figure counts, whatever it wraps. A quote used to be
1732
+ // excluded here, because §4a made a captioned quote an attribution
1733
+ // that drew no number; that clause is withdrawn (carve#1213), and
1734
+ // a quote figure numbers like the code listing beside it.
1735
+ const caption = n.caption;
1736
+ if (Array.isArray(caption) && caption.some((x) => x?.type === 'caption_number')) {
1737
+ const label = captionLabel(caption) ?? 'caption';
1738
+ const next = (captionCounts.get(label) ?? 0) + 1;
1739
+ captionCounts.set(label, next);
1740
+ const a = (n.attrs ?? {});
1741
+ if (a.id && !ctx.crossrefTargets.has(a.id)) {
1742
+ ctx.crossrefTargets.set(a.id, [{ type: 'text', value: `${label} ${next}` }]);
1743
+ }
1744
+ }
1745
+ }
1746
+ for (const key of ['children', 'items', 'target']) {
1747
+ const v = n[key];
1748
+ if (Array.isArray(v))
1749
+ collectCrossrefTargets(ctx, v, captionCounts, inPanel);
1750
+ else if (v && typeof v === 'object') {
1751
+ collectCrossrefTargets(ctx, [v], captionCounts, inPanel);
1752
+ }
1753
+ }
1754
+ }
1755
+ }
1756
+ /**
1757
+ * The `</#id>` targets a composite figure contributes (PART 9 §4c).
1758
+ *
1759
+ * The GROUP draws one number from its label's sequence, at its OPENING fence -
1760
+ * before anything inside it, which is what makes corpus
1761
+ * 318-composite-figures-11 number the group "Figure 1" and a figure nested
1762
+ * inside its stray content "Figure 2".
1763
+ *
1764
+ * A PANEL - a direct `figure` or `table` child - draws nothing, and its id
1765
+ * resolves as the group's number plus a letter by panel order: "Figure 2a"
1766
+ * (corpus 318-composite-figures-2), a..z then aa, ab, ... A group whose
1767
+ * caption carries no `#` has no number to lend, so it registers nothing for
1768
+ * its panels either.
1769
+ *
1770
+ * The suppression covers everything a panel CONTAINS, not just the panel's own
1771
+ * caption. Stray non-panel content numbers normally, exactly as it would
1772
+ * outside the group.
1773
+ */
1774
+ function figureGroupTargets(ctx, n, captionCounts, inPanel) {
1775
+ const caption = n.caption;
1776
+ let resolved;
1777
+ if (!inPanel &&
1778
+ Array.isArray(caption) &&
1779
+ caption.some((x) => x?.type === 'caption_number')) {
1780
+ const label = captionLabel(caption) ?? 'caption';
1781
+ const next = (captionCounts.get(label) ?? 0) + 1;
1782
+ captionCounts.set(label, next);
1783
+ resolved = `${label} ${next}`;
1784
+ const a = (n.attrs ?? {});
1785
+ if (a.id && !ctx.crossrefTargets.has(a.id)) {
1786
+ ctx.crossrefTargets.set(a.id, [{ type: 'text', value: resolved }]);
1787
+ }
1788
+ }
1789
+ let panelIndex = 0;
1790
+ for (const child of n.children ?? []) {
1791
+ if (!isPanel(child)) {
1792
+ collectCrossrefTargets(ctx, [child], captionCounts, inPanel);
1793
+ continue;
1794
+ }
1795
+ const a = (child.attrs ?? {});
1796
+ const letter = panelLetter(panelIndex++);
1797
+ if (resolved && a.id && !ctx.crossrefTargets.has(a.id)) {
1798
+ ctx.crossrefTargets.set(a.id, [{ type: 'text', value: `${resolved}${letter}` }]);
1799
+ }
1800
+ collectCrossrefTargets(ctx, [child], captionCounts, true);
1801
+ }
1802
+ }
1803
+ /**
1804
+ * The §4c panel letter for panel index `k` (0-based): `a`..`z`, then `aa`,
1805
+ * `ab`, ... - bijective base 26, the same function the engine resolves with.
1806
+ */
1807
+ function panelLetter(k) {
1808
+ let out = '';
1809
+ let i = k + 1;
1810
+ while (i > 0) {
1811
+ i--;
1812
+ out = String.fromCharCode(97 + (i % 26)) + out;
1813
+ i = Math.floor(i / 26);
1814
+ }
1815
+ return out;
1816
+ }