@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 +68 -23
- package/dist/attribute-sanitize.d.ts +6 -0
- package/dist/attribute-sanitize.js +55 -0
- package/dist/cli.js +32 -9
- package/dist/convert.d.ts +2 -0
- package/dist/convert.js +338 -100
- package/dist/diagnostics.d.ts +17 -0
- package/dist/diagnostics.js +83 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -3
- package/dist/pandoc.d.ts +1 -1
- package/dist/pandoc.js +4 -5
- package/dist/provenance.d.ts +9 -0
- package/dist/provenance.js +31 -0
- package/dist/reverse.d.ts +2 -0
- package/dist/reverse.js +326 -47
- package/dist/url-scheme.d.ts +26 -0
- package/dist/url-scheme.js +83 -0
- package/package.json +4 -3
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
|
|
113
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
the
|
|
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.
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|