@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 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 = Object.entries(a.keyValues ?? {});
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))], [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 a = (n.attrs ?? {});
899
- const kvs = Object.entries(a.keyValues ?? {});
935
+ const [id, classes, kvs] = toAttr(ctx, n.attrs);
900
936
  return [
901
- P.CodeBlock(P.attr(a.id, [...lang, ...(a.classes ?? [])], kvs), String(n.content ?? '')),
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
- if (hasAttrs(item.attrs)) {
1016
- const [id, classes, kvs] = toAttr(item.attrs);
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
  /**
@@ -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
- attrs = fromAttr([attr[0], attr[1], attr[2].filter(([key]) => key !== 'carve-list-item')]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markup-carve/pandoc-carve",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Carve to Pandoc bridge: convert Carve markup to Pandoc's JSON AST and reach every pandoc output format (LaTeX, Typst, DOCX, PDF, ...)",
5
5
  "keywords": [
6
6
  "carve",