@markup-carve/pandoc-carve 0.1.3 → 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.
@@ -160,6 +192,36 @@ The complete node-by-node contract lives in the test goldens. Worked
160
192
  input/output pairs in both directions - including how interactive constructs
161
193
  degrade for print formats - are in [`examples/`](examples/README.md).
162
194
 
195
+ ## Dangerous URL schemes are blanked
196
+
197
+ A link or image destination whose scheme Carve's spec denies (PART 9 §25 -
198
+ `javascript:`, `vbscript:`, `data:`, `file:`, and the OS protocol-handler class
199
+ such as `ms-msdt:`, `search-ms:`, `shell:`, `vscode:` and `jar:`) leaves this
200
+ bridge as an **empty target**, and an `unsafe-url-scheme` diagnostic is emitted
201
+ naming the scheme and the destination that was refused.
202
+
203
+ Carve's own HTML, Markdown and ANSI writers all blank it, and the clause binds
204
+ every target that emits a resolvable URL. Pandoc's targets are not HTML, but
205
+ `pandoc -f json -t html` is one command away - passing the scheme through here
206
+ would not be a narrower policy, it would be the same sink one step removed.
207
+
208
+ The diagnostic class is `lossy`, so `--fail-on-loss` stops on its dropped
209
+ fidelity and a caller reading
210
+ `diagnostics` can act on it. The scheme list and the scheme probe are mirrored
211
+ from the engine rather than invented, and a test drives the engine's own writer
212
+ over both to keep the mirror honest - the probe strips control characters and
213
+ Unicode whitespace before it reads a scheme, so `<U+202F>javascript:` and
214
+ `java<DEL>script:` are caught along with the plain spelling.
215
+
216
+ Nothing else is touched. An `https:`, `mailto:`, `tel:`, `ftp:`, relative or
217
+ fragment destination reaches pandoc exactly as written, and emits no
218
+ diagnostic.
219
+
220
+ > [!NOTE]
221
+ > This covers link and image **destinations**. An `{onclick=…}`, `{style=…}` or
222
+ > `{srcdoc=…}` attribute, which Carve's HTML writer also refuses, still reaches
223
+ > pandoc - see [#163](https://github.com/markup-carve/pandoc-carve/issues/163).
224
+
163
225
  ## Why a bridge, not a pandoc reader?
164
226
 
165
227
  A native `Text.Pandoc.Readers.Carve` upstream would be a fourth full Carve
@@ -327,16 +389,4 @@ a processor with neither extension enabled would render.
327
389
 
328
390
  ## Development
329
391
 
330
- The test suite converts the whole shared spec corpus, which arrives as a
331
- submodule:
332
-
333
- ```sh
334
- git clone --recurse-submodules https://github.com/markup-carve/pandoc-carve
335
- # or, in an existing clone:
336
- git submodule update --init
337
- npm ci && npm test
338
- ```
339
-
340
- Without the submodule the corpus tests FAIL rather than skip - a skipped corpus
341
- reads like a converted one, which is how a stack overflow reachable from a
342
- 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
@@ -0,0 +1,6 @@
1
+ /** Attribute names Carve never emits, regardless of value. */
2
+ export declare const DANGEROUS_ATTR_NAMES: readonly string[];
3
+ /** Mirror of Carve's always-on PART 9 §25 attribute-name policy. */
4
+ export declare function isDangerousAttrName(name: string): boolean;
5
+ /** Mirror of Carve's always-on PART 9 §25 attribute-value policy. */
6
+ export declare function renderedAttrValue(name: string, value: string): string;
@@ -0,0 +1,55 @@
1
+ import { DANGEROUS_URL_SCHEMES, SCHEME_PROBE_STRIP_RE } from './url-scheme.js';
2
+ /** Attribute names Carve never emits, regardless of value. */
3
+ export const DANGEROUS_ATTR_NAMES = ['srcdoc', 'formaction'];
4
+ const DANGEROUS_NAMES = new Set(DANGEROUS_ATTR_NAMES);
5
+ const DANGEROUS_VALUE_SCHEMES = new Set(DANGEROUS_URL_SCHEMES);
6
+ const ASCII_WHITESPACE = '\\t\\n\\f\\r ';
7
+ const URL_LIST_SEPARATORS = new Map([
8
+ ['srcset', new RegExp(`[,${ASCII_WHITESPACE}]+`)],
9
+ ['imagesrcset', new RegExp(`[,${ASCII_WHITESPACE}]+`)],
10
+ ['ping', new RegExp(`[${ASCII_WHITESPACE}]+`)],
11
+ ['attributionsrc', new RegExp(`[${ASCII_WHITESPACE}]+`)],
12
+ ]);
13
+ /** Mirror of Carve's always-on PART 9 §25 attribute-name policy. */
14
+ export function isDangerousAttrName(name) {
15
+ const normalized = name.toLowerCase();
16
+ return normalized.startsWith('on') || DANGEROUS_NAMES.has(normalized);
17
+ }
18
+ function hasDeniedValueScheme(value) {
19
+ const colon = value.indexOf(':');
20
+ if (colon === -1)
21
+ return false;
22
+ const scheme = value.slice(0, colon).replace(SCHEME_PROBE_STRIP_RE, '').toLowerCase();
23
+ return DANGEROUS_VALUE_SCHEMES.has(scheme);
24
+ }
25
+ function decodeCssEscapes(value) {
26
+ return value.replace(/\\([0-9a-f]{1,6}\s?|[\s\S])/gi, (_match, escape) => {
27
+ if (!/^[0-9a-f]/i.test(escape))
28
+ return escape;
29
+ const codePoint = Number.parseInt(escape.trim(), 16);
30
+ return Number.isFinite(codePoint) && codePoint <= 0x10ffff
31
+ ? String.fromCodePoint(codePoint)
32
+ : '';
33
+ });
34
+ }
35
+ function hasDangerousCss(value) {
36
+ const compact = decodeCssEscapes(value.replace(/\/\*[\s\S]*?\*\//g, ''))
37
+ .toLowerCase()
38
+ .replace(/\s+/g, '');
39
+ return compact.includes('expression(')
40
+ || compact.includes('url(')
41
+ || compact.includes('@import')
42
+ || compact.includes('behavior:')
43
+ || compact.includes('-moz-binding');
44
+ }
45
+ /** Mirror of Carve's always-on PART 9 §25 attribute-value policy. */
46
+ export function renderedAttrValue(name, value) {
47
+ if (hasDeniedValueScheme(value))
48
+ return '';
49
+ const separator = URL_LIST_SEPARATORS.get(name.toLowerCase());
50
+ if (separator && value.split(separator).some((part) => part !== '' && hasDeniedValueScheme(part)))
51
+ return '';
52
+ if (name.toLowerCase() === 'style' && hasDangerousCss(value))
53
+ return '';
54
+ return value;
55
+ }
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
  /**