@markup-carve/pandoc-carve 0.1.2 → 0.1.4

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
@@ -62,16 +62,21 @@ carve doc.crv --to-json | pandoc-carve - -f carve-json -t latex
62
62
  ```
63
63
 
64
64
  Anything Carve cannot map faithfully is reported on stderr as a
65
- `pandoc-carve: degraded ...` warning - nothing degrades silently.
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.
66
70
 
67
71
  ## API
68
72
 
69
73
  ```js
70
74
  import { carveToPandoc, carveToPandocJson } from '@markup-carve/pandoc-carve';
71
75
 
72
- const { doc, warnings } = carveToPandoc('Hello /world/!');
76
+ const { doc, warnings, diagnostics } = carveToPandoc('Hello /world/!');
73
77
  // doc = { 'pandoc-api-version': [1, 23, 1], meta: {...}, blocks: [...] }
74
78
  // warnings = ['degraded: ...'] for lossy constructs
79
+ // diagnostics = [{ code, direction, severity, message, details?, path?, sourceLocation? }]
75
80
 
76
81
  const json = carveToPandocJson('Hello /world/!'); // stringified doc
77
82
  ```
@@ -85,7 +90,7 @@ whether it arrives as an object or as JSON text:
85
90
  import { carveAstToPandoc, carveToCarveAst } from '@markup-carve/pandoc-carve';
86
91
 
87
92
  // A tree from carve-rs, carve-php, carve-go ... or `carve doc.crv --to-json`
88
- const { doc, warnings } = carveAstToPandoc(serializedAstJson);
93
+ const { doc, warnings, diagnostics } = carveAstToPandoc(serializedAstJson);
89
94
 
90
95
  // The same exchange format, produced from source here
91
96
  const ast = carveToCarveAst('Hello /world/!');
@@ -98,7 +103,7 @@ serializer), so output formatting carries fmt's guarantees:
98
103
  ```js
99
104
  import { pandocToCarve, pandocToCarveAst } from '@markup-carve/pandoc-carve';
100
105
 
101
- const { carve, warnings } = pandocToCarve(pandocJsonString);
106
+ const { carve, warnings, diagnostics } = pandocToCarve(pandocJsonString);
102
107
 
103
108
  // Preserve structured fields that Carve 0.1 source cannot spell, including
104
109
  // Pandoc's optional short figure/table caption.
@@ -109,8 +114,13 @@ Round-trips are tested as a hard gate: `carve -> pandoc AST -> carve` must
109
114
  render byte-identical HTML to the original source across the test corpus.
110
115
  For exact restoration of attribute placement (`{.lead}` on a paragraph or
111
116
  list), export with `carveToPandoc(src, { roundtrip: true })` or the CLI's
112
- `--roundtrip` flag - it stamps wrapper divs with a `carve-block` marker the
113
- importer uses, at the cost of that marker being visible in writer output.
117
+ `--roundtrip` flag. In addition to the existing `carve-block` attribute marker,
118
+ this preserves comments, typed citation fields, and unknown future Carve nodes
119
+ with a versioned private Span/Div envelope. The native citation and readable
120
+ unknown-node fallback remain usable by Pandoc. The marker may be visible in
121
+ writer output and exact preservation is guaranteed only through Pandoc JSON.
122
+ The complete format, safety checks, available source information, and filter
123
+ semantics are documented in [the provenance envelope](docs/roundtrip-provenance.md).
114
124
 
115
125
  Pipe the JSON to pandoc yourself:
116
126
 
@@ -150,6 +160,35 @@ The complete node-by-node contract lives in the test goldens. Worked
150
160
  input/output pairs in both directions - including how interactive constructs
151
161
  degrade for print formats - are in [`examples/`](examples/README.md).
152
162
 
163
+ ## Dangerous URL schemes are blanked
164
+
165
+ A link or image destination whose scheme Carve's spec denies (PART 9 §25 -
166
+ `javascript:`, `vbscript:`, `data:`, `file:`, and the OS protocol-handler class
167
+ such as `ms-msdt:`, `search-ms:`, `shell:`, `vscode:` and `jar:`) leaves this
168
+ bridge as an **empty target**, and an `unsafe-url-scheme` diagnostic is emitted
169
+ naming the scheme and the destination that was refused.
170
+
171
+ Carve's own HTML, Markdown and ANSI writers all blank it, and the clause binds
172
+ every target that emits a resolvable URL. Pandoc's targets are not HTML, but
173
+ `pandoc -f json -t html` is one command away - passing the scheme through here
174
+ would not be a narrower policy, it would be the same sink one step removed.
175
+
176
+ The diagnostic is `lossy`, so `--fail-on-loss` stops on it and a caller reading
177
+ `diagnostics` can act on it. The scheme list and the scheme probe are mirrored
178
+ from the engine rather than invented, and a test drives the engine's own writer
179
+ over both to keep the mirror honest - the probe strips control characters and
180
+ Unicode whitespace before it reads a scheme, so `<U+202F>javascript:` and
181
+ `java<DEL>script:` are caught along with the plain spelling.
182
+
183
+ Nothing else is touched. An `https:`, `mailto:`, `tel:`, `ftp:`, relative or
184
+ fragment destination reaches pandoc exactly as written, and emits no
185
+ diagnostic.
186
+
187
+ > [!NOTE]
188
+ > This covers link and image **destinations**. An `{onclick=…}`, `{style=…}` or
189
+ > `{srcdoc=…}` attribute, which Carve's HTML writer also refuses, still reaches
190
+ > pandoc - see [#163](https://github.com/markup-carve/pandoc-carve/issues/163).
191
+
153
192
  ## Why a bridge, not a pandoc reader?
154
193
 
155
194
  A native `Text.Pandoc.Readers.Carve` upstream would be a fourth full Carve
@@ -197,13 +236,14 @@ a processor with neither extension enabled would render.
197
236
  against `resources/ast-schema.json` can still be incoherent - the bridge
198
237
  checks it itself rather than trusting a green validator.
199
238
  - A pandoc table with block content in a cell (a list, two paragraphs, a code
200
- block) is imported as `::: list-table` rather than a pipe table. PART 9 §16's
201
- pipe-table cell holds inlines, so there is no pipe form for it, and the
202
- extension's cells are list items that hold full blocks. Structure is
203
- preserved; three things the extension does not spell are reported instead -
204
- per-column alignment, a foot, and a body group's intermediate header rows.
205
- It converts back to the pandoc table by default; `listTable: false` returns
206
- the degraded div instead.
239
+ block) is imported as `::: list-table` rather than a pipe table, and that is
240
+ now the ONLY reason a table leaves the pipe form. PART 9 §16's pipe-table cell
241
+ holds inlines, so there is no pipe form for it, and the extension's cells are
242
+ list items that hold full blocks. Structure is preserved; what the extension
243
+ cannot spell is reported instead - a body group's attributes, a body boundary
244
+ no header row marks, and body groups that disagree on their row-head column
245
+ count. It converts back to the pandoc table by default; `listTable: false`
246
+ returns the degraded div instead.
207
247
  - A pandoc table with ROW-HEAD COLUMNS stays a pipe table. `RowHeadColumns` says
208
248
  the leading N cells of every body row are row headers, and a pipe table says
209
249
  that per cell: `|= Mercury | 4,879.4 |` is a `<th scope="row">`. Marking the
@@ -249,7 +289,9 @@ a processor with neither extension enabled would render.
249
289
  those two fields on the way back - the label table is section 4.2's, it lives
250
290
  in the engine, and a second copy here would drift. The locator TEXT round-
251
291
  trips byte for byte, so re-parsing the emitted source with the citations
252
- extension restores the typed pair. A group whose items mix `AuthorInText` and
292
+ extension restores the typed pair. With `roundtrip: true`, the private
293
+ provenance wrapper retains the original typed fields directly while its
294
+ enclosed native `Cite` remains available to citeproc. A group whose items mix `AuthorInText` and
253
295
  `NormalCitation` cannot be spelled in Carve (the integral `+` is a property of
254
296
  the whole cluster) and is imported as integral with a warning.
255
297
  - Pandoc keeps its bibliography in document metadata, not in the AST, so
@@ -266,11 +308,18 @@ a processor with neither extension enabled would render.
266
308
  lists, lists of maps, scalars, booleans - round-trips too.
267
309
  - A `rowGroups` partition (a foot, several body groups, a body's own header
268
310
  rows or row-head columns) survives into the exchange AST, so
269
- `pandocToCarveAst` hands it on whole - but PART 9 section 16's pipe table
270
- spells only a leading run of header rows, so `pandocToCarve` flattens the
271
- rest into body rows and says so. Row-head columns are the exception - the pipe
272
- table marks them on the cells (above). Use the AST entry point when a foot or
273
- a second body group has to survive.
311
+ `pandocToCarveAst` hands it on whole. `pandocToCarve` spells most of it too: a
312
+ pipe table states its head and foot row counts on its attribute line
313
+ (`{header-rows=2 footer-rows=1}`) and marks its row headers on the cells, so
314
+ what flattens into body rows is a second body group and a body's own
315
+ intermediate header rows and attributes - reported, not dropped quietly. Use
316
+ the AST entry point when a second body group has to survive.
317
+ - Stating the foot states the whole partition, and a stated partition is ONE
318
+ body. A body carries one `RowHeadColumns`, so when the body rows disagree on
319
+ how many leading cells are row headers, a table with a foot cannot keep them:
320
+ the reader takes a count every row agrees on, and the disagreement is reported
321
+ from both directions. Without a foot the reader splits a body at every change
322
+ and every run survives.
274
323
  - A marker on a HEAD cell (`|=> Name |`) is the column's alignment and becomes
275
324
  pandoc's `ColSpec`; a marker on a BODY cell (`|> 12 |`) aligns that cell alone
276
325
  and becomes the cell's own `Alignment`. The two are not interchangeable: a
@@ -320,7 +369,3 @@ npm ci && npm test
320
369
  Without the submodule the corpus tests FAIL rather than skip - a skipped corpus
321
370
  reads like a converted one, which is how a stack overflow reachable from a
322
371
  two-word document stayed live on the published package.
323
-
324
- ## License
325
-
326
- MIT
@@ -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
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { readFileSync, writeFileSync } from 'node:fs';
12
12
  import { carveAstToPandoc, carveToPandoc } from './index.js';
13
+ import { hasLoss } from './diagnostics.js';
13
14
  function usage(exitCode) {
14
15
  const text = `Usage: pandoc-carve <input | -> [options] [-- pandoc-args...]
15
16
 
@@ -31,6 +32,9 @@ Common options:
31
32
  them to real tables)
32
33
  --no-citations read [@key] as an @mention rather than a citation
33
34
  (export from Carve source; default reads citations)
35
+ --diagnostics FILE write structured diagnostics as JSON (use - for stderr;
36
+ human warnings are suppressed)
37
+ --fail-on-loss exit 3 when lossy or unsupported diagnostics are present
34
38
  --symbols FILE JSON map resolving :name: symbols to text (export)
35
39
  --pandoc PATH pandoc executable (default: $PANDOC or "pandoc")
36
40
  -h, --help show this help
@@ -46,6 +50,7 @@ function parseArgs(argv) {
46
50
  roundtrip: false,
47
51
  listTable: true,
48
52
  citations: true,
53
+ failOnLoss: false,
49
54
  pandocPath: process.env.PANDOC ?? 'pandoc',
50
55
  passthrough: [],
51
56
  };
@@ -83,6 +88,12 @@ function parseArgs(argv) {
83
88
  else if (a === '--symbols') {
84
89
  args.symbolsFile = argv[++i] ?? usage(1);
85
90
  }
91
+ else if (a === '--diagnostics') {
92
+ args.diagnosticsFile = argv[++i] ?? usage(1);
93
+ }
94
+ else if (a === '--fail-on-loss') {
95
+ args.failOnLoss = true;
96
+ }
86
97
  else if (a === '--pandoc') {
87
98
  args.pandocPath = argv[++i] ?? usage(1);
88
99
  }
@@ -117,18 +128,18 @@ async function main() {
117
128
  citations: args.citations,
118
129
  symbols,
119
130
  };
120
- const { doc, warnings } = args.from === 'carve-json'
131
+ const { doc, warnings, diagnostics } = args.from === 'carve-json'
121
132
  ? carveAstToPandoc(source, options)
122
133
  : carveToPandoc(source, options);
123
- for (const w of warnings) {
124
- process.stderr.write(`pandoc-carve: degraded: ${w}\n`);
125
- }
134
+ report(args, warnings, diagnostics);
126
135
  const json = JSON.stringify(doc);
127
136
  if (args.to === 'json') {
128
137
  if (args.output)
129
138
  writeFileSync(args.output, json + '\n');
130
139
  else
131
140
  process.stdout.write(json + '\n');
141
+ if (args.failOnLoss && hasLoss(diagnostics))
142
+ process.exitCode = 3;
132
143
  return;
133
144
  }
134
145
  const { spawnSync } = await import('node:child_process');
@@ -161,7 +172,7 @@ async function main() {
161
172
  }
162
173
  throw result.error;
163
174
  }
164
- process.exit(result.status ?? 0);
175
+ process.exit(result.status || (args.failOnLoss && hasLoss(diagnostics) ? 3 : 0));
165
176
  }
166
177
  /** Reverse direction: pandoc-readable input -> Carve source. */
167
178
  async function importToCarve(args) {
@@ -189,14 +200,26 @@ async function importToCarve(args) {
189
200
  json = result.stdout;
190
201
  }
191
202
  const { pandocToCarve } = await import('./index.js');
192
- const { carve, warnings } = pandocToCarve(json);
193
- for (const w of warnings) {
194
- process.stderr.write(`pandoc-carve: degraded: ${w}\n`);
195
- }
203
+ const { carve, warnings, diagnostics } = pandocToCarve(json);
204
+ report(args, warnings, diagnostics);
196
205
  if (args.output)
197
206
  writeFileSync(args.output, carve);
198
207
  else
199
208
  process.stdout.write(carve);
209
+ if (args.failOnLoss && hasLoss(diagnostics))
210
+ process.exitCode = 3;
211
+ }
212
+ function report(args, warnings, diagnostics) {
213
+ if (args.diagnosticsFile !== undefined) {
214
+ const json = JSON.stringify(diagnostics, null, 2) + '\n';
215
+ if (args.diagnosticsFile === '-')
216
+ process.stderr.write(json);
217
+ else
218
+ writeFileSync(args.diagnosticsFile, json);
219
+ return;
220
+ }
221
+ for (const warning of warnings)
222
+ process.stderr.write(`pandoc-carve: degraded: ${warning}\n`);
200
223
  }
201
224
  main().catch((err) => {
202
225
  process.stderr.write(`pandoc-carve: ${err instanceof Error ? err.message : String(err)}\n`);
package/dist/convert.d.ts CHANGED
@@ -11,9 +11,11 @@
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
15
  export interface ConvertResult {
15
16
  doc: P.PandocDoc;
16
17
  warnings: string[];
18
+ diagnostics: ConversionDiagnostic[];
17
19
  }
18
20
  export interface ConvertOptions {
19
21
  /**