@markup-carve/pandoc-carve 0.1.8 → 0.1.9
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.
- package/dist/cli.js +16 -3
- package/dist/convert.js +334 -40
- package/dist/diagnostics.js +14 -2
- package/dist/import-limits.d.ts +16 -0
- package/dist/import-limits.js +54 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +46 -2
- package/dist/reverse.js +211 -46
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -234,8 +234,21 @@ async function importToCarve(args) {
|
|
|
234
234
|
}
|
|
235
235
|
json = result.stdout;
|
|
236
236
|
}
|
|
237
|
-
const { pandocToCarve } = await import('./index.js');
|
|
238
|
-
|
|
237
|
+
const { pandocToCarve, PandocImportLimitError } = await import('./index.js');
|
|
238
|
+
let converted;
|
|
239
|
+
try {
|
|
240
|
+
converted = pandocToCarve(json);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (!(error instanceof PandocImportLimitError))
|
|
244
|
+
throw error;
|
|
245
|
+
if (args.diagnosticsFile !== undefined) {
|
|
246
|
+
report(args, [error.message], [error.diagnostic], 'pandoc-json');
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
const { carve, warnings, diagnostics } = converted;
|
|
239
252
|
const reportDiagnostics = args.from === 'json' ? diagnostics : [
|
|
240
253
|
...diagnostics,
|
|
241
254
|
{
|
|
@@ -315,7 +328,7 @@ function diagnosticsForIncludes(warnings, suppressed, root) {
|
|
|
315
328
|
const NORMALIZED_INCLUDE_RULES = new Set([
|
|
316
329
|
'include-footnote-rename',
|
|
317
330
|
'include-heading-clamp',
|
|
318
|
-
'include-
|
|
331
|
+
'include-id-rename',
|
|
319
332
|
]);
|
|
320
333
|
function displayIncludeFile(file, root, fallback) {
|
|
321
334
|
if (!file || !root)
|
package/dist/convert.js
CHANGED
|
@@ -15,7 +15,7 @@ import { diagnostic, migrationReport } from './diagnostics.js';
|
|
|
15
15
|
import { provenanceAttr } from './provenance.js';
|
|
16
16
|
import { blankDeniedDestination, probeScheme } from './url-scheme.js';
|
|
17
17
|
import { isDangerousAttrName, renderedAttrValue } from './attribute-sanitize.js';
|
|
18
|
-
import { SMART_PUNCTUATION_GLYPHS } from '@markup-carve/carve';
|
|
18
|
+
import { SMART_PUNCTUATION_GLYPHS, fromAstJson, resolve } from '@markup-carve/carve';
|
|
19
19
|
function warn(ctx, msg, details, sourceLocation) {
|
|
20
20
|
ctx.warnings.push(msg);
|
|
21
21
|
ctx.diagnostics.push(diagnostic('carve-to-pandoc', msg, details, sourceLocation));
|
|
@@ -89,12 +89,74 @@ function smallCapsOrSpan(a, xs) {
|
|
|
89
89
|
return caps;
|
|
90
90
|
return P.Span([id, rest, kvs], [caps]);
|
|
91
91
|
}
|
|
92
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Fallback heading slug, for a tree the engine's own resolver refuses. Mirrors
|
|
94
|
+
* the engine's rule: NFC, invisible controls dropped, each run of non-
|
|
95
|
+
* alphanumeric ASCII one `-`, an `s-` prefix before a leading digit.
|
|
96
|
+
*/
|
|
93
97
|
function slugify(text) {
|
|
94
|
-
|
|
95
|
-
.
|
|
96
|
-
.replace(/[
|
|
97
|
-
.replace(
|
|
98
|
+
const s = text
|
|
99
|
+
.normalize('NFC')
|
|
100
|
+
.replace(/[\u202a-\u202e\u2066-\u2069\u200b-\u200d\u2060\ufeff\u00ad]/gu, '')
|
|
101
|
+
.replace(/[^0-9A-Za-z\u{80}-\u{10FFFF}]+/gu, '-')
|
|
102
|
+
.replace(/^-+|-+$/g, '');
|
|
103
|
+
if (s === '')
|
|
104
|
+
return 's';
|
|
105
|
+
return /^\p{N}/u.test(s) ? `s-${s}` : s;
|
|
106
|
+
}
|
|
107
|
+
const HEADING_TAG = 'data-pandoc-carve-heading';
|
|
108
|
+
/**
|
|
109
|
+
* The id the engine's `resolve()` gives every heading, keyed by the node.
|
|
110
|
+
*
|
|
111
|
+
* Asking the resolver rather than re-deriving: it owns the slug rule, the
|
|
112
|
+
* dedup suffix and the id namespace shared with explicit ids on other
|
|
113
|
+
* elements. Each heading in a copy is tagged so the answer maps back even
|
|
114
|
+
* though ingest moves footnote bodies around. Empty when the resolver refuses
|
|
115
|
+
* the tree; pass 1 then falls back to {@link slugify}.
|
|
116
|
+
*/
|
|
117
|
+
function resolvedHeadingIds(ast) {
|
|
118
|
+
const byTag = [];
|
|
119
|
+
const copy = (value) => {
|
|
120
|
+
if (Array.isArray(value))
|
|
121
|
+
return value.map(copy);
|
|
122
|
+
if (!value || typeof value !== 'object')
|
|
123
|
+
return value;
|
|
124
|
+
const out = {};
|
|
125
|
+
for (const [k, v] of Object.entries(value))
|
|
126
|
+
out[k] = copy(v);
|
|
127
|
+
if (value.type === 'heading') {
|
|
128
|
+
const attrs = (out.attrs ?? {});
|
|
129
|
+
out.attrs = { ...attrs, keyValues: { ...(attrs.keyValues ?? {}), [HEADING_TAG]: String(byTag.length) } };
|
|
130
|
+
byTag.push(value);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
};
|
|
134
|
+
const ids = new Map();
|
|
135
|
+
try {
|
|
136
|
+
const tagged = copy(ast);
|
|
137
|
+
if (byTag.length === 0)
|
|
138
|
+
return ids;
|
|
139
|
+
const resolved = resolve(fromAstJson(tagged));
|
|
140
|
+
const stack = [resolved];
|
|
141
|
+
while (stack.length) {
|
|
142
|
+
const value = stack.pop();
|
|
143
|
+
if (Array.isArray(value))
|
|
144
|
+
stack.push(...value);
|
|
145
|
+
else if (value && typeof value === 'object') {
|
|
146
|
+
const node = value;
|
|
147
|
+
const attrs = node.attrs;
|
|
148
|
+
const tag = node.type === 'heading' ? attrs?.keyValues?.[HEADING_TAG] : undefined;
|
|
149
|
+
const original = tag === undefined ? undefined : byTag[Number(tag)];
|
|
150
|
+
if (original && attrs?.id)
|
|
151
|
+
ids.set(original, attrs.id);
|
|
152
|
+
stack.push(...Object.values(node));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
ids.clear();
|
|
158
|
+
}
|
|
159
|
+
return ids;
|
|
98
160
|
}
|
|
99
161
|
function plainText(nodes) {
|
|
100
162
|
let out = '';
|
|
@@ -134,7 +196,41 @@ function hasLabel(n) {
|
|
|
134
196
|
* exactly; a flattened caption Para would be indistinguishable from a title.
|
|
135
197
|
*/
|
|
136
198
|
function labelCaption(ctx, n) {
|
|
137
|
-
return !ctx.roundtrip && hasLabel(n) ? [P.Para([P.Strong(
|
|
199
|
+
return !ctx.roundtrip && hasLabel(n) ? [P.Para([P.Strong(labelInlines(ctx, n.label))])] : [];
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A container label is an inline run (`CARVE-P9-041`), held as text on the
|
|
203
|
+
* node. It is parsed as a paragraph behind an escaped `.`, which no inline
|
|
204
|
+
* construct can absorb and which keeps a leading `#` or `-` from opening a
|
|
205
|
+
* block; the `.` is then taken back off.
|
|
206
|
+
*/
|
|
207
|
+
function labelInlines(ctx, label) {
|
|
208
|
+
if (!ctx.parseBlocks || /[\r\n]/.test(label))
|
|
209
|
+
return textInlines(label);
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = ctx.parseBlocks(`\\.${label}`);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return textInlines(label);
|
|
216
|
+
}
|
|
217
|
+
const para = parsed.length === 1 && parsed[0]?.type === 'paragraph' ? parsed[0] : undefined;
|
|
218
|
+
const nodes = [...(para?.children ?? [])];
|
|
219
|
+
const first = nodes[0];
|
|
220
|
+
if (first?.type === 'text' && String(first.value ?? '').startsWith('.')) {
|
|
221
|
+
const rest = String(first.value).slice(1);
|
|
222
|
+
if (rest)
|
|
223
|
+
nodes[0] = { ...first, value: rest };
|
|
224
|
+
else
|
|
225
|
+
nodes.shift();
|
|
226
|
+
}
|
|
227
|
+
else if (first?.type === 'escaped_text' && first.value === '.') {
|
|
228
|
+
nodes.shift();
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
return textInlines(label);
|
|
232
|
+
}
|
|
233
|
+
return inlines(ctx, nodes);
|
|
138
234
|
}
|
|
139
235
|
/**
|
|
140
236
|
* Roundtrip-only kv that carries the grouping `[label]` for exact re-import.
|
|
@@ -143,7 +239,8 @@ function labelCaption(ctx, n) {
|
|
|
143
239
|
* that the reverse importer would mistake for this internal marker.
|
|
144
240
|
*/
|
|
145
241
|
function labelKv(ctx, n) {
|
|
146
|
-
|
|
242
|
+
// An empty label still renders (an empty caption line), so it crosses too.
|
|
243
|
+
return ctx.roundtrip && typeof n.label === 'string' ? [['carve.label', n.label]] : [];
|
|
147
244
|
}
|
|
148
245
|
// Glyphs for the smart-typography kinds that do not carry one on the node.
|
|
149
246
|
// A quote's glyph is locale-dependent and is chosen during parsing, so the node
|
|
@@ -395,8 +492,8 @@ function kids(ctx, n) {
|
|
|
395
492
|
* this package's deliverable.
|
|
396
493
|
*
|
|
397
494
|
* One break, because that is what the clause adds. A payload that ends mid-line
|
|
398
|
-
* publishes no break and keeps none here. `raw_block`
|
|
399
|
-
*
|
|
495
|
+
* publishes no break and keeps none here. `raw_block.content` follows the same
|
|
496
|
+
* rule since spec #2916, and pandoc's `RawBlock` carries no final break either.
|
|
400
497
|
*/
|
|
401
498
|
function codePayload(ctx, content, n) {
|
|
402
499
|
if (!content.endsWith('\n'))
|
|
@@ -408,7 +505,8 @@ function codePayload(ctx, content, n) {
|
|
|
408
505
|
// returns `""` for both a blank-line payload and an empty fence - but this
|
|
409
506
|
// is the only place that still knows the difference, so it says so.
|
|
410
507
|
if (payload === '') {
|
|
411
|
-
|
|
508
|
+
const raw = n.type === 'raw_block';
|
|
509
|
+
warn(ctx, `${raw ? 'raw block' : 'code block'}: a blank-only payload reaches pandoc as an empty one - pandoc's ${raw ? 'RawBlock' : 'CodeBlock'} cannot hold the difference`, { construct: raw ? 'raw_block' : 'code_block' }, n.pos);
|
|
412
510
|
}
|
|
413
511
|
return payload;
|
|
414
512
|
}
|
|
@@ -468,7 +566,8 @@ function collapsedHeadingRef(ctx, n) {
|
|
|
468
566
|
}
|
|
469
567
|
/** The key a collapsed reference and a heading's text meet on (PART 9R R1). */
|
|
470
568
|
function headingTextKey(text) {
|
|
471
|
-
|
|
569
|
+
// A tab is key content, not a separator (corpus 565).
|
|
570
|
+
return text.replace(/[ \n\r]+/g, ' ').replace(/^ | $/g, '').normalize('NFC');
|
|
472
571
|
}
|
|
473
572
|
function unresolvedReference(ctx, n, kind) {
|
|
474
573
|
if (n.ref === undefined)
|
|
@@ -494,7 +593,74 @@ function unresolvedReference(ctx, n, kind) {
|
|
|
494
593
|
});
|
|
495
594
|
return out;
|
|
496
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* A Note, with any note referenced from inside it lifted out to follow it.
|
|
598
|
+
*
|
|
599
|
+
* Pandoc's notes do not nest - its writers drop a Note inside a Note - so a
|
|
600
|
+
* reference in a note body becomes a sibling note right after the outer one,
|
|
601
|
+
* and a diagnostic says the reference moved. A roundtrip conversion keeps the
|
|
602
|
+
* nesting: the reverse direction reads it back, and pandoc's JSON carries it.
|
|
603
|
+
*/
|
|
604
|
+
function liftedNote(ctx, body) {
|
|
605
|
+
if (ctx.roundtrip) {
|
|
606
|
+
const note = P.Note(body());
|
|
607
|
+
ctx.noteCount++;
|
|
608
|
+
return [note];
|
|
609
|
+
}
|
|
610
|
+
if (ctx.noteDepth > 0) {
|
|
611
|
+
warn(ctx, 'footnote: a note referenced inside a note is lifted to follow it - pandoc\'s notes do not nest');
|
|
612
|
+
}
|
|
613
|
+
const outer = ctx.liftedNotes;
|
|
614
|
+
ctx.liftedNotes = [];
|
|
615
|
+
ctx.noteDepth++;
|
|
616
|
+
let content;
|
|
617
|
+
let lifted;
|
|
618
|
+
try {
|
|
619
|
+
content = body();
|
|
620
|
+
}
|
|
621
|
+
finally {
|
|
622
|
+
ctx.noteDepth--;
|
|
623
|
+
lifted = ctx.liftedNotes;
|
|
624
|
+
ctx.liftedNotes = outer;
|
|
625
|
+
}
|
|
626
|
+
ctx.noteCount++;
|
|
627
|
+
const notes = [P.Note(content), ...lifted];
|
|
628
|
+
if (ctx.noteDepth > 0) {
|
|
629
|
+
ctx.liftedNotes.push(...notes);
|
|
630
|
+
return [];
|
|
631
|
+
}
|
|
632
|
+
return notes;
|
|
633
|
+
}
|
|
634
|
+
/** Pandoc reads an empty title as no title, so an authored `""` cannot cross. */
|
|
635
|
+
function emptyTitle(ctx, n, kind) {
|
|
636
|
+
if (n.title === '') {
|
|
637
|
+
warn(ctx, `${kind}: an empty title is dropped - pandoc reads an empty title as none`, { construct: kind }, n.pos);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Inline nodes whose pandoc constructor has no Attr slot (or, for the classed
|
|
642
|
+
* Span stand-ins, whose slot the bridge's own class fills), so an attribute run
|
|
643
|
+
* on them is dropped - said once per node rather than silently.
|
|
644
|
+
*/
|
|
645
|
+
const NO_ATTR_SLOT = {
|
|
646
|
+
emphasis: 'Emph',
|
|
647
|
+
strong: 'Strong',
|
|
648
|
+
underline: 'Underline',
|
|
649
|
+
strike: 'Strikeout',
|
|
650
|
+
subscript: 'Subscript',
|
|
651
|
+
superscript: 'Superscript',
|
|
652
|
+
highlight: 'Span .mark',
|
|
653
|
+
insert: 'Span .insertion',
|
|
654
|
+
delete: 'Span .deletion',
|
|
655
|
+
substitution: 'Span .substitution',
|
|
656
|
+
footnote_ref: 'Note',
|
|
657
|
+
inline_footnote: 'Note',
|
|
658
|
+
};
|
|
497
659
|
function inline(ctx, n) {
|
|
660
|
+
const slotless = NO_ATTR_SLOT[n.type];
|
|
661
|
+
if (slotless && hasAttrs(n.attrs)) {
|
|
662
|
+
warn(ctx, `attribute: the attributes on a ${n.type} are dropped - pandoc's ${slotless} has no slot for them`, { construct: n.type }, n.pos);
|
|
663
|
+
}
|
|
498
664
|
switch (n.type) {
|
|
499
665
|
case 'text':
|
|
500
666
|
return textInlines(String(n.value ?? ''));
|
|
@@ -504,6 +670,9 @@ function inline(ctx, n) {
|
|
|
504
670
|
return [P.SoftBreak];
|
|
505
671
|
case 'hard_break':
|
|
506
672
|
return [P.LineBreak];
|
|
673
|
+
case 'cell_wrap':
|
|
674
|
+
// A table continuation-line join; the contract renders it as one space.
|
|
675
|
+
return [P.Space];
|
|
507
676
|
case 'emphasis':
|
|
508
677
|
return [P.Emph(kids(ctx, n))];
|
|
509
678
|
case 'strong':
|
|
@@ -570,6 +739,7 @@ function inline(ctx, n) {
|
|
|
570
739
|
const unresolved = unresolvedReference(ctx, n, 'link');
|
|
571
740
|
if (unresolved)
|
|
572
741
|
return unresolved;
|
|
742
|
+
emptyTitle(ctx, n, 'link');
|
|
573
743
|
return [
|
|
574
744
|
P.Link(toAttr(ctx, n.attrs), inLink(ctx, n), [
|
|
575
745
|
safeDestination(ctx, String(n.href ?? ''), 'link'),
|
|
@@ -605,6 +775,7 @@ function inline(ctx, n) {
|
|
|
605
775
|
const unresolved = unresolvedReference(ctx, n, 'image');
|
|
606
776
|
if (unresolved)
|
|
607
777
|
return unresolved;
|
|
778
|
+
emptyTitle(ctx, n, 'image');
|
|
608
779
|
return [
|
|
609
780
|
P.Image(toAttr(ctx, n.attrs), textInlines(String(n.alt ?? '')), [
|
|
610
781
|
safeDestination(ctx, String(n.src ?? ''), 'image'),
|
|
@@ -658,8 +829,14 @@ function inline(ctx, n) {
|
|
|
658
829
|
ctx.inCrossref = false;
|
|
659
830
|
}
|
|
660
831
|
}
|
|
661
|
-
|
|
662
|
-
|
|
832
|
+
// The engine renders an unresolved crossref as its literal source.
|
|
833
|
+
// Roundtrip keeps the classed link so the reverse direction restores
|
|
834
|
+
// the crossref rather than an escaped `</\#id>`.
|
|
835
|
+
warn(ctx, `crossref: unresolved target "${target}" - emitting the literal source`);
|
|
836
|
+
if (ctx.roundtrip) {
|
|
837
|
+
return [P.Link(P.attr(undefined, ['crossref', 'unresolved']), textInlines(`</#${target}>`), [`#${target}`, ''])];
|
|
838
|
+
}
|
|
839
|
+
return textInlines(`</#${target}>`);
|
|
663
840
|
}
|
|
664
841
|
// The wire has two nodes here, split so that a profile can deny one
|
|
665
842
|
// without the other (markup-carve/carve#405): `footnote_ref` (`[^a]`)
|
|
@@ -672,9 +849,7 @@ function inline(ctx, n) {
|
|
|
672
849
|
// a citation inside the note carries that note's number rather than
|
|
673
850
|
// the next one - which is what pandoc's markdown reader produces.
|
|
674
851
|
if (Array.isArray(n.inline)) {
|
|
675
|
-
|
|
676
|
-
ctx.noteCount++;
|
|
677
|
-
return [note];
|
|
852
|
+
return liftedNote(ctx, () => [P.Para(inlines(ctx, n.inline))]);
|
|
678
853
|
}
|
|
679
854
|
const id = String(n.label ?? n.id ?? '');
|
|
680
855
|
const def = ctx.footnoteDefs[id];
|
|
@@ -701,9 +876,21 @@ function inline(ctx, n) {
|
|
|
701
876
|
warn(ctx, `footnote: missing definition for [^${id}] - emitting the literal source`);
|
|
702
877
|
return textInlines(`[^${id}]`);
|
|
703
878
|
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
879
|
+
if (ctx.openNotes.has(id)) {
|
|
880
|
+
warn(ctx, `footnote: a cyclic reference to [^${id}] inside its own note is dropped - pandoc's notes do not nest`);
|
|
881
|
+
return [];
|
|
882
|
+
}
|
|
883
|
+
if (ctx.citedNotes.has(id)) {
|
|
884
|
+
warn(ctx, `footnote: [^${id}] is referenced more than once - pandoc's Note has no identity, so each reference carries its own copy`, { reference: `[^${id}]` }, n.pos);
|
|
885
|
+
}
|
|
886
|
+
ctx.citedNotes.add(id);
|
|
887
|
+
ctx.openNotes.add(id);
|
|
888
|
+
try {
|
|
889
|
+
return liftedNote(ctx, () => blocks(ctx, def));
|
|
890
|
+
}
|
|
891
|
+
finally {
|
|
892
|
+
ctx.openNotes.delete(id);
|
|
893
|
+
}
|
|
707
894
|
}
|
|
708
895
|
case 'math':
|
|
709
896
|
// PANDOC'S `Math` IS `Math MathType Text` - two children and no
|
|
@@ -969,22 +1156,32 @@ function block(ctx, n) {
|
|
|
969
1156
|
*/
|
|
970
1157
|
function lineBlock(ctx, n) {
|
|
971
1158
|
const lines = [];
|
|
1159
|
+
let emptyLine = false;
|
|
972
1160
|
const stanzas = n.children ?? [];
|
|
973
1161
|
stanzas.forEach((stanza, i) => {
|
|
974
1162
|
if (i > 0)
|
|
975
1163
|
lines.push([]);
|
|
976
1164
|
const kids = stanza.children ?? [stanza];
|
|
977
1165
|
let current = [];
|
|
1166
|
+
let broke = false;
|
|
978
1167
|
for (const kid of kids) {
|
|
979
1168
|
if (kid.type === 'hard_break' || kid.type === 'soft_break') {
|
|
1169
|
+
if (current.length === 0 && broke)
|
|
1170
|
+
emptyLine = true;
|
|
980
1171
|
lines.push(inlines(ctx, current));
|
|
981
1172
|
current = [];
|
|
1173
|
+
broke = true;
|
|
982
1174
|
continue;
|
|
983
1175
|
}
|
|
984
1176
|
current.push(kid);
|
|
985
1177
|
}
|
|
1178
|
+
if (current.length === 0 && broke)
|
|
1179
|
+
emptyLine = true;
|
|
986
1180
|
lines.push(inlines(ctx, current));
|
|
987
1181
|
});
|
|
1182
|
+
if (emptyLine) {
|
|
1183
|
+
warn(ctx, 'line block: a hard break that leaves an empty line becomes an empty entry, which pandoc cannot tell from a stanza break', { construct: 'line_block' }, n.pos);
|
|
1184
|
+
}
|
|
988
1185
|
return P.LineBlock(lines);
|
|
989
1186
|
}
|
|
990
1187
|
function blockInner(ctx, n) {
|
|
@@ -994,18 +1191,21 @@ function blockInner(ctx, n) {
|
|
|
994
1191
|
return [ctx.tight ? P.Plain(xs) : P.Para(xs)];
|
|
995
1192
|
}
|
|
996
1193
|
case 'heading':
|
|
997
|
-
return [P.Header(Number(n.level ?? 1), toAttr(ctx, n.attrs), kids(ctx, n))];
|
|
1194
|
+
return [P.Header(Number(n.level ?? 1), toAttr(ctx, { ...(n.attrs ?? {}), id: ctx.headingIds.get(n) ?? n.attrs?.id }), kids(ctx, n))];
|
|
998
1195
|
case 'block_quote':
|
|
999
1196
|
return [P.BlockQuote(untight(ctx, () => blocks(ctx, n.children)))];
|
|
1000
1197
|
case 'code_block': {
|
|
1001
1198
|
const lang = n.lang ? [String(n.lang)] : [];
|
|
1002
1199
|
const [id, classes, kvs] = toAttr(ctx, n.attrs);
|
|
1200
|
+
if (lang.length === 0 && classes.length > 0) {
|
|
1201
|
+
warn(ctx, `code block: a class without a language is read as the language by pandoc's writers - "${classes[0]}"`, { construct: 'code_block', class: classes[0] }, n.pos);
|
|
1202
|
+
}
|
|
1003
1203
|
return [
|
|
1004
1204
|
P.CodeBlock(P.attr(id, [...lang, ...classes], kvs), codePayload(ctx, String(n.content ?? ''), n)),
|
|
1005
1205
|
];
|
|
1006
1206
|
}
|
|
1007
1207
|
case 'raw_block':
|
|
1008
|
-
return [P.RawBlock(String(n.format ?? ''), String(n.content ?? ''))];
|
|
1208
|
+
return [P.RawBlock(String(n.format ?? ''), codePayload(ctx, String(n.content ?? ''), n))];
|
|
1009
1209
|
case 'citation_definition':
|
|
1010
1210
|
return [citationDefinition(ctx, n)];
|
|
1011
1211
|
case 'thematic_break':
|
|
@@ -1153,15 +1353,27 @@ function list(ctx, n) {
|
|
|
1153
1353
|
? [...kvs, ['carve-list-item', 'true'],
|
|
1154
1354
|
...(carried ? [[TASK_STATE_KEY, state]] : [])]
|
|
1155
1355
|
: kvs;
|
|
1156
|
-
|
|
1356
|
+
const lead = itemBlocks[0];
|
|
1357
|
+
if (typeof item.checked === 'boolean' && lead && (lead.t === 'Plain' || lead.t === 'Para')) {
|
|
1358
|
+
// Pandoc reads a task only from a ballot that OPENS the item's
|
|
1359
|
+
// first block, so a wrapping Div would hide it. The item's
|
|
1360
|
+
// attributes ride on a Span after the ballot instead.
|
|
1361
|
+
const [ballot, space, ...rest] = lead.c;
|
|
1362
|
+
itemBlocks = [
|
|
1363
|
+
{ t: lead.t, c: [ballot, space, P.Span([id, classes, marked], rest)] },
|
|
1364
|
+
...itemBlocks.slice(1),
|
|
1365
|
+
];
|
|
1366
|
+
}
|
|
1367
|
+
else {
|
|
1368
|
+
itemBlocks = [P.Div([id, classes, marked], itemBlocks)];
|
|
1369
|
+
}
|
|
1157
1370
|
}
|
|
1158
1371
|
return itemBlocks;
|
|
1159
1372
|
});
|
|
1160
1373
|
if (n.ordered) {
|
|
1161
1374
|
const style = OL_TYPE[String(n.olType ?? '')] ?? 'Decimal';
|
|
1162
|
-
//
|
|
1163
|
-
|
|
1164
|
-
const delim = n.delim === ')' ? 'OneParen' : n.delim === '.' ? 'Period' : 'DefaultDelim';
|
|
1375
|
+
// AST contract 1.1 omits the default `.` delimiter, so absence means Period.
|
|
1376
|
+
const delim = n.delim === ')' ? 'OneParen' : 'Period';
|
|
1165
1377
|
return P.OrderedList(Number(n.start ?? 1), style, converted, delim);
|
|
1166
1378
|
}
|
|
1167
1379
|
return P.BulletList(converted);
|
|
@@ -1270,6 +1482,11 @@ const ALIGN = {
|
|
|
1270
1482
|
* origin transitively, bump the origin's span, and emit only origin cells.
|
|
1271
1483
|
*/
|
|
1272
1484
|
function table(ctx, n, caption, shortCaption = null) {
|
|
1485
|
+
const valigned = (n.columns ?? []).some((c) => c?.valign)
|
|
1486
|
+
|| (n.rows ?? []).some((r) => r?.cells?.some((c) => c?.valign));
|
|
1487
|
+
if (valigned) {
|
|
1488
|
+
warn(ctx, "table: vertical alignment is dropped - pandoc's Table has no slot for it", { construct: 'table' }, n.pos);
|
|
1489
|
+
}
|
|
1273
1490
|
if (!caption && Array.isArray(n.caption)) {
|
|
1274
1491
|
ctx.captionKind = captionLabel(n.caption);
|
|
1275
1492
|
caption = inlines(ctx, n.caption);
|
|
@@ -1289,7 +1506,10 @@ function table(ctx, n, caption, shortCaption = null) {
|
|
|
1289
1506
|
const groups = read.groups;
|
|
1290
1507
|
// Split leading all-header rows into the table head.
|
|
1291
1508
|
let headCount = 0;
|
|
1292
|
-
|
|
1509
|
+
// A span marker carries no header flag of its own, as in the engine's walk.
|
|
1510
|
+
while (headCount < rows.length
|
|
1511
|
+
&& rows[headCount].some((c) => c.span === undefined)
|
|
1512
|
+
&& rows[headCount].every((c) => c.header || c.span !== undefined)) {
|
|
1293
1513
|
headCount++;
|
|
1294
1514
|
}
|
|
1295
1515
|
if (groups)
|
|
@@ -1354,8 +1574,15 @@ function table(ctx, n, caption, shortCaption = null) {
|
|
|
1354
1574
|
const declared = ALIGN[columns[c]?.align ?? ''];
|
|
1355
1575
|
if (declared)
|
|
1356
1576
|
return declared;
|
|
1357
|
-
|
|
1358
|
-
|
|
1577
|
+
if (!firstRowIsHead)
|
|
1578
|
+
return 'AlignDefault';
|
|
1579
|
+
// The last header row that marks the column sets its default, as in the engine.
|
|
1580
|
+
for (let r = headCount - 1; r >= 0; r--) {
|
|
1581
|
+
const marked = rows[r][c]?.align;
|
|
1582
|
+
if (marked)
|
|
1583
|
+
return ALIGN[marked] ?? 'AlignDefault';
|
|
1584
|
+
}
|
|
1585
|
+
return 'AlignDefault';
|
|
1359
1586
|
});
|
|
1360
1587
|
const origin = rows.map(() => []);
|
|
1361
1588
|
const emitted = rows.map((row) => row.map(() => null));
|
|
@@ -1363,6 +1590,9 @@ function table(ctx, n, caption, shortCaption = null) {
|
|
|
1363
1590
|
const row = rows[r];
|
|
1364
1591
|
for (let c = 0; c < row.length; c++) {
|
|
1365
1592
|
const cc = row[c];
|
|
1593
|
+
// Already claimed by the strip of a `^` to its left.
|
|
1594
|
+
if (origin[r][c])
|
|
1595
|
+
continue;
|
|
1366
1596
|
// Pandoc omits covered positions entirely, so a continuation that
|
|
1367
1597
|
// RESOLVES leaves nowhere to hang its attributes. The grammar
|
|
1368
1598
|
// cannot produce that shape (a cell carrying attributes is never a
|
|
@@ -1377,11 +1607,10 @@ function table(ctx, n, caption, shortCaption = null) {
|
|
|
1377
1607
|
};
|
|
1378
1608
|
if (cc.span === 'colspan') {
|
|
1379
1609
|
const org = origin[r][c - 1];
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
org.cell.colSpan = Math.max(org.cell.colSpan, c - org.col + 1);
|
|
1610
|
+
// Widening a cell that started in an earlier row would cover
|
|
1611
|
+
// cells already placed above (PART 9 section 13 T5).
|
|
1612
|
+
if (org && org.row === r) {
|
|
1613
|
+
org.cell.colSpan = Math.max(org.cell.colSpan, c - org.col + 1);
|
|
1385
1614
|
origin[r][c] = org;
|
|
1386
1615
|
droppedAttrs();
|
|
1387
1616
|
continue;
|
|
@@ -1389,7 +1618,14 @@ function table(ctx, n, caption, shortCaption = null) {
|
|
|
1389
1618
|
warn(ctx, `table: colspan continuation at row ${r + 1}, col ${c + 1} has no origin - emitting empty cell`);
|
|
1390
1619
|
}
|
|
1391
1620
|
else if (cc.span === 'rowspan') {
|
|
1392
|
-
const
|
|
1621
|
+
const above = r > 0 ? origin[r - 1][c] : undefined;
|
|
1622
|
+
// T5: only the origin's own column extends it, and only when the
|
|
1623
|
+
// rest of its strip in this row is markers, so a span never
|
|
1624
|
+
// covers another cell.
|
|
1625
|
+
const org = above && above.col === c
|
|
1626
|
+
&& row.slice(c + 1, c + above.cell.colSpan).every((next, k) => next.span !== undefined && !origin[r][c + 1 + k])
|
|
1627
|
+
? above
|
|
1628
|
+
: undefined;
|
|
1393
1629
|
if (org) {
|
|
1394
1630
|
// POLICY: clip, do not restructure. Carve is the richer
|
|
1395
1631
|
// model here - its rows are one flat list, so a rowspan may
|
|
@@ -1411,7 +1647,8 @@ function table(ctx, n, caption, shortCaption = null) {
|
|
|
1411
1647
|
// max(): a 2D block has one rowspan continuation per
|
|
1412
1648
|
// covered column - count rows, not continuations.
|
|
1413
1649
|
org.cell.rowSpan = Math.max(org.cell.rowSpan, r - org.row + 1);
|
|
1414
|
-
|
|
1650
|
+
for (let k = c; k < c + org.cell.colSpan && k < row.length; k++)
|
|
1651
|
+
origin[r][k] = org;
|
|
1415
1652
|
droppedAttrs();
|
|
1416
1653
|
continue;
|
|
1417
1654
|
}
|
|
@@ -1428,9 +1665,10 @@ function table(ctx, n, caption, shortCaption = null) {
|
|
|
1428
1665
|
// A head cell's marker became the ColSpec above, so repeating it
|
|
1429
1666
|
// here would emit an override pandoc does not need and the source
|
|
1430
1667
|
// never had.
|
|
1431
|
-
const
|
|
1668
|
+
const ownAlign = ALIGN[cc.align ?? ''] ?? 'AlignDefault';
|
|
1669
|
+
const cellAlign = firstRowIsHead && r === 0 && ownAlign === colAligns[c]
|
|
1432
1670
|
? 'AlignDefault'
|
|
1433
|
-
:
|
|
1671
|
+
: ownAlign;
|
|
1434
1672
|
const pc = P.cell(cellBlocks, cellAlign, toAttr(ctx, cc.attrs));
|
|
1435
1673
|
origin[r][c] = { cell: pc, row: r, col: c };
|
|
1436
1674
|
emitted[r][c] = pc;
|
|
@@ -2097,6 +2335,42 @@ function scalarValue(ctx, key, raw) {
|
|
|
2097
2335
|
}
|
|
2098
2336
|
return P.MetaInlines(textInlines(value));
|
|
2099
2337
|
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Trojan-Source controls (U+202A-202E, U+2066-2069), mirrored from the
|
|
2340
|
+
* engine, which strips them from every rendered text and code run but keeps
|
|
2341
|
+
* LRM/RLM. Raw passthrough is left alone, as the engine leaves it.
|
|
2342
|
+
*/
|
|
2343
|
+
const BIDI_CONTROLS = /[\u202a-\u202e\u2066-\u2069]/gu;
|
|
2344
|
+
/** Strip {@link BIDI_CONTROLS} in place from Str, Code, CodeBlock and Math; returns the count. */
|
|
2345
|
+
function stripBidi(value) {
|
|
2346
|
+
let count = 0;
|
|
2347
|
+
const strip = (s) => s.replace(BIDI_CONTROLS, () => {
|
|
2348
|
+
count++;
|
|
2349
|
+
return '';
|
|
2350
|
+
});
|
|
2351
|
+
const stack = [value];
|
|
2352
|
+
while (stack.length) {
|
|
2353
|
+
const v = stack.pop();
|
|
2354
|
+
if (Array.isArray(v)) {
|
|
2355
|
+
for (const x of v)
|
|
2356
|
+
stack.push(x);
|
|
2357
|
+
continue;
|
|
2358
|
+
}
|
|
2359
|
+
if (!v || typeof v !== 'object')
|
|
2360
|
+
continue;
|
|
2361
|
+
const node = v;
|
|
2362
|
+
if (node.t === 'Str' && typeof node.c === 'string')
|
|
2363
|
+
node.c = strip(node.c);
|
|
2364
|
+
else if ((node.t === 'Code' || node.t === 'CodeBlock' || node.t === 'Math') && Array.isArray(node.c)
|
|
2365
|
+
&& typeof node.c[1] === 'string')
|
|
2366
|
+
node.c[1] = strip(node.c[1]);
|
|
2367
|
+
else if (node.t === 'RawInline' || node.t === 'RawBlock')
|
|
2368
|
+
continue;
|
|
2369
|
+
for (const x of Object.values(node))
|
|
2370
|
+
stack.push(x);
|
|
2371
|
+
}
|
|
2372
|
+
return count;
|
|
2373
|
+
}
|
|
2100
2374
|
// --- Entry point ---
|
|
2101
2375
|
export function convert(ast, options = {}) {
|
|
2102
2376
|
// PART 12 section 7: frontmatter and footnote DEFINITIONS are block nodes
|
|
@@ -2125,6 +2399,7 @@ export function convert(ast, options = {}) {
|
|
|
2125
2399
|
footnoteDefs,
|
|
2126
2400
|
crossrefTargets: new Map(),
|
|
2127
2401
|
headingIdByText: new Map(),
|
|
2402
|
+
headingIds: resolvedHeadingIds(ast),
|
|
2128
2403
|
captionTargets: new Set(),
|
|
2129
2404
|
tight: false,
|
|
2130
2405
|
inCrossref: false,
|
|
@@ -2136,6 +2411,10 @@ export function convert(ast, options = {}) {
|
|
|
2136
2411
|
symbols: options.symbols ?? {},
|
|
2137
2412
|
listTable: options.listTable ?? true,
|
|
2138
2413
|
noteCount: 0,
|
|
2414
|
+
noteDepth: 0,
|
|
2415
|
+
openNotes: new Set(),
|
|
2416
|
+
citedNotes: new Set(),
|
|
2417
|
+
liftedNotes: [],
|
|
2139
2418
|
parseBlocks: options.parseBlocks,
|
|
2140
2419
|
blockScalars: new Map(),
|
|
2141
2420
|
};
|
|
@@ -2145,12 +2424,17 @@ export function convert(ast, options = {}) {
|
|
|
2145
2424
|
// sits relative to its target.
|
|
2146
2425
|
collectCrossrefTargets(ctx, body, new Map());
|
|
2147
2426
|
const meta = parseMeta(ctx, frontmatter);
|
|
2427
|
+
const converted = blocks(ctx, body);
|
|
2428
|
+
const stripped = stripBidi(meta) + stripBidi(converted);
|
|
2429
|
+
if (stripped > 0) {
|
|
2430
|
+
warn(ctx, `bidi: ${stripped} bidi override or isolate control(s) stripped from text and code, as the engine's writers strip them`, { count: stripped });
|
|
2431
|
+
}
|
|
2148
2432
|
const diagnostics = ctx.diagnostics;
|
|
2149
2433
|
return {
|
|
2150
2434
|
doc: {
|
|
2151
2435
|
'pandoc-api-version': [...P.PANDOC_API_VERSION],
|
|
2152
2436
|
meta,
|
|
2153
|
-
blocks:
|
|
2437
|
+
blocks: converted,
|
|
2154
2438
|
},
|
|
2155
2439
|
warnings: ctx.warnings,
|
|
2156
2440
|
diagnostics,
|
|
@@ -2201,7 +2485,14 @@ function collectCrossrefTargets(ctx, nodes, captionCounts, inPanel = false) {
|
|
|
2201
2485
|
if (n.type === 'heading') {
|
|
2202
2486
|
const children = n.children ?? [];
|
|
2203
2487
|
const a = (n.attrs ?? {});
|
|
2204
|
-
|
|
2488
|
+
let id = a.id ?? ctx.headingIds.get(n);
|
|
2489
|
+
if (id === undefined) {
|
|
2490
|
+
const base = slugify(plainText(children));
|
|
2491
|
+
id = base;
|
|
2492
|
+
for (let k = 2; [...ctx.headingIds.values()].includes(id); k++)
|
|
2493
|
+
id = `${base}-${k}`;
|
|
2494
|
+
}
|
|
2495
|
+
ctx.headingIds.set(n, id);
|
|
2205
2496
|
if (id && !ctx.crossrefTargets.has(id))
|
|
2206
2497
|
ctx.crossrefTargets.set(id, children);
|
|
2207
2498
|
const text = headingTextKey(plainText(children));
|
|
@@ -2280,6 +2571,9 @@ function figureGroupTargets(ctx, n, captionCounts, inPanel) {
|
|
|
2280
2571
|
const letter = panelLetter(panelIndex++);
|
|
2281
2572
|
if (resolved && a.id && !ctx.crossrefTargets.has(a.id)) {
|
|
2282
2573
|
ctx.crossrefTargets.set(a.id, [{ type: 'text', value: `${resolved}${letter}` }]);
|
|
2574
|
+
// A panel caption holds no `#` either, so the crossref has to
|
|
2575
|
+
// cross as the plain link it renders as (see `captionTargets`).
|
|
2576
|
+
ctx.captionTargets.add(a.id);
|
|
2283
2577
|
}
|
|
2284
2578
|
collectCrossrefTargets(ctx, [child], captionCounts, true);
|
|
2285
2579
|
}
|
package/dist/diagnostics.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
const RULES = [
|
|
3
3
|
{ test: /^section:/, code: 'structure-unspellable', class: 'degraded' },
|
|
4
4
|
{ test: /^table: rowGroups\.(?:headAttrs|footAttrs|bodies\[\d+\]\.attrs) is dropped/, code: 'field-unspellable', class: 'lossy' },
|
|
5
|
-
{ test: /^code block: a blank-only payload/, code: 'field-unspellable', class: 'lossy' },
|
|
5
|
+
{ test: /^(code|raw) block: a blank-only payload/, code: 'field-unspellable', class: 'lossy' },
|
|
6
|
+
{ test: /^code: a line break inside a code span/, code: 'field-unspellable', class: 'degraded' },
|
|
6
7
|
{ test: /^comment:/, code: 'comment-dropped', class: 'lossy' },
|
|
7
8
|
{ test: /^inline: unknown node type/, code: 'unknown-carve-inline', class: 'degraded' },
|
|
8
9
|
{ test: /^block: unknown node type/, code: 'unknown-carve-block', class: 'degraded' },
|
|
@@ -25,8 +26,10 @@ const RULES = [
|
|
|
25
26
|
{ test: /^definition list:/, code: 'definition-entry-skipped', class: 'lossy' },
|
|
26
27
|
{ test: /^ordered list: pandoc's example-list numbering/, code: 'ordered-list-counter-degraded', class: 'degraded' },
|
|
27
28
|
{ test: /^ordered list:/, code: 'ordered-list-marker-normalized', class: 'normalized' },
|
|
29
|
+
{ test: /^list: a tight item's second paragraph/, code: 'list-item-framing-lost', class: 'degraded' },
|
|
28
30
|
{ test: /^list: an empty item/, code: 'empty-list-item-spelled', class: 'normalized' },
|
|
29
31
|
{ test: /^task state:/, code: 'task-state-dropped', class: 'lossy' },
|
|
32
|
+
{ test: /^bidi: /, code: 'bidi-control-stripped', class: 'normalized' },
|
|
30
33
|
{ test: /^math: attributes/, code: 'math-attributes-dropped', class: 'lossy' },
|
|
31
34
|
{ test: /^symbol:/, code: 'symbol-unresolved', class: 'degraded' },
|
|
32
35
|
{ test: /^url: a denied scheme/, code: 'unsafe-url-scheme', class: 'lossy' },
|
|
@@ -35,6 +38,14 @@ const RULES = [
|
|
|
35
38
|
{ test: /^extension: ruby/, code: 'ruby-flattened', class: 'degraded' },
|
|
36
39
|
{ test: /^extension: block extension/, code: 'block-extension-fallback-rendered', class: 'degraded' },
|
|
37
40
|
{ test: /^extension:/, code: 'inline-extension-degraded', class: 'degraded' },
|
|
41
|
+
{ test: /^footnote: a cyclic reference/, code: 'nested-note-cycle-dropped', class: 'lossy' },
|
|
42
|
+
{ test: /^footnote: a note referenced inside a note is lifted/, code: 'nested-note-lifted', class: 'degraded' },
|
|
43
|
+
{ test: /^link: an empty destination/, code: 'link-empty-destination-dropped', class: 'lossy' },
|
|
44
|
+
{ test: /^(?:link|image): an empty title is dropped/, code: 'empty-title-dropped', class: 'lossy' },
|
|
45
|
+
{ test: /^attribute: the attributes on a /, code: 'inline-attributes-dropped', class: 'lossy' },
|
|
46
|
+
{ test: /^footnote: \[\^[^\]]*\] is referenced more than once/, code: 'note-reference-duplicated', class: 'degraded' },
|
|
47
|
+
{ test: /^code block: a class without a language/, code: 'code-block-class-read-as-language', class: 'degraded' },
|
|
48
|
+
{ test: /^line block: a hard break/, code: 'line-block-hard-break-dropped', class: 'lossy' },
|
|
38
49
|
{ test: /^(?:link|image|footnote): missing definition/, code: 'reference-unresolved', class: 'degraded' },
|
|
39
50
|
{ test: /^crossref:/, code: 'crossref-unresolved', class: 'degraded' },
|
|
40
51
|
{ test: /^list-table: structure/, code: 'list-table-structure-degraded', class: 'degraded' },
|
|
@@ -52,12 +63,13 @@ const RULES = [
|
|
|
52
63
|
{ test: /^table: .*padded with empty cells/, code: 'table-groups-normalized', class: 'degraded' },
|
|
53
64
|
{ test: /^table: a cell holds block content/, code: 'table-groups-normalized', class: 'normalized' },
|
|
54
65
|
{ test: /^table: the merged id/, code: 'table-wrapper-id-dropped', class: 'lossy' },
|
|
55
|
-
{ test: /^table: a blank row has no pipe-table source spelling/, code: 'table-blank-row-spelled', class: 'normalized' },
|
|
56
66
|
{ test: /^table: .*converted with the implicit head\/body split/, code: 'table-row-groups-invalid', class: 'degraded' },
|
|
57
67
|
{ test: /^table: .*preserved in the Carve AST as `rowGroups`/, code: 'table-groups-flattened', class: 'degraded' },
|
|
58
68
|
{ test: /^table: a foot row's row header is dropped/, code: 'table-foot-row-header-dropped', class: 'lossy' },
|
|
59
69
|
{ test: /^table: the body rows disagree on how many leading cells/, code: 'table-row-heads-degraded', class: 'degraded' },
|
|
60
70
|
{ test: /^table: the rows of a declared body group disagree/, code: 'table-row-heads-degraded', class: 'degraded' },
|
|
71
|
+
{ test: /^table: a hard break inside a cell/, code: 'table-cell-break-flattened', class: 'lossy' },
|
|
72
|
+
{ test: /^table: vertical alignment/, code: 'table-vertical-alignment-dropped', class: 'lossy' },
|
|
61
73
|
{ test: /^table:/, code: 'table-unclassified-loss', class: 'unsupported' },
|
|
62
74
|
];
|
|
63
75
|
export function diagnostic(direction, message, details, sourceLocation) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nesting limits checked before a Pandoc document is imported: the reverse
|
|
3
|
+
* direction recurses per node, and the engine's Carve writer slows down steeply
|
|
4
|
+
* on deeply nested loose lists, so either could crash or hang the CLI.
|
|
5
|
+
*/
|
|
6
|
+
import type { ConversionDiagnostic } from './diagnostics.js';
|
|
7
|
+
/** Deepest Pandoc node nesting accepted: the engine's own render cap. */
|
|
8
|
+
export declare const MAX_PANDOC_DEPTH: number;
|
|
9
|
+
/** Deepest list nesting accepted. */
|
|
10
|
+
export declare const MAX_LIST_DEPTH = 32;
|
|
11
|
+
export declare class PandocImportLimitError extends Error {
|
|
12
|
+
readonly diagnostic: ConversionDiagnostic;
|
|
13
|
+
constructor(kind: 'nodes' | 'lists', depth: number, limit: number);
|
|
14
|
+
}
|
|
15
|
+
/** Throw {@link PandocImportLimitError} when `doc` nests past a limit. Iterative, so it cannot overflow itself. */
|
|
16
|
+
export declare function checkImportLimits(doc: unknown): void;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nesting limits checked before a Pandoc document is imported: the reverse
|
|
3
|
+
* direction recurses per node, and the engine's Carve writer slows down steeply
|
|
4
|
+
* on deeply nested loose lists, so either could crash or hang the CLI.
|
|
5
|
+
*/
|
|
6
|
+
import { MAX_RENDER_DEPTH } from '@markup-carve/carve';
|
|
7
|
+
/** Deepest Pandoc node nesting accepted: the engine's own render cap. */
|
|
8
|
+
export const MAX_PANDOC_DEPTH = MAX_RENDER_DEPTH;
|
|
9
|
+
/** Deepest list nesting accepted. */
|
|
10
|
+
export const MAX_LIST_DEPTH = 32;
|
|
11
|
+
const LISTS = new Set(['BulletList', 'OrderedList', 'DefinitionList']);
|
|
12
|
+
export class PandocImportLimitError extends Error {
|
|
13
|
+
diagnostic;
|
|
14
|
+
constructor(kind, depth, limit) {
|
|
15
|
+
const what = kind === 'lists' ? 'lists nest' : 'nodes nest';
|
|
16
|
+
const message = `pandoc document refused: ${what} ${depth} levels deep, past the import limit of ${limit}`;
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'PandocImportLimitError';
|
|
19
|
+
this.diagnostic = {
|
|
20
|
+
code: 'pandoc-nesting-too-deep',
|
|
21
|
+
direction: 'pandoc-to-carve',
|
|
22
|
+
class: 'unsupported',
|
|
23
|
+
severity: 'error',
|
|
24
|
+
fidelity: 'dropped',
|
|
25
|
+
confidence: 'exact',
|
|
26
|
+
message,
|
|
27
|
+
details: { kind, depth, limit },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Throw {@link PandocImportLimitError} when `doc` nests past a limit. Iterative, so it cannot overflow itself. */
|
|
32
|
+
export function checkImportLimits(doc) {
|
|
33
|
+
const stack = [[doc, 0, 0]];
|
|
34
|
+
while (stack.length) {
|
|
35
|
+
const [value, depth, lists] = stack.pop();
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
for (const item of value)
|
|
38
|
+
stack.push([item, depth, lists]);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!value || typeof value !== 'object')
|
|
42
|
+
continue;
|
|
43
|
+
const t = value.t;
|
|
44
|
+
const isNode = typeof t === 'string';
|
|
45
|
+
const nextDepth = isNode ? depth + 1 : depth;
|
|
46
|
+
const nextLists = isNode && LISTS.has(t) ? lists + 1 : lists;
|
|
47
|
+
if (nextDepth > MAX_PANDOC_DEPTH)
|
|
48
|
+
throw new PandocImportLimitError('nodes', nextDepth, MAX_PANDOC_DEPTH);
|
|
49
|
+
if (nextLists > MAX_LIST_DEPTH)
|
|
50
|
+
throw new PandocImportLimitError('lists', nextLists, MAX_LIST_DEPTH);
|
|
51
|
+
for (const child of Object.values(value))
|
|
52
|
+
stack.push([child, nextDepth, nextLists]);
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { PandocDoc } from './pandoc.js';
|
|
|
5
5
|
import type { ConversionDiagnostic } from './diagnostics.js';
|
|
6
6
|
import { type MigrationReport } from './diagnostics.js';
|
|
7
7
|
export { PANDOC_API_VERSION, type PandocDoc } from './pandoc.js';
|
|
8
|
+
export { PandocImportLimitError, MAX_PANDOC_DEPTH, MAX_LIST_DEPTH } from './import-limits.js';
|
|
8
9
|
export type { ConvertOptions, ConvertResult } from './convert.js';
|
|
9
10
|
export type { ParseOptions } from './parse-options.js';
|
|
10
11
|
export type { ReverseResult } from './reverse.js';
|
package/dist/index.js
CHANGED
|
@@ -3,8 +3,10 @@ import { normalizeCarveAst, parseCarveAst, toCarveAst } from './ast-json.js';
|
|
|
3
3
|
import { convert } from './convert.js';
|
|
4
4
|
import { parseExtensions } from './parse-options.js';
|
|
5
5
|
import { pandocToCarve as reverse } from './reverse.js';
|
|
6
|
-
import { migrationReport } from './diagnostics.js';
|
|
6
|
+
import { diagnostic, migrationReport } from './diagnostics.js';
|
|
7
|
+
import { checkImportLimits, PandocImportLimitError, MAX_PANDOC_DEPTH } from './import-limits.js';
|
|
7
8
|
export { PANDOC_API_VERSION } from './pandoc.js';
|
|
9
|
+
export { PandocImportLimitError, MAX_PANDOC_DEPTH, MAX_LIST_DEPTH } from './import-limits.js';
|
|
8
10
|
/**
|
|
9
11
|
* The engine's own serializer, when the installed engine has one.
|
|
10
12
|
*
|
|
@@ -228,14 +230,55 @@ export function carveToPandocJson(source, options) {
|
|
|
228
230
|
*/
|
|
229
231
|
export function pandocToCarve(doc) {
|
|
230
232
|
const parsed = typeof doc === 'string' ? JSON.parse(doc) : doc;
|
|
233
|
+
checkImportLimits(parsed);
|
|
231
234
|
const { ast, warnings, diagnostics } = reverse(parsed);
|
|
232
235
|
return {
|
|
233
|
-
carve:
|
|
236
|
+
carve: writeCarve(ast, warnings, diagnostics),
|
|
234
237
|
warnings,
|
|
235
238
|
diagnostics,
|
|
236
239
|
report: migrationReport(diagnostics),
|
|
237
240
|
};
|
|
238
241
|
}
|
|
242
|
+
function writeCarve(ast, warnings, diagnostics) {
|
|
243
|
+
const write = (tree) => carve.renderCarve(engineRenderTree(tree));
|
|
244
|
+
try {
|
|
245
|
+
return write(ast);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
// The Carve tree can nest deeper than the Pandoc one (a list item is a
|
|
249
|
+
// node of its own), so the engine's cap can still be reached here.
|
|
250
|
+
if (error instanceof carve.RenderDepthError) {
|
|
251
|
+
throw new PandocImportLimitError('nodes', MAX_PANDOC_DEPTH + 1, MAX_PANDOC_DEPTH);
|
|
252
|
+
}
|
|
253
|
+
// The writer cannot spell some multiline code spans (a line block is
|
|
254
|
+
// one). Pandoc's own writers treat a newline in `Code` as a space, so
|
|
255
|
+
// fold it and say so rather than failing the whole document.
|
|
256
|
+
if (!(error instanceof Error) || !/cannot spell code/.test(error.message))
|
|
257
|
+
throw error;
|
|
258
|
+
let folded = false;
|
|
259
|
+
const fold = (node) => {
|
|
260
|
+
if (Array.isArray(node))
|
|
261
|
+
return node.map(fold);
|
|
262
|
+
if (!node || typeof node !== 'object')
|
|
263
|
+
return node;
|
|
264
|
+
const out = {};
|
|
265
|
+
for (const [key, value] of Object.entries(node))
|
|
266
|
+
out[key] = fold(value);
|
|
267
|
+
if (out['type'] === 'code' && typeof out['value'] === 'string' && /[\r\n]/.test(out['value'])) {
|
|
268
|
+
out['value'] = out['value'].replace(/\r\n?|\n/g, ' ');
|
|
269
|
+
folded = true;
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
};
|
|
273
|
+
const retried = fold(ast);
|
|
274
|
+
if (!folded)
|
|
275
|
+
throw error;
|
|
276
|
+
const message = 'code: a line break inside a code span has no Carve spelling here - written as a space';
|
|
277
|
+
warnings.push(message);
|
|
278
|
+
diagnostics.push(diagnostic('pandoc-to-carve', message));
|
|
279
|
+
return write(retried);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
239
282
|
/**
|
|
240
283
|
* Convert Pandoc to the canonical Carve exchange AST without forcing it through
|
|
241
284
|
* Carve 0.1 source. This preserves structural fields, notably Pandoc's optional
|
|
@@ -243,6 +286,7 @@ export function pandocToCarve(doc) {
|
|
|
243
286
|
*/
|
|
244
287
|
export function pandocToCarveAst(doc) {
|
|
245
288
|
const parsed = typeof doc === 'string' ? JSON.parse(doc) : doc;
|
|
289
|
+
checkImportLimits(parsed);
|
|
246
290
|
const { ast, warnings, diagnostics } = reverse(parsed, 'ast');
|
|
247
291
|
return { ast: toCarveAst(ast, engineSerializer), warnings, diagnostics, report: migrationReport(diagnostics) };
|
|
248
292
|
}
|
package/dist/reverse.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* are recognized and restored to their native Carve nodes, which makes
|
|
9
9
|
* carve -> pandoc -> carve round-trips clean.
|
|
10
10
|
*/
|
|
11
|
-
import { parse as parseCarve, renderCarve } from '@markup-carve/carve';
|
|
11
|
+
import { parse as parseCarve, renderCarve, resolve } from '@markup-carve/carve';
|
|
12
12
|
import { diagnostic } from './diagnostics.js';
|
|
13
13
|
import { readProvenance } from './provenance.js';
|
|
14
14
|
/**
|
|
@@ -290,12 +290,16 @@ function inline(ctx, n) {
|
|
|
290
290
|
// accept both, because the type string is a single value. That is
|
|
291
291
|
// why this waited for the lockfile to move rather than emitting
|
|
292
292
|
// the new names defensively.
|
|
293
|
-
|
|
293
|
+
// An inline note cannot hold another (`^[a ^[b]]` renders the inner
|
|
294
|
+
// one as literal text), so a note with a note inside is written as
|
|
295
|
+
// a definition, where a nested note does resolve.
|
|
296
|
+
if (noteBlocks.length === 1 && (noteBlocks[0].t === 'Para' || noteBlocks[0].t === 'Plain')
|
|
297
|
+
&& !containsNote(noteBlocks[0].c)) {
|
|
294
298
|
return [{ type: 'inline_footnote', inline: inlines(ctx, noteBlocks[0].c) }];
|
|
295
299
|
}
|
|
296
300
|
const id = `fn${++ctx.noteCounter}`;
|
|
297
301
|
ctx.footnoteDefs[id] = blocks(ctx, noteBlocks);
|
|
298
|
-
return [{ type: 'footnote_ref', id }];
|
|
302
|
+
return [{ type: 'footnote_ref', label: id }];
|
|
299
303
|
}
|
|
300
304
|
case 'Span':
|
|
301
305
|
{
|
|
@@ -319,7 +323,7 @@ function inline(ctx, n) {
|
|
|
319
323
|
function link(ctx, c) {
|
|
320
324
|
const [a, xs, [href, title]] = c;
|
|
321
325
|
const [, classes] = a;
|
|
322
|
-
if (classes.includes('uri') || classes.includes('email')) {
|
|
326
|
+
if ((classes.includes('uri') || classes.includes('email')) && autolinkParses(href, stringify(xs))) {
|
|
323
327
|
const node = { type: 'autolink', href, text: stringify(xs) };
|
|
324
328
|
// `uri`/`email` is the class the forward direction SYNTHESIZES to say
|
|
325
329
|
// what kind of link this is, so it is not the author's and does not
|
|
@@ -334,14 +338,39 @@ function link(ctx, c) {
|
|
|
334
338
|
if (classes.includes('crossref')) {
|
|
335
339
|
return [{ type: 'heading_ref', target: href.replace(/^#/, '') }];
|
|
336
340
|
}
|
|
341
|
+
if (href === '') {
|
|
342
|
+
// `[text]()` is literal text in Carve, and `[text](<>)` links to `<>`.
|
|
343
|
+
warn(ctx, 'link: an empty destination has no Carve spelling - the link text is kept without the link');
|
|
344
|
+
return inlines(ctx, xs);
|
|
345
|
+
}
|
|
337
346
|
const node = { type: 'link', href, children: inlines(ctx, xs) };
|
|
338
347
|
if (title)
|
|
339
348
|
node.title = title;
|
|
340
|
-
|
|
349
|
+
// An autolink the engine cannot spell arrives here; its class is synthesized.
|
|
350
|
+
const label = stringify(xs);
|
|
351
|
+
const autolinkShaped = href === label || href === `mailto:${label}`;
|
|
352
|
+
const attrs = fromAttr([a[0], autolinkShaped ? classes.filter((c) => c !== 'uri' && c !== 'email') : classes, a[2]]);
|
|
341
353
|
if (attrs)
|
|
342
354
|
node.attrs = attrs;
|
|
343
355
|
return [node];
|
|
344
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* Whether `<text>` reads back as an autolink to `href`. The engine's autolink
|
|
359
|
+
* grammar is narrower than pandoc's (an internationalized address is one it
|
|
360
|
+
* rejects), and such a link is written as an ordinary link instead.
|
|
361
|
+
*/
|
|
362
|
+
function autolinkParses(href, text) {
|
|
363
|
+
try {
|
|
364
|
+
const doc = parseCarve(`<${text}>`);
|
|
365
|
+
const only = doc.children?.[0]?.children;
|
|
366
|
+
// An empty href is a denied scheme the forward direction blanked; the
|
|
367
|
+
// engine blanks it again when it renders the autolink.
|
|
368
|
+
return only?.length === 1 && only[0]?.type === 'autolink' && (href === '' || only[0]?.href === href);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
345
374
|
/** Restore convert.ts's classed-span degradations to native Carve nodes. */
|
|
346
375
|
function span(ctx, c) {
|
|
347
376
|
const [a, xs] = c;
|
|
@@ -567,7 +596,6 @@ function blocks(ctx, xs) {
|
|
|
567
596
|
out.push(...block(ctx, x));
|
|
568
597
|
return out;
|
|
569
598
|
}
|
|
570
|
-
const KNOWN_ADMONITIONS = new Set(['note', 'tip', 'warning', 'danger', 'info', 'caution', 'important']);
|
|
571
599
|
function block(ctx, n) {
|
|
572
600
|
const c = n.c;
|
|
573
601
|
switch (n.t) {
|
|
@@ -642,7 +670,11 @@ function block(ctx, n) {
|
|
|
642
670
|
}
|
|
643
671
|
case 'RawBlock': {
|
|
644
672
|
const [format, content] = c;
|
|
645
|
-
|
|
673
|
+
// One final break, as a parsed fence carries (`CARVE-P12-064`).
|
|
674
|
+
// Unlike `CodeBlock`, pandoc's readers disagree here: markdown and
|
|
675
|
+
// rst publish `"a"`, gfm, commonmark_x, djot and org `"a\n"`.
|
|
676
|
+
const ends = content === '' || content.endsWith('\n');
|
|
677
|
+
return [{ type: 'raw_block', format, content: ends ? content : content + '\n' }];
|
|
646
678
|
}
|
|
647
679
|
case 'HorizontalRule':
|
|
648
680
|
return [{ type: 'thematic_break' }];
|
|
@@ -678,14 +710,28 @@ function listItems(ctx, items) {
|
|
|
678
710
|
let source = item;
|
|
679
711
|
let attrs;
|
|
680
712
|
let taskState = null;
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
713
|
+
const isEnvelope = (attr) => attr[2].some(([key, value]) => key === 'carve-list-item' && value === 'true');
|
|
714
|
+
const readEnvelope = (attr) => {
|
|
715
|
+
taskState = readTaskState(attr[2]);
|
|
716
|
+
attrs = fromAttr([attr[0], attr[1], attr[2].filter(([key]) => key !== 'carve-list-item' && key !== TASK_STATE_KEY)]);
|
|
717
|
+
};
|
|
718
|
+
const lead = item[0];
|
|
719
|
+
const leadInlines = lead && (lead.t === 'Plain' || lead.t === 'Para') ? lead.c : undefined;
|
|
720
|
+
const span = leadInlines?.length === 3 && leadInlines[0]?.t === 'Str' && /^[☐☒]$/.test(String(leadInlines[0].c))
|
|
721
|
+
&& leadInlines[1]?.t === 'Space' && leadInlines[2]?.t === 'Span' ? leadInlines[2] : undefined;
|
|
722
|
+
if (item.length === 1 && lead?.t === 'Div') {
|
|
723
|
+
const [attr, inner] = lead.c;
|
|
724
|
+
if (isEnvelope(attr)) {
|
|
725
|
+
readEnvelope(attr);
|
|
686
726
|
source = inner;
|
|
687
727
|
}
|
|
688
728
|
}
|
|
729
|
+
else if (span && isEnvelope(span.c[0])) {
|
|
730
|
+
// A task item's envelope rides on a Span after its ballot.
|
|
731
|
+
const [attr, inner] = span.c;
|
|
732
|
+
readEnvelope(attr);
|
|
733
|
+
source = [{ t: lead.t, c: [leadInlines[0], leadInlines[1], ...inner] }, ...item.slice(1)];
|
|
734
|
+
}
|
|
689
735
|
if (source.some((b) => b.t === 'Para'))
|
|
690
736
|
tight = false;
|
|
691
737
|
const children = blocks(ctx, source);
|
|
@@ -781,12 +827,9 @@ function orderedList(ctx, c) {
|
|
|
781
827
|
const olType = OL_STYLE[style.t];
|
|
782
828
|
if (olType)
|
|
783
829
|
node.olType = olType;
|
|
784
|
-
//
|
|
785
|
-
// normalizes to `1.` regardless (fmt canonical form).
|
|
830
|
+
// AST contract 1.1 records only the non-default `)`; `.` is absence.
|
|
786
831
|
if (delim?.t === 'OneParen' || delim?.t === 'TwoParens')
|
|
787
832
|
node.delim = ')';
|
|
788
|
-
else if (delim?.t === 'Period')
|
|
789
|
-
node.delim = '.';
|
|
790
833
|
// Pandoc's `Example` list is `(@)` numbering: ONE counter shared by every
|
|
791
834
|
// example list in the document, and items can be labelled and referenced
|
|
792
835
|
// from prose. PART 12 section 6 admits `a`/`A`/`i`/`I` and decimal, so the
|
|
@@ -984,7 +1027,8 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
|
|
|
984
1027
|
// Row-head columns no longer send a table to the list-table form: the pipe
|
|
985
1028
|
// table marks those cells directly (see `rowHeadCols` above), which is
|
|
986
1029
|
// readable, needs no extension, and round-trips. Block cells need the
|
|
987
|
-
// list-table form
|
|
1030
|
+
// list-table form. A wholly blank row is a pipe-table row since the spec
|
|
1031
|
+
// ruled an explicitly delimited row of blank cells a table (corpus 589).
|
|
988
1032
|
//
|
|
989
1033
|
// A FOOT USED TO BE A SECOND REASON, AND IT WAS A REAL ONE AT THE TIME.
|
|
990
1034
|
// The clause read `|| (ctx.target === 'source' && footRaw.length > 0)`.
|
|
@@ -1004,25 +1048,7 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
|
|
|
1004
1048
|
// (corpus 376, spec f074372), so at the time the pipe form genuinely could
|
|
1005
1049
|
// not carry a foot and the list-table detour was the only way to keep it.
|
|
1006
1050
|
// The limitation is gone, so the detour is too.
|
|
1007
|
-
const
|
|
1008
|
-
const hasBlankRows = allRaw.some((row, r) => {
|
|
1009
|
-
let col = 0;
|
|
1010
|
-
let blank = nCols > 0;
|
|
1011
|
-
for (const raw of row[1]) {
|
|
1012
|
-
while (occupied[r][col])
|
|
1013
|
-
col++;
|
|
1014
|
-
const [attr, align, height, width, content] = raw;
|
|
1015
|
-
if (width > 1 || height > 1 || content.length || fromAttr(attr) || align.t !== 'AlignDefault')
|
|
1016
|
-
blank = false;
|
|
1017
|
-
for (let down = 1; down < height && r + down < allRaw.length; down++) {
|
|
1018
|
-
for (let across = 0; across < width; across++)
|
|
1019
|
-
occupied[r + down][col + across] = true;
|
|
1020
|
-
}
|
|
1021
|
-
col += width;
|
|
1022
|
-
}
|
|
1023
|
-
return blank && !occupied[r].some(Boolean);
|
|
1024
|
-
});
|
|
1025
|
-
const useListTable = ctx.target === 'source' && (hasBlockCells || hasBlankRows);
|
|
1051
|
+
const useListTable = ctx.target === 'source' && hasBlockCells;
|
|
1026
1052
|
const pending = allRaw.map(() => Array(nCols));
|
|
1027
1053
|
const rows = [];
|
|
1028
1054
|
// The pandoc blocks that sat at each grid position, kept alongside the
|
|
@@ -1110,7 +1136,6 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
|
|
|
1110
1136
|
attrs: fromAttr(a),
|
|
1111
1137
|
colAligns,
|
|
1112
1138
|
colWidths: columns.map((column) => column.width),
|
|
1113
|
-
blankRows: !hasBlockCells && hasBlankRows,
|
|
1114
1139
|
});
|
|
1115
1140
|
}
|
|
1116
1141
|
const node = { type: 'table', rows };
|
|
@@ -1176,7 +1201,10 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
|
|
|
1176
1201
|
&& groupBodies.every((b) => b.bodyRows > 0)
|
|
1177
1202
|
&& adjacentRunsDiffer
|
|
1178
1203
|
&& !leadingRunFillsTheRow
|
|
1179
|
-
|
|
1204
|
+
// A head is the leading run of fully marked rows, so it is safe beside
|
|
1205
|
+
// a collapse as long as the first body does not open with one
|
|
1206
|
+
// (`leadingRunFillsTheRow`). Corpus 589-31.
|
|
1207
|
+
&& footRaw.length === 0
|
|
1180
1208
|
&& !headAttrs && !footAttrs;
|
|
1181
1209
|
const collapsed = [{
|
|
1182
1210
|
headRows: 0,
|
|
@@ -1373,9 +1401,7 @@ function isInlineShaped(cellBlocks) {
|
|
|
1373
1401
|
*/
|
|
1374
1402
|
function listTable(ctx, input) {
|
|
1375
1403
|
const { rows, cellBlocksAt, headRows, footRows, bodies, caption, attrs, colAligns, colWidths } = input;
|
|
1376
|
-
warn(ctx,
|
|
1377
|
-
? 'table: a blank row has no pipe-table source spelling - emitted as a `::: list-table` (structure preserved)'
|
|
1378
|
-
: 'table: a cell holds block content, which a pipe table cannot spell - emitted as a `::: list-table` (structure preserved)');
|
|
1404
|
+
warn(ctx, 'table: a cell holds block content, which a pipe table cannot spell - emitted as a `::: list-table` (structure preserved)');
|
|
1379
1405
|
if (bodies.some((b) => b.attrs)) {
|
|
1380
1406
|
warn(ctx, 'list-table: a body group\'s attributes are dropped - the extension has no body to hang them on');
|
|
1381
1407
|
}
|
|
@@ -1493,12 +1519,27 @@ const bulletListOf = (items) => ({
|
|
|
1493
1519
|
ordered: false,
|
|
1494
1520
|
tight: items.every((item) => (item.children ?? []).length <= 1),
|
|
1495
1521
|
items,
|
|
1496
|
-
bulletChar: '-',
|
|
1497
1522
|
});
|
|
1498
1523
|
function cellInlines(ctx, cellBlocks) {
|
|
1499
1524
|
// Block cells take the list-table path before this function is called.
|
|
1500
1525
|
const first = cellBlocks[0];
|
|
1501
|
-
return first ? mergeText(inlines(ctx, first.c)) : [];
|
|
1526
|
+
return first ? mergeText(flattenCellBreaks(ctx, inlines(ctx, first.c))) : [];
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* A pipe-table row is one line, so a break inside a cell would end the row and
|
|
1530
|
+
* turn the whole table into a paragraph. A soft break is a space anyway; a hard
|
|
1531
|
+
* break has no spelling in a cell and is reported.
|
|
1532
|
+
*/
|
|
1533
|
+
function flattenCellBreaks(ctx, nodes) {
|
|
1534
|
+
return nodes.map((n) => {
|
|
1535
|
+
if (n.type === 'soft_break')
|
|
1536
|
+
return text(' ');
|
|
1537
|
+
if (n.type === 'hard_break') {
|
|
1538
|
+
warn(ctx, 'table: a hard break inside a cell has no pipe-table spelling - written as a space');
|
|
1539
|
+
return text(' ');
|
|
1540
|
+
}
|
|
1541
|
+
return Array.isArray(n.children) ? { ...n, children: flattenCellBreaks(ctx, n.children) } : n;
|
|
1542
|
+
});
|
|
1502
1543
|
}
|
|
1503
1544
|
function captionFromBlocks(ctx, capBlocks) {
|
|
1504
1545
|
if (!capBlocks?.length)
|
|
@@ -1736,13 +1777,12 @@ function div(ctx, c) {
|
|
|
1736
1777
|
let kind;
|
|
1737
1778
|
let rest = [];
|
|
1738
1779
|
const wrapper = classes.includes('directive') ? 'directive' : classes.includes('admonition') ? 'admonition' : undefined;
|
|
1780
|
+
// Only the bridge's own `admonition`/`directive` marker makes one: a
|
|
1781
|
+
// pandoc Div classed `note` is an ordinary div that happens to use the word.
|
|
1739
1782
|
if (wrapper) {
|
|
1740
1783
|
rest = classes.filter((x) => x !== wrapper);
|
|
1741
1784
|
kind = rest.shift();
|
|
1742
1785
|
}
|
|
1743
|
-
else if (classes.length === 1 && KNOWN_ADMONITIONS.has(classes[0])) {
|
|
1744
|
-
kind = classes[0];
|
|
1745
|
-
}
|
|
1746
1786
|
if (kind) {
|
|
1747
1787
|
const node = { type: wrapper === 'directive' ? 'directive' : 'admonition', kind, children: [] };
|
|
1748
1788
|
let children = body;
|
|
@@ -1932,6 +1972,10 @@ export function pandocToCarve(doc, target = 'source') {
|
|
|
1932
1972
|
const ast = { type: 'document', children };
|
|
1933
1973
|
if (Object.keys(ctx.footnoteDefs).length)
|
|
1934
1974
|
ast.footnoteDefs = ctx.footnoteDefs;
|
|
1975
|
+
dropDerivableHeadingIds(ast);
|
|
1976
|
+
if (target === 'source' && hasRejoinedFraming(children, false)) {
|
|
1977
|
+
warn(ctx, "list: a tight item's second paragraph inside an indented container is rejoined to its first by the Carve writer");
|
|
1978
|
+
}
|
|
1935
1979
|
const yaml = metaToYaml(ctx, doc.meta ?? {});
|
|
1936
1980
|
if (yaml)
|
|
1937
1981
|
ast.frontmatter = { format: 'yaml', content: yaml };
|
|
@@ -1947,3 +1991,124 @@ function containsShortCaption(value) {
|
|
|
1947
1991
|
return true;
|
|
1948
1992
|
return Object.values(record).some(containsShortCaption);
|
|
1949
1993
|
}
|
|
1994
|
+
const HEADING_TAG = 'data-pandoc-carve-heading';
|
|
1995
|
+
/**
|
|
1996
|
+
* Drop a heading id the engine would derive at that position anyway.
|
|
1997
|
+
*
|
|
1998
|
+
* The forward direction writes every heading's resolved id onto `Header`, so
|
|
1999
|
+
* without this a round trip would spell each one out as `{#id}`. Removal is
|
|
2000
|
+
* applied only when re-resolving proves every heading keeps its id; a pandoc
|
|
2001
|
+
* id that differs from the derived one stays explicit.
|
|
2002
|
+
*/
|
|
2003
|
+
function dropDerivableHeadingIds(ast) {
|
|
2004
|
+
const headings = [];
|
|
2005
|
+
const walk = (value) => {
|
|
2006
|
+
if (Array.isArray(value))
|
|
2007
|
+
value.forEach(walk);
|
|
2008
|
+
else if (value && typeof value === 'object') {
|
|
2009
|
+
if (value.type === 'heading')
|
|
2010
|
+
headings.push(value);
|
|
2011
|
+
Object.values(value).forEach(walk);
|
|
2012
|
+
}
|
|
2013
|
+
};
|
|
2014
|
+
walk(ast);
|
|
2015
|
+
const explicit = headings.filter((h) => typeof h.attrs?.id === 'string');
|
|
2016
|
+
if (explicit.length === 0)
|
|
2017
|
+
return;
|
|
2018
|
+
const resolvedIds = (strip) => {
|
|
2019
|
+
const index = new Map(headings.map((h, i) => [h, i]));
|
|
2020
|
+
const copy = (value) => {
|
|
2021
|
+
if (Array.isArray(value))
|
|
2022
|
+
return value.map(copy);
|
|
2023
|
+
if (!value || typeof value !== 'object')
|
|
2024
|
+
return value;
|
|
2025
|
+
const out = {};
|
|
2026
|
+
for (const [k, v] of Object.entries(value))
|
|
2027
|
+
out[k] = copy(v);
|
|
2028
|
+
const at = index.get(value);
|
|
2029
|
+
if (at !== undefined) {
|
|
2030
|
+
const { id: _id, ...rest } = (out.attrs ?? {});
|
|
2031
|
+
const attrs = strip.has(value) ? rest : { ...rest, ...(_id ? { id: _id } : {}) };
|
|
2032
|
+
out.attrs = { ...attrs, keyValues: { ...(attrs.keyValues ?? {}), [HEADING_TAG]: String(at) } };
|
|
2033
|
+
}
|
|
2034
|
+
return out;
|
|
2035
|
+
};
|
|
2036
|
+
try {
|
|
2037
|
+
const ids = new Array(headings.length);
|
|
2038
|
+
const stack = [resolve(copy(ast))];
|
|
2039
|
+
while (stack.length) {
|
|
2040
|
+
const value = stack.pop();
|
|
2041
|
+
if (Array.isArray(value))
|
|
2042
|
+
stack.push(...value);
|
|
2043
|
+
else if (value && typeof value === 'object') {
|
|
2044
|
+
const node = value;
|
|
2045
|
+
const attrs = node.attrs;
|
|
2046
|
+
const tag = node.type === 'heading' ? attrs?.keyValues?.[HEADING_TAG] : undefined;
|
|
2047
|
+
if (tag !== undefined)
|
|
2048
|
+
ids[Number(tag)] = attrs?.id;
|
|
2049
|
+
stack.push(...Object.values(node));
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
return ids;
|
|
2053
|
+
}
|
|
2054
|
+
catch {
|
|
2055
|
+
return null;
|
|
2056
|
+
}
|
|
2057
|
+
};
|
|
2058
|
+
const baseline = resolvedIds(new Set());
|
|
2059
|
+
const derived = resolvedIds(new Set(explicit));
|
|
2060
|
+
if (!baseline || !derived)
|
|
2061
|
+
return;
|
|
2062
|
+
const strip = new Set(explicit.filter((h) => derived[headings.indexOf(h)] === h.attrs.id));
|
|
2063
|
+
if (strip.size === 0)
|
|
2064
|
+
return;
|
|
2065
|
+
const after = resolvedIds(strip);
|
|
2066
|
+
if (!after || after.some((id, i) => id !== baseline[i]))
|
|
2067
|
+
return;
|
|
2068
|
+
for (const h of strip) {
|
|
2069
|
+
const { id: _id, ...rest } = h.attrs;
|
|
2070
|
+
const order = (rest.order ?? []).filter((o) => o !== '#id');
|
|
2071
|
+
if (order.length === 0)
|
|
2072
|
+
delete h.attrs;
|
|
2073
|
+
else
|
|
2074
|
+
h.attrs = { ...rest, order };
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
function containsNote(value) {
|
|
2078
|
+
if (Array.isArray(value))
|
|
2079
|
+
return value.some(containsNote);
|
|
2080
|
+
if (!value || typeof value !== 'object')
|
|
2081
|
+
return false;
|
|
2082
|
+
if (value.t === 'Note')
|
|
2083
|
+
return true;
|
|
2084
|
+
return containsNote(value.c);
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Whether a tight list nested in a list item or a definition holds an item
|
|
2088
|
+
* with two adjacent paragraphs. The writer spells the second one as a `+`
|
|
2089
|
+
* continuation it places at the wrong column there, so it reads back joined
|
|
2090
|
+
* to the first.
|
|
2091
|
+
*/
|
|
2092
|
+
function hasRejoinedFraming(nodes, indented) {
|
|
2093
|
+
if (!Array.isArray(nodes))
|
|
2094
|
+
return false;
|
|
2095
|
+
return nodes.some((value) => {
|
|
2096
|
+
if (!value || typeof value !== 'object')
|
|
2097
|
+
return false;
|
|
2098
|
+
const node = value;
|
|
2099
|
+
if (node.type === 'list') {
|
|
2100
|
+
const items = node.items ?? [];
|
|
2101
|
+
if (indented && node.tight && items.some((item) => {
|
|
2102
|
+
const kids = item.children ?? [];
|
|
2103
|
+
return kids.some((kid, i) => kid.type === 'paragraph' && kids[i + 1]?.type === 'paragraph');
|
|
2104
|
+
}))
|
|
2105
|
+
return true;
|
|
2106
|
+
return items.some((item) => hasRejoinedFraming(item.children, true));
|
|
2107
|
+
}
|
|
2108
|
+
if (node.type === 'definition_list') {
|
|
2109
|
+
const items = node.items ?? [];
|
|
2110
|
+
return items.some((item) => (item.definitions ?? []).some((d) => hasRejoinedFraming(d, true)));
|
|
2111
|
+
}
|
|
2112
|
+
return hasRejoinedFraming(node.children, indented);
|
|
2113
|
+
});
|
|
2114
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markup-carve/pandoc-carve",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Carve to Pandoc bridge: convert Carve markup to Pandoc's JSON AST and reach every pandoc output format (LaTeX, Typst, DOCX, PDF, ...)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"carve",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"source": "https://github.com/markup-carve/pandoc-carve"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@markup-carve/carve": "^0.1.
|
|
58
|
+
"@markup-carve/carve": "^0.1.11"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^24.0.0",
|