@markup-carve/pandoc-carve 0.1.4 → 0.1.5

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/README.md CHANGED
@@ -46,6 +46,12 @@ pandoc-carve doc.crv -t latex -s -o doc.tex
46
46
  # Just the Pandoc JSON AST (no pandoc needed)
47
47
  pandoc-carve doc.crv -t json
48
48
 
49
+ # Expand contained includes; named files default to their own directory
50
+ pandoc-carve book/main.crv -t json --include-root "$PWD/book"
51
+
52
+ # Keep directives literal for a named file
53
+ pandoc-carve book/main.crv -t json --no-includes
54
+
49
55
  # Read from stdin, pass extra args through to pandoc after --
50
56
  cat doc.crv | pandoc-carve - -t latex -- --toc
51
57
 
@@ -62,25 +68,51 @@ carve doc.crv --to-json | pandoc-carve - -f carve-json -t latex
62
68
  ```
63
69
 
64
70
  Anything Carve cannot map faithfully is reported on stderr as a
65
- `pandoc-carve: degraded ...` warning - nothing degrades silently. For migration
66
- automation, `--diagnostics report.json` writes the same findings as structured
67
- JSON without mixing them into document output. `--fail-on-loss` exits with code
68
- 3 for `lossy` or `unsupported` findings, while harmless `normalized` findings
69
- do not fail CI. Use `--diagnostics -` for JSON on stderr.
71
+ `pandoc-carve: degraded ...` warning - nothing degrades silently. Unresolved or
72
+ refused includes are reported as dropped, while safe include renames and heading
73
+ clamps are normalized. For migration
74
+ automation, `--diagnostics report.json` writes the same findings in a versioned
75
+ `{ schemaVersion, sourceFormat, diagnostics }` JSON envelope without mixing
76
+ them into document output. `--fail-on-loss` exits with code 3 for degraded or
77
+ dropped findings, while preserved and normalized findings do not fail CI.
78
+ Imports through a Pandoc reader other than `-f json` also exit
79
+ 3 because fidelity before the Pandoc JSON boundary cannot be verified; stderr
80
+ names that worst-case gate explicitly. Use `--diagnostics -` for JSON on stderr.
70
81
 
71
82
  ## API
72
83
 
73
84
  ```js
74
85
  import { carveToPandoc, carveToPandocJson } from '@markup-carve/pandoc-carve';
75
86
 
76
- const { doc, warnings, diagnostics } = carveToPandoc('Hello /world/!');
87
+ const { doc, warnings, diagnostics, report } = carveToPandoc('Hello /world/!');
77
88
  // doc = { 'pandoc-api-version': [1, 23, 1], meta: {...}, blocks: [...] }
78
89
  // warnings = ['degraded: ...'] for lossy constructs
79
- // diagnostics = [{ code, direction, severity, message, details?, path?, sourceLocation? }]
90
+ // diagnostics = [{ code, direction, class, severity, fidelity, confidence, message, ... }]
91
+ // report = { schemaVersion: 2, sourceFormat: 'carve', diagnostics }
80
92
 
81
93
  const json = carveToPandocJson('Hello /world/!'); // stringified doc
82
94
  ```
83
95
 
96
+ String conversion leaves `{{ path }}` directives literal. File-backed hosts can
97
+ opt into contained expansion and receive warnings and dependency identities:
98
+
99
+ ```js
100
+ import { readFileSync } from 'node:fs';
101
+ import { carveToPandocWithIncludes } from '@markup-carve/pandoc-carve/node';
102
+
103
+ const sourcePath = '/srv/book/main.crv';
104
+ const result = carveToPandocWithIncludes(readFileSync(sourcePath, 'utf8'), {
105
+ includeRoot: '/srv/book',
106
+ sourcePath,
107
+ });
108
+ // includeWarnings and suppressedIncludeWarnings describe expansion diagnostics;
109
+ // dependencies contains resolved and unresolved include identities.
110
+ ```
111
+
112
+ Both API paths must be absolute, and `sourcePath` must stay inside
113
+ `includeRoot`. The CLI accepts ordinary relative input paths but requires an
114
+ absolute `--include-root`. Stdin stays literal unless that option is supplied.
115
+
84
116
  What the converter reads is the **serialized AST of the Carve spec's PART 12**
85
117
  (the shape `resources/ast-schema.json` pins), not any implementation's runtime
86
118
  tree - so a document another engine already parsed converts the same way,
@@ -103,7 +135,7 @@ serializer), so output formatting carries fmt's guarantees:
103
135
  ```js
104
136
  import { pandocToCarve, pandocToCarveAst } from '@markup-carve/pandoc-carve';
105
137
 
106
- const { carve, warnings, diagnostics } = pandocToCarve(pandocJsonString);
138
+ const { carve, warnings, diagnostics, report } = pandocToCarve(pandocJsonString);
107
139
 
108
140
  // Preserve structured fields that Carve 0.1 source cannot spell, including
109
141
  // Pandoc's optional short figure/table caption.
@@ -173,7 +205,8 @@ every target that emits a resolvable URL. Pandoc's targets are not HTML, but
173
205
  `pandoc -f json -t html` is one command away - passing the scheme through here
174
206
  would not be a narrower policy, it would be the same sink one step removed.
175
207
 
176
- The diagnostic is `lossy`, so `--fail-on-loss` stops on it and a caller reading
208
+ The diagnostic class is `lossy`, so `--fail-on-loss` stops on its dropped
209
+ fidelity and a caller reading
177
210
  `diagnostics` can act on it. The scheme list and the scheme probe are mirrored
178
211
  from the engine rather than invented, and a test drives the engine's own writer
179
212
  over both to keep the mirror honest - the probe strips control characters and
@@ -356,16 +389,4 @@ a processor with neither extension enabled would render.
356
389
 
357
390
  ## Development
358
391
 
359
- The test suite converts the whole shared spec corpus, which arrives as a
360
- submodule:
361
-
362
- ```sh
363
- git clone --recurse-submodules https://github.com/markup-carve/pandoc-carve
364
- # or, in an existing clone:
365
- git submodule update --init
366
- npm ci && npm test
367
- ```
368
-
369
- Without the submodule the corpus tests FAIL rather than skip - a skipped corpus
370
- reads like a converted one, which is how a stack overflow reachable from a
371
- two-word document stayed live on the published package.
392
+ Contributor setup, testing, and maintenance notes are in the [development guide](docs/development.md).
package/dist/ast-json.js CHANGED
@@ -47,6 +47,8 @@ const CHILD_FIELDS = [
47
47
  'shortCaption',
48
48
  'title',
49
49
  'target',
50
+ 'old',
51
+ 'new',
50
52
  ];
51
53
  function isObject(value) {
52
54
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -107,6 +109,19 @@ function normalize(node) {
107
109
  // reads the wire has no reason to know the other spelling existed.
108
110
  set('type', 'critic_comment');
109
111
  break;
112
+ case 'substitution': {
113
+ if (!Array.isArray(current['old']) && typeof current['oldText'] === 'string') {
114
+ set('old', [{ type: 'text', value: current['oldText'] }]);
115
+ }
116
+ if (!Array.isArray(current['new']) && typeof current['newText'] === 'string') {
117
+ set('new', [{ type: 'text', value: current['newText'] }]);
118
+ }
119
+ if (current['oldText'] !== undefined || current['newText'] !== undefined) {
120
+ const { oldText: _oldText, newText: _newText, ...rest } = out ?? { ...node };
121
+ out = rest;
122
+ }
123
+ break;
124
+ }
110
125
  case 'footnote': {
111
126
  // The pre-split inline node (carve#405), whose name COLLIDES with
112
127
  // the wire `footnote` - which is the DEFINITION block, carrying
package/dist/cli.js CHANGED
@@ -8,9 +8,11 @@
8
8
  *
9
9
  * Everything after `--` is passed through to pandoc verbatim.
10
10
  */
11
- import { readFileSync, writeFileSync } from 'node:fs';
11
+ import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
12
+ import path from 'node:path';
12
13
  import { carveAstToPandoc, carveToPandoc } from './index.js';
13
- import { hasLoss } from './diagnostics.js';
14
+ import { carveToPandocWithIncludes } from './includes.js';
15
+ import { hasLoss, migrationReport } from './diagnostics.js';
14
16
  function usage(exitCode) {
15
17
  const text = `Usage: pandoc-carve <input | -> [options] [-- pandoc-args...]
16
18
 
@@ -36,6 +38,8 @@ Common options:
36
38
  human warnings are suppressed)
37
39
  --fail-on-loss exit 3 when lossy or unsupported diagnostics are present
38
40
  --symbols FILE JSON map resolving :name: symbols to text (export)
41
+ --include-root DIR containment root for includes (defaults to input folder)
42
+ --no-includes leave include directives literal
39
43
  --pandoc PATH pandoc executable (default: $PANDOC or "pandoc")
40
44
  -h, --help show this help
41
45
  `;
@@ -50,6 +54,7 @@ function parseArgs(argv) {
50
54
  roundtrip: false,
51
55
  listTable: true,
52
56
  citations: true,
57
+ includes: true,
53
58
  failOnLoss: false,
54
59
  pandocPath: process.env.PANDOC ?? 'pandoc',
55
60
  passthrough: [],
@@ -88,6 +93,12 @@ function parseArgs(argv) {
88
93
  else if (a === '--symbols') {
89
94
  args.symbolsFile = argv[++i] ?? usage(1);
90
95
  }
96
+ else if (a === '--include-root') {
97
+ args.includeRoot = argv[++i] ?? usage(1);
98
+ }
99
+ else if (a === '--no-includes') {
100
+ args.includes = false;
101
+ }
91
102
  else if (a === '--diagnostics') {
92
103
  args.diagnosticsFile = argv[++i] ?? usage(1);
93
104
  }
@@ -112,6 +123,9 @@ function parseArgs(argv) {
112
123
  }
113
124
  async function main() {
114
125
  const args = parseArgs(process.argv.slice(2));
126
+ if (args.from && args.from !== 'carve' && (args.includeRoot || !args.includes)) {
127
+ throw new Error('--include-root and --no-includes only apply to Carve source input');
128
+ }
115
129
  // `carve` and `carve-json` are the two EXPORT inputs - Carve source, and the
116
130
  // serialized AST any engine writes with --to-json. Every other `-f` names a
117
131
  // pandoc reader, which is the import direction.
@@ -128,17 +142,38 @@ async function main() {
128
142
  citations: args.citations,
129
143
  symbols,
130
144
  };
131
- const { doc, warnings, diagnostics } = args.from === 'carve-json'
132
- ? carveAstToPandoc(source, options)
133
- : carveToPandoc(source, options);
134
- report(args, warnings, diagnostics);
145
+ if (args.includeRoot && !path.isAbsolute(args.includeRoot)) {
146
+ throw new Error('--include-root must be an absolute path');
147
+ }
148
+ let includeResult;
149
+ let converted;
150
+ if (args.from === 'carve-json') {
151
+ converted = carveAstToPandoc(source, options);
152
+ }
153
+ else if (args.includes && (args.input !== '-' || args.includeRoot)) {
154
+ includeResult = carveToPandocWithIncludes(source, {
155
+ includeRoot: path.resolve(args.includeRoot ?? path.dirname(args.input)),
156
+ ...(args.input === '-' ? {} : { sourcePath: path.resolve(args.input) }),
157
+ }, options);
158
+ converted = includeResult;
159
+ }
160
+ else {
161
+ converted = carveToPandoc(source, options);
162
+ }
163
+ const includeWarnings = includeResult?.includeWarnings ?? [];
164
+ const suppressedIncludeWarnings = includeResult?.suppressedIncludeWarnings ?? 0;
165
+ const { doc, warnings, diagnostics } = converted;
166
+ const root = args.includeRoot ?? (args.input === '-' ? undefined : path.dirname(path.resolve(args.input)));
167
+ const includeDiagnostics = diagnosticsForIncludes(includeWarnings, suppressedIncludeWarnings, root);
168
+ const allDiagnostics = [...diagnostics, ...includeDiagnostics];
169
+ report(args, warnings, allDiagnostics, 'carve', includeWarnings, suppressedIncludeWarnings, root);
135
170
  const json = JSON.stringify(doc);
136
171
  if (args.to === 'json') {
137
172
  if (args.output)
138
173
  writeFileSync(args.output, json + '\n');
139
174
  else
140
175
  process.stdout.write(json + '\n');
141
- if (args.failOnLoss && hasLoss(diagnostics))
176
+ if (args.failOnLoss && hasLoss(allDiagnostics))
142
177
  process.exitCode = 3;
143
178
  return;
144
179
  }
@@ -172,7 +207,7 @@ async function main() {
172
207
  }
173
208
  throw result.error;
174
209
  }
175
- process.exit(result.status || (args.failOnLoss && hasLoss(diagnostics) ? 3 : 0));
210
+ process.exit(result.status || (args.failOnLoss && hasLoss(allDiagnostics) ? 3 : 0));
176
211
  }
177
212
  /** Reverse direction: pandoc-readable input -> Carve source. */
178
213
  async function importToCarve(args) {
@@ -201,17 +236,29 @@ async function importToCarve(args) {
201
236
  }
202
237
  const { pandocToCarve } = await import('./index.js');
203
238
  const { carve, warnings, diagnostics } = pandocToCarve(json);
204
- report(args, warnings, diagnostics);
239
+ const reportDiagnostics = args.from === 'json' ? diagnostics : [
240
+ ...diagnostics,
241
+ {
242
+ code: 'fidelity-unverified',
243
+ direction: 'pandoc-to-carve',
244
+ class: 'unsupported',
245
+ severity: 'error',
246
+ fidelity: 'dropped',
247
+ confidence: 'fallback',
248
+ message: 'Fidelity before the Pandoc JSON boundary was not reported; dropped is a conservative worst-case release-gate classification',
249
+ },
250
+ ];
251
+ report(args, warnings, reportDiagnostics, 'pandoc-json');
205
252
  if (args.output)
206
253
  writeFileSync(args.output, carve);
207
254
  else
208
255
  process.stdout.write(carve);
209
- if (args.failOnLoss && hasLoss(diagnostics))
256
+ if (args.failOnLoss && hasLoss(reportDiagnostics))
210
257
  process.exitCode = 3;
211
258
  }
212
- function report(args, warnings, diagnostics) {
259
+ function report(args, warnings, diagnostics, sourceFormat, includeWarnings = [], suppressedIncludeWarnings = 0, includeRoot) {
213
260
  if (args.diagnosticsFile !== undefined) {
214
- const json = JSON.stringify(diagnostics, null, 2) + '\n';
261
+ const json = JSON.stringify(migrationReport(diagnostics, sourceFormat), null, 2) + '\n';
215
262
  if (args.diagnosticsFile === '-')
216
263
  process.stderr.write(json);
217
264
  else
@@ -220,6 +267,72 @@ function report(args, warnings, diagnostics) {
220
267
  }
221
268
  for (const warning of warnings)
222
269
  process.stderr.write(`pandoc-carve: degraded: ${warning}\n`);
270
+ for (const warning of includeWarnings) {
271
+ const file = displayIncludeFile(warning.file, includeRoot, args.input);
272
+ process.stderr.write(`pandoc-carve: ${file}:${warning.line}:${warning.column} ${warning.rule} - ${warning.message}\n`);
273
+ }
274
+ if (suppressedIncludeWarnings) {
275
+ process.stderr.write(`pandoc-carve: ${suppressedIncludeWarnings} additional include warning(s) suppressed\n`);
276
+ }
277
+ for (const diagnostic of diagnostics) {
278
+ if (diagnostic.code === 'fidelity-unverified') {
279
+ process.stderr.write(`pandoc-carve: dropped: ${diagnostic.message}\n`);
280
+ }
281
+ }
282
+ }
283
+ function diagnosticsForIncludes(warnings, suppressed, root) {
284
+ const diagnostics = warnings.map((warning) => {
285
+ const normalized = NORMALIZED_INCLUDE_RULES.has(warning.rule);
286
+ const file = warning.file ? displayIncludeFile(warning.file, root, '') : undefined;
287
+ return {
288
+ code: warning.rule,
289
+ direction: 'carve-to-pandoc',
290
+ class: normalized ? 'normalized' : 'lossy',
291
+ severity: normalized ? 'info' : 'error',
292
+ fidelity: normalized ? 'normalized' : 'dropped',
293
+ confidence: 'exact',
294
+ message: warning.message,
295
+ details: {
296
+ ...(file ? { file } : {}),
297
+ line: warning.line,
298
+ column: warning.column,
299
+ },
300
+ };
301
+ });
302
+ if (suppressed)
303
+ diagnostics.push({
304
+ code: 'include-warnings-suppressed',
305
+ direction: 'carve-to-pandoc',
306
+ class: 'degraded',
307
+ severity: 'warning',
308
+ fidelity: 'degraded',
309
+ confidence: 'exact',
310
+ message: `${suppressed} additional include warning(s) suppressed`,
311
+ details: { count: suppressed },
312
+ });
313
+ return diagnostics;
314
+ }
315
+ const NORMALIZED_INCLUDE_RULES = new Set([
316
+ 'include-footnote-rename',
317
+ 'include-heading-clamp',
318
+ 'include-heading-id-rename',
319
+ ]);
320
+ function displayIncludeFile(file, root, fallback) {
321
+ if (!file || !root)
322
+ return fallback;
323
+ const canonicalRoot = realpathSync(root);
324
+ let canonicalFile = file;
325
+ try {
326
+ canonicalFile = realpathSync(file);
327
+ }
328
+ catch {
329
+ // A resolver may report an unresolved identity. Keep it contained below.
330
+ }
331
+ const relative = path.relative(canonicalRoot, canonicalFile);
332
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
333
+ return '[outside-include-root]';
334
+ }
335
+ return `[include-root]/${relative || '.'}`;
223
336
  }
224
337
  main().catch((err) => {
225
338
  process.stderr.write(`pandoc-carve: ${err instanceof Error ? err.message : String(err)}\n`);
package/dist/convert.d.ts CHANGED
@@ -11,11 +11,12 @@
11
11
  */
12
12
  import type { CarveAstDocument } from './ast-json.js';
13
13
  import * as P from './pandoc.js';
14
- import { type ConversionDiagnostic } from './diagnostics.js';
14
+ import { type ConversionDiagnostic, type MigrationReport } from './diagnostics.js';
15
15
  export interface ConvertResult {
16
16
  doc: P.PandocDoc;
17
17
  warnings: string[];
18
18
  diagnostics: ConversionDiagnostic[];
19
+ report: MigrationReport;
19
20
  }
20
21
  export interface ConvertOptions {
21
22
  /**
package/dist/convert.js CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import * as P from './pandoc.js';
13
13
  import { readRowGroups } from './row-groups.js';
14
- import { diagnostic } from './diagnostics.js';
14
+ 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';
@@ -710,13 +710,13 @@ function inline(ctx, n) {
710
710
  case 'delete':
711
711
  return [P.Span(P.attr(undefined, ['deletion']), kids(ctx, n))];
712
712
  case 'substitution': {
713
- const oldText = textInlines(String(n.oldText ?? ''));
714
- const newText = textInlines(String(n.newText ?? ''));
713
+ const oldContent = Array.isArray(n.old) ? inlines(ctx, n.old) : [];
714
+ const newContent = Array.isArray(n.new) ? inlines(ctx, n.new) : [];
715
715
  return [
716
716
  P.Span(P.attr(undefined, ['substitution']), [
717
- P.Span(P.attr(undefined, ['deletion']), oldText),
717
+ P.Span(P.attr(undefined, ['deletion']), oldContent),
718
718
  P.Str('→'),
719
- P.Span(P.attr(undefined, ['insertion']), newText),
719
+ P.Span(P.attr(undefined, ['insertion']), newContent),
720
720
  ]),
721
721
  ];
722
722
  }
@@ -2038,6 +2038,7 @@ export function convert(ast, options = {}) {
2038
2038
  // sits relative to its target.
2039
2039
  collectCrossrefTargets(ctx, body, new Map());
2040
2040
  const meta = parseMeta(ctx, frontmatter);
2041
+ const diagnostics = ctx.diagnostics;
2041
2042
  return {
2042
2043
  doc: {
2043
2044
  'pandoc-api-version': [...P.PANDOC_API_VERSION],
@@ -2045,7 +2046,8 @@ export function convert(ast, options = {}) {
2045
2046
  blocks: blocks(ctx, body),
2046
2047
  },
2047
2048
  warnings: ctx.warnings,
2048
- diagnostics: ctx.diagnostics,
2049
+ diagnostics,
2050
+ report: migrationReport(diagnostics, 'carve'),
2049
2051
  };
2050
2052
  }
2051
2053
  /**
@@ -1,10 +1,16 @@
1
1
  export type DiagnosticDirection = 'carve-to-pandoc' | 'pandoc-to-carve';
2
- export type DiagnosticSeverity = 'lossy' | 'degraded' | 'normalized' | 'unsupported';
2
+ export type DiagnosticClass = 'lossy' | 'degraded' | 'normalized' | 'unsupported';
3
+ export type DiagnosticSeverity = 'info' | 'warning' | 'error';
4
+ export type MigrationFidelity = 'preserved' | 'normalized' | 'degraded' | 'dropped';
5
+ export type MigrationConfidence = 'exact' | 'inferred' | 'fallback';
3
6
  export interface ConversionDiagnostic {
4
7
  /** Stable, machine-readable identifier. */
5
8
  code: string;
6
9
  direction: DiagnosticDirection;
10
+ class: DiagnosticClass;
7
11
  severity: DiagnosticSeverity;
12
+ fidelity: MigrationFidelity;
13
+ confidence: MigrationConfidence;
8
14
  message: string;
9
15
  /** Construct-specific values useful to migration tooling. */
10
16
  details?: Record<string, unknown>;
@@ -14,4 +20,10 @@ export interface ConversionDiagnostic {
14
20
  sourceLocation?: unknown;
15
21
  }
16
22
  export declare function diagnostic(direction: DiagnosticDirection, message: string, details?: Record<string, unknown>, sourceLocation?: unknown): ConversionDiagnostic;
23
+ export interface MigrationReport {
24
+ schemaVersion: 2;
25
+ sourceFormat: string;
26
+ diagnostics: ConversionDiagnostic[];
27
+ }
28
+ export declare function migrationReport(diagnostics: ConversionDiagnostic[], sourceFormat?: string): MigrationReport;
17
29
  export declare function hasLoss(diagnostics: ConversionDiagnostic[]): boolean;
@@ -1,49 +1,57 @@
1
1
  // Ordered from specific to general. Codes are API: add rules, never rename them.
2
2
  const RULES = [
3
- { test: /^comment:/, code: 'comment-dropped', severity: 'lossy' },
4
- { test: /^inline: unknown node type/, code: 'unknown-carve-inline', severity: 'degraded' },
5
- { test: /^block: unknown node type/, code: 'unknown-carve-block', severity: 'degraded' },
6
- { test: /^inline: pandoc node/, code: 'unsupported-pandoc-inline', severity: 'unsupported' },
7
- { test: /^block: pandoc node/, code: 'unsupported-pandoc-block', severity: 'unsupported' },
8
- { test: /^citation: .*typed locator/, code: 'citation-locator-flattened', severity: 'normalized' },
9
- { test: /^citation: .*suppresses its author/, code: 'citation-mode-normalized', severity: 'normalized' },
10
- { test: /^Cite mixes /, code: 'citation-mode-normalized', severity: 'normalized' },
11
- { test: /^Cite mapped /, code: 'citation-bibliography-not-emitted', severity: 'degraded' },
12
- { test: /^SmallCaps /, code: 'smallcaps-degraded', severity: 'degraded' },
13
- { test: /^short caption:/, code: 'short-caption-source-unavailable', severity: 'unsupported' },
14
- { test: /^figure:/, code: 'figure-unwrapped', severity: 'degraded' },
15
- { test: /^figure group: short caption/, code: 'figure-group-short-caption-dropped', severity: 'lossy' },
16
- { test: /^frontmatter: format/, code: 'frontmatter-format-unsupported', severity: 'unsupported' },
17
- { test: /^frontmatter: line/, code: 'frontmatter-line-skipped', severity: 'lossy' },
18
- { test: /^frontmatter: value/, code: 'frontmatter-value-skipped', severity: 'lossy' },
19
- { test: /^meta: .*empty/, code: 'metadata-empty-blocks-skipped', severity: 'lossy' },
20
- { test: /^meta:/, code: 'metadata-value-skipped', severity: 'lossy' },
21
- { test: /^definition list: looseness/, code: 'definition-list-looseness-widened', severity: 'normalized' },
22
- { test: /^definition list:/, code: 'definition-entry-skipped', severity: 'lossy' },
23
- { test: /^ordered list:/, code: 'ordered-list-marker-normalized', severity: 'normalized' },
24
- { test: /^list: an empty item/, code: 'empty-list-item-spelled', severity: 'normalized' },
25
- { test: /^task state:/, code: 'task-state-dropped', severity: 'lossy' },
26
- { test: /^math: attributes/, code: 'math-attributes-dropped', severity: 'lossy' },
27
- { test: /^symbol:/, code: 'symbol-unresolved', severity: 'degraded' },
28
- { test: /^url: a denied scheme/, code: 'unsafe-url-scheme', severity: 'lossy' },
29
- { test: /^attribute: unsafe name/, code: 'unsafe-attribute-name', severity: 'lossy' },
30
- { test: /^attribute: unsafe value/, code: 'unsafe-attribute-value', severity: 'lossy' },
31
- { test: /^extension:/, code: 'inline-extension-degraded', severity: 'degraded' },
32
- { test: /missing definition/, code: 'reference-unresolved', severity: 'degraded' },
33
- { test: /^crossref:/, code: 'crossref-unresolved', severity: 'degraded' },
34
- { test: /^list-table: structure/, code: 'list-table-structure-degraded', severity: 'degraded' },
35
- { test: /^list-table: the short caption/, code: 'list-table-short-caption-dropped', severity: 'lossy' },
36
- { test: /^list-table: a body group's attributes/, code: 'list-table-body-attributes-dropped', severity: 'lossy' },
37
- { test: /^list-table: the table's .* body groups/, code: 'list-table-body-groups-merged', severity: 'normalized' },
38
- { test: /^list-table: the body groups disagree/, code: 'list-table-row-heads-normalized', severity: 'normalized' },
39
- { test: /^list-table: rowspan/, code: 'list-table-rowspan-clipped', severity: 'lossy' },
40
- { test: /^table: a row header outside/, code: 'table-row-head-outside-leading-run', severity: 'lossy' },
41
- { test: /^table: block-level/, code: 'table-cell-blocks-flattened', severity: 'lossy' },
42
- { test: /^table: attributes on/, code: 'table-continuation-attributes-dropped', severity: 'lossy' },
43
- { test: /^table: colspan continuation/, code: 'table-colspan-origin-missing', severity: 'degraded' },
44
- { test: /^table: rowspan crossing/, code: 'table-rowspan-clipped', severity: 'lossy' },
45
- { test: /^table: rowspan continuation/, code: 'table-rowspan-origin-missing', severity: 'degraded' },
46
- { test: /^table:/, code: 'table-groups-normalized', severity: 'normalized' },
3
+ { test: /^comment:/, code: 'comment-dropped', class: 'lossy' },
4
+ { test: /^inline: unknown node type/, code: 'unknown-carve-inline', class: 'degraded' },
5
+ { test: /^block: unknown node type/, code: 'unknown-carve-block', class: 'degraded' },
6
+ { test: /^inline: pandoc node/, code: 'unsupported-pandoc-inline', class: 'unsupported' },
7
+ { test: /^block: pandoc node/, code: 'unsupported-pandoc-block', class: 'unsupported' },
8
+ { test: /^citation: .*typed locator/, code: 'citation-locator-flattened', class: 'normalized' },
9
+ { test: /^citation: .*suppresses its author/, code: 'citation-mode-normalized', class: 'normalized' },
10
+ { test: /^Cite mixes /, code: 'citation-mode-normalized', class: 'normalized' },
11
+ { test: /^Cite mapped /, code: 'citation-bibliography-not-emitted', class: 'degraded' },
12
+ { test: /^SmallCaps /, code: 'smallcaps-degraded', class: 'degraded' },
13
+ { test: /^short caption:/, code: 'short-caption-source-unavailable', class: 'unsupported' },
14
+ { test: /^figure:/, code: 'figure-unwrapped', class: 'degraded' },
15
+ { test: /^figure group: short caption/, code: 'figure-group-short-caption-dropped', class: 'lossy' },
16
+ { test: /^frontmatter: format/, code: 'frontmatter-format-unsupported', class: 'unsupported' },
17
+ { test: /^frontmatter: block content/, code: 'frontmatter-block-content-dropped', class: 'lossy' },
18
+ { test: /^frontmatter: line/, code: 'frontmatter-line-skipped', class: 'lossy' },
19
+ { test: /^frontmatter: value/, code: 'frontmatter-value-skipped', class: 'lossy' },
20
+ { test: /^meta: .*empty/, code: 'metadata-empty-blocks-skipped', class: 'lossy' },
21
+ { test: /^meta:/, code: 'metadata-value-skipped', class: 'lossy' },
22
+ { test: /^definition list: looseness/, code: 'definition-list-looseness-widened', class: 'normalized' },
23
+ { test: /^definition list:/, code: 'definition-entry-skipped', class: 'lossy' },
24
+ { test: /^ordered list:/, code: 'ordered-list-marker-normalized', class: 'normalized' },
25
+ { test: /^list: an empty item/, code: 'empty-list-item-spelled', class: 'normalized' },
26
+ { test: /^task state:/, code: 'task-state-dropped', class: 'lossy' },
27
+ { test: /^math: attributes/, code: 'math-attributes-dropped', class: 'lossy' },
28
+ { test: /^symbol:/, code: 'symbol-unresolved', class: 'degraded' },
29
+ { test: /^url: a denied scheme/, code: 'unsafe-url-scheme', class: 'lossy' },
30
+ { test: /^attribute: unsafe name/, code: 'unsafe-attribute-name', class: 'lossy' },
31
+ { test: /^attribute: unsafe value/, code: 'unsafe-attribute-value', class: 'lossy' },
32
+ { test: /^extension:/, code: 'inline-extension-degraded', class: 'degraded' },
33
+ { test: /^(?:link|image|footnote): missing definition/, code: 'reference-unresolved', class: 'degraded' },
34
+ { test: /^crossref:/, code: 'crossref-unresolved', class: 'degraded' },
35
+ { test: /^list-table: structure/, code: 'list-table-structure-degraded', class: 'degraded' },
36
+ { test: /^list-table: the short caption/, code: 'list-table-short-caption-dropped', class: 'lossy' },
37
+ { test: /^list-table: a body group's attributes/, code: 'list-table-body-attributes-dropped', class: 'lossy' },
38
+ { test: /^list-table: the table's .* body groups/, code: 'list-table-body-groups-merged', class: 'normalized' },
39
+ { test: /^list-table: the body groups disagree/, code: 'list-table-row-heads-normalized', class: 'normalized' },
40
+ { test: /^list-table: rowspan/, code: 'list-table-rowspan-clipped', class: 'lossy' },
41
+ { test: /^table: a row header outside/, code: 'table-row-head-outside-leading-run', class: 'lossy' },
42
+ { test: /^table: block-level/, code: 'table-cell-blocks-flattened', class: 'lossy' },
43
+ { test: /^table: attributes on/, code: 'table-continuation-attributes-dropped', class: 'lossy' },
44
+ { test: /^table: colspan continuation/, code: 'table-colspan-origin-missing', class: 'degraded' },
45
+ { test: /^table: rowspan crossing/, code: 'table-rowspan-clipped', class: 'lossy' },
46
+ { test: /^table: rowspan continuation/, code: 'table-rowspan-origin-missing', class: 'degraded' },
47
+ { test: /^table: .*padded with empty cells/, code: 'table-groups-normalized', class: 'normalized' },
48
+ { test: /^table: a cell holds block content/, code: 'table-groups-normalized', class: 'normalized' },
49
+ { test: /^table: .*converted with the implicit head\/body split/, code: 'table-row-groups-invalid', class: 'degraded' },
50
+ { test: /^table: .*preserved in the Carve AST as `rowGroups`/, code: 'table-groups-flattened', class: 'degraded' },
51
+ { test: /^table: a foot row's row header is dropped/, code: 'table-foot-row-header-dropped', class: 'lossy' },
52
+ { test: /^table: the body rows disagree on how many leading cells/, code: 'table-row-heads-degraded', class: 'degraded' },
53
+ { test: /^table: the rows of a declared body group disagree/, code: 'table-row-heads-degraded', class: 'degraded' },
54
+ { test: /^table:/, code: 'table-unclassified-loss', class: 'unsupported' },
47
55
  ];
48
56
  export function diagnostic(direction, message, details, sourceLocation) {
49
57
  const rule = RULES.find((candidate) => candidate.test.test(message));
@@ -53,12 +61,22 @@ export function diagnostic(direction, message, details, sourceLocation) {
53
61
  return {
54
62
  code: rule.code,
55
63
  direction,
56
- severity: rule.severity,
64
+ class: rule.class,
65
+ severity: rule.class === 'normalized' ? 'info' : rule.class === 'degraded' ? 'warning' : 'error',
66
+ fidelity: rule.class === 'normalized'
67
+ ? 'normalized'
68
+ : rule.class === 'degraded'
69
+ ? 'degraded'
70
+ : 'dropped',
71
+ confidence: 'inferred',
57
72
  message,
58
73
  ...(Object.keys(inferred).length ? { details: inferred } : {}),
59
74
  ...(sourceLocation !== undefined ? { sourceLocation } : {}),
60
75
  };
61
76
  }
77
+ export function migrationReport(diagnostics, sourceFormat = 'pandoc-json') {
78
+ return { schemaVersion: 2, sourceFormat, diagnostics };
79
+ }
62
80
  function inferDetails(message) {
63
81
  const details = {};
64
82
  const nodeType = /node(?: type)? "([^"]+)"/.exec(message)?.[1];
@@ -79,5 +97,5 @@ function inferDetails(message) {
79
97
  return details;
80
98
  }
81
99
  export function hasLoss(diagnostics) {
82
- return diagnostics.some((item) => item.severity === 'lossy' || item.severity === 'unsupported');
100
+ return diagnostics.some((item) => item.fidelity === 'degraded' || item.fidelity === 'dropped');
83
101
  }
@@ -0,0 +1,14 @@
1
+ import * as carve from '@markup-carve/carve';
2
+ import { type ParseOptions } from './parse-options.js';
3
+ import type { ConvertOptions, ConvertResult } from './convert.js';
4
+ export interface IncludeConversionOptions {
5
+ includeRoot: string;
6
+ sourcePath?: string;
7
+ }
8
+ export interface IncludeConversionResult extends ConvertResult {
9
+ includeWarnings: carve.IncludeWarning[];
10
+ suppressedIncludeWarnings: number;
11
+ dependencies: carve.IncludeDependency[];
12
+ }
13
+ /** Convert file-backed Carve source after contained include expansion. */
14
+ export declare function carveToPandocWithIncludes(source: string, include: IncludeConversionOptions, options?: ConvertOptions & ParseOptions): IncludeConversionResult;
@@ -0,0 +1,35 @@
1
+ import * as carve from '@markup-carve/carve';
2
+ import { fileSystemResolver } from '@markup-carve/carve/node';
3
+ import path from 'node:path';
4
+ import { toCarveAst } from './ast-json.js';
5
+ import { carveAstToPandoc } from './index.js';
6
+ import { parseExtensions } from './parse-options.js';
7
+ /** Convert file-backed Carve source after contained include expansion. */
8
+ export function carveToPandocWithIncludes(source, include, options) {
9
+ if (!path.isAbsolute(include.includeRoot)) {
10
+ throw new Error('includeRoot must be an absolute path');
11
+ }
12
+ if (include.sourcePath && !path.isAbsolute(include.sourcePath)) {
13
+ throw new Error('sourcePath must be an absolute path');
14
+ }
15
+ if (include.sourcePath) {
16
+ const relative = path.relative(include.includeRoot, include.sourcePath);
17
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
18
+ throw new Error('input file must be inside --include-root');
19
+ }
20
+ }
21
+ const extensions = parseExtensions(options);
22
+ const expanded = carve.expandIncludes(carve.parse(source, { extensions, positions: true }), source, {
23
+ resolve: fileSystemResolver(include.includeRoot),
24
+ ...(include.sourcePath ? { sourcePath: include.sourcePath } : {}),
25
+ extensions,
26
+ });
27
+ const serializer = carve.toAstJson;
28
+ const converted = carveAstToPandoc(toCarveAst(expanded.doc, serializer), options);
29
+ return {
30
+ ...converted,
31
+ includeWarnings: expanded.warnings,
32
+ suppressedIncludeWarnings: expanded.suppressedWarnings,
33
+ dependencies: expanded.dependencies,
34
+ };
35
+ }
package/dist/index.d.ts CHANGED
@@ -3,11 +3,12 @@ import { type ConvertOptions, type ConvertResult } from './convert.js';
3
3
  import { type ParseOptions } from './parse-options.js';
4
4
  import type { PandocDoc } from './pandoc.js';
5
5
  import type { ConversionDiagnostic } from './diagnostics.js';
6
+ import { type MigrationReport } from './diagnostics.js';
6
7
  export { PANDOC_API_VERSION, type PandocDoc } from './pandoc.js';
7
8
  export type { ConvertOptions, ConvertResult } from './convert.js';
8
9
  export type { ParseOptions } from './parse-options.js';
9
10
  export type { ReverseResult } from './reverse.js';
10
- export type { ConversionDiagnostic, DiagnosticDirection, DiagnosticSeverity } from './diagnostics.js';
11
+ export type { ConversionDiagnostic, DiagnosticClass, DiagnosticDirection, DiagnosticSeverity, MigrationConfidence, MigrationFidelity, MigrationReport } from './diagnostics.js';
11
12
  export type { CarveAstDocument, CarveAstNode } from './ast-json.js';
12
13
  /**
13
14
  * Parse Carve source to the serialized AST of PART 12 - the shape
@@ -46,6 +47,7 @@ export declare function pandocToCarve(doc: PandocDoc | string): {
46
47
  carve: string;
47
48
  warnings: string[];
48
49
  diagnostics: ConversionDiagnostic[];
50
+ report: MigrationReport;
49
51
  };
50
52
  /**
51
53
  * Convert Pandoc to the canonical Carve exchange AST without forcing it through
@@ -56,4 +58,5 @@ export declare function pandocToCarveAst(doc: PandocDoc | string): {
56
58
  ast: CarveAstDocument;
57
59
  warnings: string[];
58
60
  diagnostics: ConversionDiagnostic[];
61
+ report: MigrationReport;
59
62
  };
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ 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
7
  export { PANDOC_API_VERSION } from './pandoc.js';
7
8
  /**
8
9
  * The engine's own serializer, when the installed engine has one.
@@ -16,6 +17,54 @@ export { PANDOC_API_VERSION } from './pandoc.js';
16
17
  * version does not have fails at link time, before any check could run.
17
18
  */
18
19
  const engineSerializer = carve.toAstJson;
20
+ let rendererReadsInlineSubstitutions;
21
+ function engineRenderTree(ast) {
22
+ rendererReadsInlineSubstitutions ??= (() => {
23
+ const parsed = carve.parse('{~/old/~>/new/~}');
24
+ const probe = (engineSerializer ? engineSerializer(parsed) : parsed);
25
+ const children = Array.isArray(probe['children']) ? probe['children'] : [];
26
+ const paragraph = children[0];
27
+ const substitution = Array.isArray(paragraph?.['children'])
28
+ ? paragraph['children'].find((node) => node?.type === 'substitution')
29
+ : undefined;
30
+ return Array.isArray(substitution?.['old']) && Array.isArray(substitution?.['new']);
31
+ })();
32
+ return rendererReadsInlineSubstitutions ? ast : legacySubstitutions(ast);
33
+ }
34
+ function legacySubstitutions(value) {
35
+ if (Array.isArray(value))
36
+ return value.map(legacySubstitutions);
37
+ if (typeof value !== 'object' || value === null)
38
+ return value;
39
+ const node = value;
40
+ if (node['type'] === 'substitution') {
41
+ const { old, new: replacement, ...rest } = node;
42
+ const legacyRest = Object.fromEntries(Object.entries(rest).map(([key, child]) => [key, legacySubstitutions(child)]));
43
+ return {
44
+ ...legacyRest,
45
+ // The published renderer cannot express inline structure in these
46
+ // fields. This lossy view exists only at that legacy API boundary;
47
+ // the returned and converted ASTs retain the arrays.
48
+ oldText: inlineText(old),
49
+ newText: inlineText(replacement),
50
+ };
51
+ }
52
+ return Object.fromEntries(Object.entries(node).map(([key, child]) => [key, legacySubstitutions(child)]));
53
+ }
54
+ function inlineText(value) {
55
+ if (!Array.isArray(value))
56
+ return '';
57
+ return value.map((node) => {
58
+ if (typeof node !== 'object' || node === null)
59
+ return '';
60
+ const inline = node;
61
+ if (inline['type'] === 'text')
62
+ return String(inline['value'] ?? '');
63
+ if (inline['type'] === 'soft_break' || inline['type'] === 'hard_break')
64
+ return '\n';
65
+ return inlineText(inline['children']);
66
+ }).join('');
67
+ }
19
68
  /**
20
69
  * Parse Carve source to the serialized AST of PART 12 - the shape
21
70
  * `resources/ast-schema.json` pins, and the shape every engine's `--to-json`
@@ -77,9 +126,10 @@ export function pandocToCarve(doc) {
77
126
  const parsed = typeof doc === 'string' ? JSON.parse(doc) : doc;
78
127
  const { ast, warnings, diagnostics } = reverse(parsed);
79
128
  return {
80
- carve: carve.renderCarve(ast),
129
+ carve: carve.renderCarve(engineRenderTree(ast)),
81
130
  warnings,
82
131
  diagnostics,
132
+ report: migrationReport(diagnostics),
83
133
  };
84
134
  }
85
135
  /**
@@ -90,5 +140,5 @@ export function pandocToCarve(doc) {
90
140
  export function pandocToCarveAst(doc) {
91
141
  const parsed = typeof doc === 'string' ? JSON.parse(doc) : doc;
92
142
  const { ast, warnings, diagnostics } = reverse(parsed, 'ast');
93
- return { ast: toCarveAst(ast, engineSerializer), warnings, diagnostics };
143
+ return { ast: toCarveAst(ast, engineSerializer), warnings, diagnostics, report: migrationReport(diagnostics) };
94
144
  }
package/dist/reverse.js CHANGED
@@ -346,7 +346,7 @@ function span(ctx, c) {
346
346
  if (parts.length === 2) {
347
347
  const [oldC, newC] = parts.map((p) => p.c[1]);
348
348
  return [
349
- { type: 'substitution', oldText: stringify(oldC), newText: stringify(newC) },
349
+ { type: 'substitution', old: inlines(ctx, oldC), new: inlines(ctx, newC) },
350
350
  ];
351
351
  }
352
352
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markup-carve/pandoc-carve",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",
@@ -21,6 +21,10 @@
21
21
  "types": "./dist/index.d.ts",
22
22
  "import": "./dist/index.js"
23
23
  },
24
+ "./node": {
25
+ "types": "./dist/includes.d.ts",
26
+ "import": "./dist/includes.js"
27
+ },
24
28
  "./package.json": "./package.json"
25
29
  },
26
30
  "bin": {
@@ -38,7 +42,8 @@
38
42
  "scripts": {
39
43
  "build": "tsc",
40
44
  "examples:build": "npm run build && node scripts/build-examples.mjs",
41
- "test": "npm run build && node --test test/*.test.mjs",
45
+ "check:importer-fixtures": "node scripts/check-importer-fixtures.mjs",
46
+ "test": "npm run check:importer-fixtures && npm run build && node --test test/*.test.mjs",
42
47
  "prepublishOnly": "npm run build"
43
48
  },
44
49
  "repository": {
@@ -50,7 +55,7 @@
50
55
  "source": "https://github.com/markup-carve/pandoc-carve"
51
56
  },
52
57
  "dependencies": {
53
- "@markup-carve/carve": "^0.1.5"
58
+ "@markup-carve/carve": "^0.1.7"
54
59
  },
55
60
  "devDependencies": {
56
61
  "@types/node": "^24.0.0",