@markup-carve/pandoc-carve 0.1.3 → 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 +29 -0
- package/dist/attribute-sanitize.d.ts +6 -0
- package/dist/attribute-sanitize.js +55 -0
- package/dist/convert.js +98 -34
- package/dist/diagnostics.js +5 -0
- package/dist/reverse.js +48 -1
- package/dist/url-scheme.d.ts +26 -0
- package/dist/url-scheme.js +83 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -160,6 +160,35 @@ The complete node-by-node contract lives in the test goldens. Worked
|
|
|
160
160
|
input/output pairs in both directions - including how interactive constructs
|
|
161
161
|
degrade for print formats - are in [`examples/`](examples/README.md).
|
|
162
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
|
+
|
|
163
192
|
## Why a bridge, not a pandoc reader?
|
|
164
193
|
|
|
165
194
|
A native `Text.Pandoc.Readers.Carve` upstream would be a fourth full Carve
|
|
@@ -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/convert.js
CHANGED
|
@@ -13,11 +13,34 @@ import * as P from './pandoc.js';
|
|
|
13
13
|
import { readRowGroups } from './row-groups.js';
|
|
14
14
|
import { diagnostic } from './diagnostics.js';
|
|
15
15
|
import { provenanceAttr } from './provenance.js';
|
|
16
|
+
import { blankDeniedDestination, probeScheme } from './url-scheme.js';
|
|
17
|
+
import { isDangerousAttrName, renderedAttrValue } from './attribute-sanitize.js';
|
|
16
18
|
import { SMART_PUNCTUATION_GLYPHS } from '@markup-carve/carve';
|
|
17
19
|
function warn(ctx, msg, details, sourceLocation) {
|
|
18
20
|
ctx.warnings.push(msg);
|
|
19
21
|
ctx.diagnostics.push(diagnostic('carve-to-pandoc', msg, details, sourceLocation));
|
|
20
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* A destination with a PART 9 §25 scheme blanked, and said out loud.
|
|
25
|
+
*
|
|
26
|
+
* The engine's writers blank it and the AST keeps what the author wrote, so
|
|
27
|
+
* without this the scheme reaches pandoc intact and `pandoc -f json -t html`
|
|
28
|
+
* puts it straight back into an `href` (markup-carve/pandoc-carve#157). Only
|
|
29
|
+
* schemes the engine denies are touched, and it denies none that a LaTeX or
|
|
30
|
+
* Typst document has a legitimate use for.
|
|
31
|
+
*
|
|
32
|
+
* `lossy`, not `normalized`: the author's destination does not survive, and
|
|
33
|
+
* `--fail-on-loss` is the switch a caller who would rather stop than lose it
|
|
34
|
+
* already reaches for.
|
|
35
|
+
*/
|
|
36
|
+
function safeDestination(ctx, url, kind) {
|
|
37
|
+
const blanked = blankDeniedDestination(url);
|
|
38
|
+
if (blanked === url)
|
|
39
|
+
return url;
|
|
40
|
+
warn(ctx, `url: a denied scheme on a ${kind} destination is blanked - ` +
|
|
41
|
+
`"${truncateForWarning(url)}"`, { scheme: probeScheme(url), destination: url, construct: kind });
|
|
42
|
+
return blanked;
|
|
43
|
+
}
|
|
21
44
|
/**
|
|
22
45
|
* A warning carries enough of the dropped content to find it in the source,
|
|
23
46
|
* and not a whole fenced block.
|
|
@@ -26,9 +49,20 @@ function truncateForWarning(content) {
|
|
|
26
49
|
const flat = content.replace(/\s+/g, ' ').trim();
|
|
27
50
|
return flat.length > 80 ? `${flat.slice(0, 77)}...` : flat;
|
|
28
51
|
}
|
|
29
|
-
function toAttr(attrs) {
|
|
52
|
+
function toAttr(ctx, attrs) {
|
|
30
53
|
const a = (attrs ?? {});
|
|
31
|
-
const kvs =
|
|
54
|
+
const kvs = [];
|
|
55
|
+
for (const [name, value] of Object.entries(a.keyValues ?? {})) {
|
|
56
|
+
if (isDangerousAttrName(name)) {
|
|
57
|
+
warn(ctx, `attribute: unsafe name "${name}" is dropped`, { attribute: name });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const safe = renderedAttrValue(name, value);
|
|
61
|
+
if (safe !== value) {
|
|
62
|
+
warn(ctx, `attribute: unsafe value for "${name}" is blanked`, { attribute: name, value });
|
|
63
|
+
}
|
|
64
|
+
kvs.push([name, safe]);
|
|
65
|
+
}
|
|
32
66
|
return P.attr(a.id, a.classes, kvs);
|
|
33
67
|
}
|
|
34
68
|
/**
|
|
@@ -456,7 +490,7 @@ function inline(ctx, n) {
|
|
|
456
490
|
case 'superscript':
|
|
457
491
|
return [P.Superscript(kids(ctx, n))];
|
|
458
492
|
case 'code':
|
|
459
|
-
return [P.Code(toAttr(n.attrs), String(n.value ?? ''))];
|
|
493
|
+
return [P.Code(toAttr(ctx, n.attrs), String(n.value ?? ''))];
|
|
460
494
|
case 'literal_inline': {
|
|
461
495
|
// PART 9 SS27: verbatim capture rendered as ORDINARY PROSE. The
|
|
462
496
|
// `<code>` wrapper is deliberately dropped, so Pandoc `Code` would
|
|
@@ -478,7 +512,7 @@ function inline(ctx, n) {
|
|
|
478
512
|
if (ctx.roundtrip)
|
|
479
513
|
return [P.Span(provenanceAttr('literal-inline', n), text)];
|
|
480
514
|
return hasAttrs(n.attrs)
|
|
481
|
-
? [P.Span(toAttr(n.attrs), text)]
|
|
515
|
+
? [P.Span(toAttr(ctx, n.attrs), text)]
|
|
482
516
|
: text;
|
|
483
517
|
}
|
|
484
518
|
case 'link': {
|
|
@@ -486,14 +520,14 @@ function inline(ctx, n) {
|
|
|
486
520
|
// it is tried before the missing-definition path reports one.
|
|
487
521
|
const heading = String(n.href ?? '') === '' ? collapsedHeadingRef(ctx, n) : undefined;
|
|
488
522
|
if (heading !== undefined) {
|
|
489
|
-
return [P.Link(toAttr(n.attrs), kids(ctx, n), ['#' + heading, ''])];
|
|
523
|
+
return [P.Link(toAttr(ctx, n.attrs), kids(ctx, n), ['#' + heading, ''])];
|
|
490
524
|
}
|
|
491
525
|
const unresolved = unresolvedReference(ctx, n, 'link');
|
|
492
526
|
if (unresolved)
|
|
493
527
|
return unresolved;
|
|
494
528
|
return [
|
|
495
|
-
P.Link(toAttr(n.attrs), kids(ctx, n), [
|
|
496
|
-
String(n.href ?? ''),
|
|
529
|
+
P.Link(toAttr(ctx, n.attrs), kids(ctx, n), [
|
|
530
|
+
safeDestination(ctx, String(n.href ?? ''), 'link'),
|
|
497
531
|
String(n.title ?? ''),
|
|
498
532
|
]),
|
|
499
533
|
];
|
|
@@ -509,9 +543,12 @@ function inline(ctx, n) {
|
|
|
509
543
|
// replace it: the class says what KIND of link this is, and a
|
|
510
544
|
// consumer keying on it should not lose that because the author
|
|
511
545
|
// added one of their own.
|
|
512
|
-
const [id, classes, kvs] = toAttr(n.attrs);
|
|
546
|
+
const [id, classes, kvs] = toAttr(ctx, n.attrs);
|
|
513
547
|
return [
|
|
514
|
-
P.Link([id, [cls, ...classes], kvs], [P.Str(String(n.text ?? href))],
|
|
548
|
+
P.Link([id, [cls, ...classes], kvs], [P.Str(String(n.text ?? href))],
|
|
549
|
+
// The autolink's TEXT is the URL and stays visible, exactly
|
|
550
|
+
// as the HTML writer leaves it: only the destination goes.
|
|
551
|
+
[safeDestination(ctx, href, 'link'), '']),
|
|
515
552
|
];
|
|
516
553
|
}
|
|
517
554
|
case 'image': {
|
|
@@ -519,8 +556,8 @@ function inline(ctx, n) {
|
|
|
519
556
|
if (unresolved)
|
|
520
557
|
return unresolved;
|
|
521
558
|
return [
|
|
522
|
-
P.Image(toAttr(n.attrs), textInlines(String(n.alt ?? '')), [
|
|
523
|
-
String(n.src ?? ''),
|
|
559
|
+
P.Image(toAttr(ctx, n.attrs), textInlines(String(n.alt ?? '')), [
|
|
560
|
+
safeDestination(ctx, String(n.src ?? ''), 'image'),
|
|
524
561
|
String(n.title ?? ''),
|
|
525
562
|
]),
|
|
526
563
|
];
|
|
@@ -667,7 +704,7 @@ function inline(ctx, n) {
|
|
|
667
704
|
case 'citation_group':
|
|
668
705
|
return [citationGroup(ctx, n)];
|
|
669
706
|
case 'span':
|
|
670
|
-
return [smallCapsOrSpan(toAttr(n.attrs), kids(ctx, n))];
|
|
707
|
+
return [smallCapsOrSpan(toAttr(ctx, n.attrs), kids(ctx, n))];
|
|
671
708
|
case 'insert':
|
|
672
709
|
return [P.Span(P.attr(undefined, ['insertion']), kids(ctx, n))];
|
|
673
710
|
case 'delete':
|
|
@@ -794,7 +831,7 @@ function citationSuffix(ctx, item) {
|
|
|
794
831
|
*/
|
|
795
832
|
function citationDefinition(ctx, n) {
|
|
796
833
|
const key = String(n.key ?? '');
|
|
797
|
-
const [id, classes, kvs] = toAttr(n.attrs);
|
|
834
|
+
const [id, classes, kvs] = toAttr(ctx, n.attrs);
|
|
798
835
|
const entry = inlines(ctx, n.children);
|
|
799
836
|
return P.Div([id || (key ? `ref-${key}` : ''), ['csl-entry', ...classes], kvs],
|
|
800
837
|
// Section 18 allows an empty entry, and an empty `Para` is not a shape
|
|
@@ -849,7 +886,7 @@ function block(ctx, n) {
|
|
|
849
886
|
if (!ATTR_CARRYING.has(n.type) && hasAttrs(a)) {
|
|
850
887
|
// In roundtrip mode the carve-block marker lets the reverse direction
|
|
851
888
|
// restore the attrs onto the inner block instead of keeping a wrapper.
|
|
852
|
-
const [id, classes, kvs] = toAttr(a);
|
|
889
|
+
const [id, classes, kvs] = toAttr(ctx, a);
|
|
853
890
|
const marked = ctx.roundtrip
|
|
854
891
|
? [...kvs, ['carve-block', n.type]]
|
|
855
892
|
: kvs;
|
|
@@ -890,15 +927,14 @@ function blockInner(ctx, n) {
|
|
|
890
927
|
return [ctx.tight ? P.Plain(xs) : P.Para(xs)];
|
|
891
928
|
}
|
|
892
929
|
case 'heading':
|
|
893
|
-
return [P.Header(Number(n.level ?? 1), toAttr(n.attrs), kids(ctx, n))];
|
|
930
|
+
return [P.Header(Number(n.level ?? 1), toAttr(ctx, n.attrs), kids(ctx, n))];
|
|
894
931
|
case 'block_quote':
|
|
895
932
|
return [P.BlockQuote(untight(ctx, () => blocks(ctx, n.children)))];
|
|
896
933
|
case 'code_block': {
|
|
897
934
|
const lang = n.lang ? [String(n.lang)] : [];
|
|
898
|
-
const
|
|
899
|
-
const kvs = Object.entries(a.keyValues ?? {});
|
|
935
|
+
const [id, classes, kvs] = toAttr(ctx, n.attrs);
|
|
900
936
|
return [
|
|
901
|
-
P.CodeBlock(P.attr(
|
|
937
|
+
P.CodeBlock(P.attr(id, [...lang, ...classes], kvs), String(n.content ?? '')),
|
|
902
938
|
];
|
|
903
939
|
}
|
|
904
940
|
case 'raw_block':
|
|
@@ -929,7 +965,7 @@ function blockInner(ctx, n) {
|
|
|
929
965
|
? [P.Para([P.Strong(inlines(ctx, n.title))])]
|
|
930
966
|
: [];
|
|
931
967
|
const body = untight(ctx, () => blocks(ctx, n.children));
|
|
932
|
-
const [id, classes, kvs] = toAttr(n.attrs);
|
|
968
|
+
const [id, classes, kvs] = toAttr(ctx, n.attrs);
|
|
933
969
|
return [
|
|
934
970
|
P.Div([id, ['admonition', kind, ...classes], [...kvs, ...labelKv(ctx, n)]], [...title, ...labelCaption(ctx, n), ...body]),
|
|
935
971
|
];
|
|
@@ -944,7 +980,7 @@ function blockInner(ctx, n) {
|
|
|
944
980
|
case 'line_block':
|
|
945
981
|
return [lineBlock(ctx, n)];
|
|
946
982
|
case 'div': {
|
|
947
|
-
const [id, classes, kvs] = toAttr(n.attrs);
|
|
983
|
+
const [id, classes, kvs] = toAttr(ctx, n.attrs);
|
|
948
984
|
return [
|
|
949
985
|
P.Div([id, classes, [...kvs, ...labelKv(ctx, n)]], [
|
|
950
986
|
...labelCaption(ctx, n),
|
|
@@ -1012,10 +1048,17 @@ function list(ctx, n) {
|
|
|
1012
1048
|
if (typeof item.checked === 'boolean') {
|
|
1013
1049
|
itemBlocks = prefixTaskMarker(itemBlocks, item.checked);
|
|
1014
1050
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1051
|
+
const state = extendedTaskState(item);
|
|
1052
|
+
if (state && !ctx.roundtrip) {
|
|
1053
|
+
warn(ctx, `task state: "${state}" has no pandoc equivalent - the item is written as an ` +
|
|
1054
|
+
'unchecked box; convert with roundtrip: true to carry it', { taskState: state }, item.pos);
|
|
1055
|
+
}
|
|
1056
|
+
const carried = state && ctx.roundtrip;
|
|
1057
|
+
if (hasAttrs(item.attrs) || carried) {
|
|
1058
|
+
const [id, classes, kvs] = toAttr(ctx, item.attrs);
|
|
1017
1059
|
const marked = ctx.roundtrip
|
|
1018
|
-
? [...kvs, ['carve-list-item', 'true']
|
|
1060
|
+
? [...kvs, ['carve-list-item', 'true'],
|
|
1061
|
+
...(carried ? [[TASK_STATE_KEY, state]] : [])]
|
|
1019
1062
|
: kvs;
|
|
1020
1063
|
itemBlocks = [P.Div([id, classes, marked], itemBlocks)];
|
|
1021
1064
|
}
|
|
@@ -1030,6 +1073,27 @@ function list(ctx, n) {
|
|
|
1030
1073
|
}
|
|
1031
1074
|
return P.BulletList(converted);
|
|
1032
1075
|
}
|
|
1076
|
+
/**
|
|
1077
|
+
* The roundtrip envelope's slot for a task state pandoc cannot say.
|
|
1078
|
+
*
|
|
1079
|
+
* Spelled as a plain kv beside `carve-list-item` rather than through the
|
|
1080
|
+
* base64 provenance envelope: a list item has no Attr of its own, so the Div
|
|
1081
|
+
* that carries `carve-list-item` is already the item's only slot, and a single
|
|
1082
|
+
* enum character needs no encoding to survive an attribute value.
|
|
1083
|
+
* `src/reverse.ts` reads the same literal.
|
|
1084
|
+
*/
|
|
1085
|
+
const TASK_STATE_KEY = 'carve-task-state';
|
|
1086
|
+
/**
|
|
1087
|
+
* A task state that `checked` alone cannot say, or null.
|
|
1088
|
+
*
|
|
1089
|
+
* PART 12 pins `taskState` to `" " x - _ > ?` and defines the first two as the
|
|
1090
|
+
* defaults for `checked` - those two are already carried by the ballot box, and
|
|
1091
|
+
* only the other four are Carve-only information.
|
|
1092
|
+
*/
|
|
1093
|
+
function extendedTaskState(item) {
|
|
1094
|
+
const state = item.taskState;
|
|
1095
|
+
return typeof state === 'string' && ['-', '_', '>', '?'].includes(state) ? state : null;
|
|
1096
|
+
}
|
|
1033
1097
|
/** GFM-style task markers: pandoc renders `- [x]` as a leading ballot-box Str. */
|
|
1034
1098
|
function prefixTaskMarker(itemBlocks, checked) {
|
|
1035
1099
|
const marker = [P.Str(checked ? '☒' : '☐'), P.Space];
|
|
@@ -1272,7 +1336,7 @@ function table(ctx, n, caption, shortCaption = null) {
|
|
|
1272
1336
|
const cellAlign = firstRowIsHead && r === 0
|
|
1273
1337
|
? 'AlignDefault'
|
|
1274
1338
|
: ALIGN[cc.align ?? ''] ?? 'AlignDefault';
|
|
1275
|
-
const pc = P.cell(cellBlocks, cellAlign, toAttr(cc.attrs));
|
|
1339
|
+
const pc = P.cell(cellBlocks, cellAlign, toAttr(ctx, cc.attrs));
|
|
1276
1340
|
origin[r][c] = { cell: pc, row: r, col: c };
|
|
1277
1341
|
emitted[r][c] = pc;
|
|
1278
1342
|
}
|
|
@@ -1282,7 +1346,7 @@ function table(ctx, n, caption, shortCaption = null) {
|
|
|
1282
1346
|
for (let r = from; r < to; r++) {
|
|
1283
1347
|
const cells = emitted[r].filter((x) => x !== null);
|
|
1284
1348
|
const rowAttrs = n.rows[r]?.attrs;
|
|
1285
|
-
out.push(P.row(cells, hasAttrs(rowAttrs) ? toAttr(rowAttrs) : undefined));
|
|
1349
|
+
out.push(P.row(cells, hasAttrs(rowAttrs) ? toAttr(ctx, rowAttrs) : undefined));
|
|
1286
1350
|
}
|
|
1287
1351
|
return out;
|
|
1288
1352
|
};
|
|
@@ -1301,7 +1365,7 @@ function table(ctx, n, caption, shortCaption = null) {
|
|
|
1301
1365
|
const headTo = at + body.headRows;
|
|
1302
1366
|
const bodyTo = headTo + body.bodyRows;
|
|
1303
1367
|
const converted = {
|
|
1304
|
-
attr: toAttr(body.attrs),
|
|
1368
|
+
attr: toAttr(ctx, body.attrs),
|
|
1305
1369
|
headRows: toRows(at, headTo),
|
|
1306
1370
|
bodyRows: toRows(headTo, bodyTo),
|
|
1307
1371
|
};
|
|
@@ -1405,7 +1469,7 @@ function table(ctx, n, caption, shortCaption = null) {
|
|
|
1405
1469
|
// reached `groups` above - leaving them here would state the same fact
|
|
1406
1470
|
// twice and put a stray attribute on the table in every pandoc writer.
|
|
1407
1471
|
// The list-table reader already filters its own copies of both.
|
|
1408
|
-
toAttrWithout(n.attrs, ['aligns', 'valigns', 'widths', 'header-rows', 'footer-rows']), caption, colAligns, toRows(0, headCount), bodies, footRows, shortCaption, Array.from({ length: width }, (_, c) => columns[c]?.width ?? null));
|
|
1472
|
+
toAttrWithout(ctx, n.attrs, ['aligns', 'valigns', 'widths', 'header-rows', 'footer-rows']), caption, colAligns, toRows(0, headCount), bodies, footRows, shortCaption, Array.from({ length: width }, (_, c) => columns[c]?.width ?? null));
|
|
1409
1473
|
}
|
|
1410
1474
|
/**
|
|
1411
1475
|
* The row-head column count a declared body's rows agree on, or 0.
|
|
@@ -1430,8 +1494,8 @@ function agreedLead(leadOf, rows, from, to, ctx, n) {
|
|
|
1430
1494
|
}
|
|
1431
1495
|
return first;
|
|
1432
1496
|
}
|
|
1433
|
-
function toAttrWithout(attrs, omitted) {
|
|
1434
|
-
const [id, classes, pairs] = toAttr(attrs);
|
|
1497
|
+
function toAttrWithout(ctx, attrs, omitted) {
|
|
1498
|
+
const [id, classes, pairs] = toAttr(ctx, attrs);
|
|
1435
1499
|
return [id, classes, pairs.filter(([key]) => !omitted.includes(key))];
|
|
1436
1500
|
}
|
|
1437
1501
|
// --- List tables (listTable extension semantics) ---
|
|
@@ -1520,7 +1584,7 @@ function listTableToTable(ctx, n) {
|
|
|
1520
1584
|
}
|
|
1521
1585
|
warn(ctx, `list-table: rowspan crossing the header/body boundary at row ${r + 1} - clipped`);
|
|
1522
1586
|
}
|
|
1523
|
-
const pc = P.cell(untight(ctx, () => blocks(ctx, cellBlocks)), 'AlignDefault', toAttr((cellItems[r][c].attrs ?? {})));
|
|
1587
|
+
const pc = P.cell(untight(ctx, () => blocks(ctx, cellBlocks)), 'AlignDefault', toAttr(ctx, (cellItems[r][c].attrs ?? {})));
|
|
1524
1588
|
origin[r][c] = { cell: pc, row: r, col: c };
|
|
1525
1589
|
emitted[r][c] = pc;
|
|
1526
1590
|
}
|
|
@@ -1533,7 +1597,7 @@ function listTableToTable(ctx, n) {
|
|
|
1533
1597
|
return out;
|
|
1534
1598
|
};
|
|
1535
1599
|
const head = Math.min(headerRows, grid.length);
|
|
1536
|
-
const [id, classes, kvs] = toAttr(a);
|
|
1600
|
+
const [id, classes, kvs] = toAttr(ctx, a);
|
|
1537
1601
|
const foot = Math.min(footerRows, Math.max(0, grid.length - head));
|
|
1538
1602
|
const bodyEnd = grid.length - foot;
|
|
1539
1603
|
const hasBareMarker = (item, key) => (item?.attrs ?? {}).keyValues?.[key] === '';
|
|
@@ -1620,13 +1684,13 @@ function figure(ctx, n) {
|
|
|
1620
1684
|
}
|
|
1621
1685
|
if (target.type === 'image') {
|
|
1622
1686
|
const img = inline(ctx, target);
|
|
1623
|
-
return [P.Figure(toAttr(n.attrs), caption, [P.Plain(img)], shortCaption)];
|
|
1687
|
+
return [P.Figure(toAttr(ctx, n.attrs), caption, [P.Plain(img)], shortCaption)];
|
|
1624
1688
|
}
|
|
1625
1689
|
// Any other captionable target, a QUOTE INCLUDED - this is the arm a
|
|
1626
1690
|
// captioned quote takes, at document level and as a §4c panel alike. It
|
|
1627
1691
|
// used to be unreachable for a quote outside a group, because §4a rerouted
|
|
1628
1692
|
// that case; nothing reroutes it now.
|
|
1629
|
-
return [P.Figure(toAttr(n.attrs), caption, untight(ctx, () => block(ctx, target)), shortCaption)];
|
|
1693
|
+
return [P.Figure(toAttr(ctx, n.attrs), caption, untight(ctx, () => block(ctx, target)), shortCaption)];
|
|
1630
1694
|
}
|
|
1631
1695
|
/** The §4c panels of a group: its `figure` and `table` children, in order. */
|
|
1632
1696
|
function isPanel(n) {
|
|
@@ -1677,7 +1741,7 @@ function figureGroup(ctx, n) {
|
|
|
1677
1741
|
ctx.inPanel = prev;
|
|
1678
1742
|
}
|
|
1679
1743
|
}));
|
|
1680
|
-
return [P.Figure(toAttr(n.attrs), caption, body)];
|
|
1744
|
+
return [P.Figure(toAttr(ctx, n.attrs), caption, body)];
|
|
1681
1745
|
}
|
|
1682
1746
|
// --- Metadata (frontmatter) ---
|
|
1683
1747
|
/**
|
package/dist/diagnostics.js
CHANGED
|
@@ -21,8 +21,13 @@ const RULES = [
|
|
|
21
21
|
{ test: /^definition list: looseness/, code: 'definition-list-looseness-widened', severity: 'normalized' },
|
|
22
22
|
{ test: /^definition list:/, code: 'definition-entry-skipped', severity: 'lossy' },
|
|
23
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' },
|
|
24
26
|
{ test: /^math: attributes/, code: 'math-attributes-dropped', severity: 'lossy' },
|
|
25
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' },
|
|
26
31
|
{ test: /^extension:/, code: 'inline-extension-degraded', severity: 'degraded' },
|
|
27
32
|
{ test: /missing definition/, code: 'reference-unresolved', severity: 'degraded' },
|
|
28
33
|
{ test: /^crossref:/, code: 'crossref-unresolved', severity: 'degraded' },
|
package/dist/reverse.js
CHANGED
|
@@ -617,10 +617,12 @@ function listItems(ctx, items) {
|
|
|
617
617
|
const converted = items.map((item) => {
|
|
618
618
|
let source = item;
|
|
619
619
|
let attrs;
|
|
620
|
+
let taskState = null;
|
|
620
621
|
if (item.length === 1 && item[0]?.t === 'Div') {
|
|
621
622
|
const [attr, inner] = item[0].c;
|
|
622
623
|
if (attr[2].some(([key, value]) => key === 'carve-list-item' && value === 'true')) {
|
|
623
|
-
|
|
624
|
+
taskState = readTaskState(attr[2]);
|
|
625
|
+
attrs = fromAttr([attr[0], attr[1], attr[2].filter(([key]) => key !== 'carve-list-item' && key !== TASK_STATE_KEY)]);
|
|
624
626
|
source = inner;
|
|
625
627
|
}
|
|
626
628
|
}
|
|
@@ -631,10 +633,55 @@ function listItems(ctx, items) {
|
|
|
631
633
|
if (attrs)
|
|
632
634
|
node.attrs = attrs;
|
|
633
635
|
stripTaskMarker(node);
|
|
636
|
+
// Only over a marker the forward direction actually wrote: the state
|
|
637
|
+
// pins `checked`, and PART 12 rejects an extended state on a checked
|
|
638
|
+
// item, so a hand-built envelope cannot make an unsatisfiable pair.
|
|
639
|
+
if (taskState && node.checked === false)
|
|
640
|
+
node.taskState = taskState;
|
|
641
|
+
if (ctx.target === 'source' && rendersEmpty(node.children))
|
|
642
|
+
spellEmptyItem(ctx, node);
|
|
634
643
|
return node;
|
|
635
644
|
});
|
|
636
645
|
return { items: converted, tight };
|
|
637
646
|
}
|
|
647
|
+
/**
|
|
648
|
+
* Whether an item would reach the writer with nothing to put after the marker.
|
|
649
|
+
*
|
|
650
|
+
* `[].every` is true, so an item pandoc gave no blocks at all is empty; so is
|
|
651
|
+
* one holding only paragraphs with no inlines, which is what a `☐`-only task
|
|
652
|
+
* item leaves behind once the marker is stripped.
|
|
653
|
+
*/
|
|
654
|
+
function rendersEmpty(children) {
|
|
655
|
+
return (children ?? []).every((child) => child.type === 'paragraph' && !child.children?.length);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Give an empty item the one spelling Carve has for invisible content.
|
|
659
|
+
*
|
|
660
|
+
* A content-less marker is NOT a list item - PART 9 is stricter than
|
|
661
|
+
* CommonMark here, and `spec/resources/examples/core.md` says so - so
|
|
662
|
+
* `renderCarve` writes a `+` after the marker rather than nothing. That `+`
|
|
663
|
+
* survives an ordered item as literal text: `1. +` comes back `<li>+</li>`
|
|
664
|
+
* where the source had `<li></li>` (#159). A comment is content the reader
|
|
665
|
+
* removes before any inline run, so `1. %%` is an item whose body is empty on
|
|
666
|
+
* both engines measured, and it is the same shape the corpus reaches through a
|
|
667
|
+
* definition line that gets collected out.
|
|
668
|
+
*
|
|
669
|
+
* Source path only. `pandocToCarveAst` hands the tree on whole, and an empty
|
|
670
|
+
* item is expressible there - inventing a comment would be a field the
|
|
671
|
+
* consumer never had.
|
|
672
|
+
*/
|
|
673
|
+
function spellEmptyItem(ctx, node) {
|
|
674
|
+
node.children = [{ type: 'comment', block: false, content: '' }];
|
|
675
|
+
warn(ctx, 'list: an empty item has no bare spelling in Carve - written as a comment, ' +
|
|
676
|
+
'which renders as the empty item it came from');
|
|
677
|
+
}
|
|
678
|
+
/** The roundtrip envelope's task-state slot; `src/convert.ts` writes it. */
|
|
679
|
+
const TASK_STATE_KEY = 'carve-task-state';
|
|
680
|
+
/** The extended states of PART 12's `taskState` enum, or null for anything else. */
|
|
681
|
+
function readTaskState(kvs) {
|
|
682
|
+
const value = kvs.find(([key]) => key === TASK_STATE_KEY)?.[1];
|
|
683
|
+
return value && ['-', '_', '>', '?'].includes(value) ? value : null;
|
|
684
|
+
}
|
|
638
685
|
/** Detect the ballot-box prefix convert.ts (and pandoc's gfm reader) emit. */
|
|
639
686
|
function stripTaskMarker(item) {
|
|
640
687
|
const first = item.children[0];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schemes Carve blanks on a link or image destination.
|
|
3
|
+
*
|
|
4
|
+
* The script / inline-content / local-file class, then the OS protocol-handler
|
|
5
|
+
* and command-execution class (the CVE-2026-20841 group). Legitimate schemes -
|
|
6
|
+
* `http`, `https`, `mailto`, `tel`, `ftp`, `sms` - and every scheme-less
|
|
7
|
+
* destination pass.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DANGEROUS_URL_SCHEMES: readonly string[];
|
|
10
|
+
/**
|
|
11
|
+
* Characters dropped before the scheme is read: every control character plus
|
|
12
|
+
* every Unicode whitespace character.
|
|
13
|
+
*
|
|
14
|
+
* A consumer may ignore any of them when it decides what the scheme is, so
|
|
15
|
+
* `<U+202F>javascript:` and `java<DEL>script:` have to fail the check the plain
|
|
16
|
+
* spelling fails. Corpus `121-scheme-probe-strips-unicode-whitespace` pins the
|
|
17
|
+
* first of those, and a `startsWith('javascript:')` check passes that document
|
|
18
|
+
* while leaving the hole wide open.
|
|
19
|
+
*/
|
|
20
|
+
export declare const SCHEME_PROBE_STRIP_RE: RegExp;
|
|
21
|
+
/** The scheme a consumer would read off `url`, lowercased, or undefined. */
|
|
22
|
+
export declare function probeScheme(url: string): string | undefined;
|
|
23
|
+
/** Whether `url` carries a scheme §25 denies. */
|
|
24
|
+
export declare function hasDeniedScheme(url: string): boolean;
|
|
25
|
+
/** `url`, or `''` when its scheme is denied. */
|
|
26
|
+
export declare function blankDeniedDestination(url: string): string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PART 9 §25 on the way out to pandoc.
|
|
3
|
+
*
|
|
4
|
+
* Carve's writers blank a destination whose scheme the spec denies, and §25
|
|
5
|
+
* binds "every target that emits a resolvable URL" - the engine's HTML,
|
|
6
|
+
* Markdown and ANSI writers all do it. This bridge emits resolvable URLs too,
|
|
7
|
+
* and `pandoc -f json -t html` is one command away, so a scheme passed through
|
|
8
|
+
* here is not a narrower policy, it is the same sink one step removed
|
|
9
|
+
* (markup-carve/pandoc-carve#157).
|
|
10
|
+
*
|
|
11
|
+
* THE LIST AND THE PROBE ARE MIRRORED FROM THE ENGINE, NOT INVENTED. The
|
|
12
|
+
* engine keeps them in `render-html.ts` and shares them with its non-HTML
|
|
13
|
+
* writers through `deny-listed-destination.ts`; its package export map reaches
|
|
14
|
+
* neither, so runtime code here cannot import them and the constants below are
|
|
15
|
+
* a restatement.
|
|
16
|
+
*
|
|
17
|
+
* test/url-scheme.test.mjs is what makes the restatement safe. It reads the
|
|
18
|
+
* engine's ACTUAL list and probe out of the installed package by `file:` URL -
|
|
19
|
+
* which the export map does not gate, and which a test may do where shipped
|
|
20
|
+
* code should not - and compares them as SETS, in both directions. So a scheme
|
|
21
|
+
* the engine ADDS goes red here too, which is the failure a check that iterated
|
|
22
|
+
* only these names could never see.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Schemes Carve blanks on a link or image destination.
|
|
26
|
+
*
|
|
27
|
+
* The script / inline-content / local-file class, then the OS protocol-handler
|
|
28
|
+
* and command-execution class (the CVE-2026-20841 group). Legitimate schemes -
|
|
29
|
+
* `http`, `https`, `mailto`, `tel`, `ftp`, `sms` - and every scheme-less
|
|
30
|
+
* destination pass.
|
|
31
|
+
*/
|
|
32
|
+
export const DANGEROUS_URL_SCHEMES = [
|
|
33
|
+
'javascript',
|
|
34
|
+
'vbscript',
|
|
35
|
+
'data',
|
|
36
|
+
'file',
|
|
37
|
+
'ms-msdt',
|
|
38
|
+
'ms-office',
|
|
39
|
+
'ms-word',
|
|
40
|
+
'ms-excel',
|
|
41
|
+
'ms-powerpoint',
|
|
42
|
+
'ms-access',
|
|
43
|
+
'ms-visio',
|
|
44
|
+
'ms-project',
|
|
45
|
+
'ms-publisher',
|
|
46
|
+
'ms-infopath',
|
|
47
|
+
'ms-spd',
|
|
48
|
+
'ms-search',
|
|
49
|
+
'search-ms',
|
|
50
|
+
'ms-cxh',
|
|
51
|
+
'ms-cxh-full',
|
|
52
|
+
'shell',
|
|
53
|
+
'vscode',
|
|
54
|
+
'vscode-insiders',
|
|
55
|
+
'jar',
|
|
56
|
+
];
|
|
57
|
+
/**
|
|
58
|
+
* Characters dropped before the scheme is read: every control character plus
|
|
59
|
+
* every Unicode whitespace character.
|
|
60
|
+
*
|
|
61
|
+
* A consumer may ignore any of them when it decides what the scheme is, so
|
|
62
|
+
* `<U+202F>javascript:` and `java<DEL>script:` have to fail the check the plain
|
|
63
|
+
* spelling fails. Corpus `121-scheme-probe-strips-unicode-whitespace` pins the
|
|
64
|
+
* first of those, and a `startsWith('javascript:')` check passes that document
|
|
65
|
+
* while leaving the hole wide open.
|
|
66
|
+
*/
|
|
67
|
+
export const SCHEME_PROBE_STRIP_RE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f\s]+/gu;
|
|
68
|
+
const DENIED = new Set(DANGEROUS_URL_SCHEMES.map((scheme) => scheme.toLowerCase()));
|
|
69
|
+
/** The scheme a consumer would read off `url`, lowercased, or undefined. */
|
|
70
|
+
export function probeScheme(url) {
|
|
71
|
+
const probe = url.replace(SCHEME_PROBE_STRIP_RE, '');
|
|
72
|
+
const match = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(probe);
|
|
73
|
+
return match?.[1]?.toLowerCase();
|
|
74
|
+
}
|
|
75
|
+
/** Whether `url` carries a scheme §25 denies. */
|
|
76
|
+
export function hasDeniedScheme(url) {
|
|
77
|
+
const scheme = probeScheme(url);
|
|
78
|
+
return scheme !== undefined && DENIED.has(scheme);
|
|
79
|
+
}
|
|
80
|
+
/** `url`, or `''` when its scheme is denied. */
|
|
81
|
+
export function blankDeniedDestination(url) {
|
|
82
|
+
return hasDeniedScheme(url) ? '' : url;
|
|
83
|
+
}
|
package/package.json
CHANGED