vantage-md 0.7.1 → 0.8.0

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/dist/react.cjs CHANGED
@@ -25,10 +25,6 @@ let react = require("react");
25
25
  react = __toESM(react, 1);
26
26
  let react_markdown = require("react-markdown");
27
27
  react_markdown = __toESM(react_markdown, 1);
28
- let remark_gfm = require("remark-gfm");
29
- remark_gfm = __toESM(remark_gfm, 1);
30
- let remark_math = require("remark-math");
31
- remark_math = __toESM(remark_math, 1);
32
28
  let rehype_raw = require("rehype-raw");
33
29
  rehype_raw = __toESM(rehype_raw, 1);
34
30
  let rehype_sanitize = require("rehype-sanitize");
@@ -40,13 +36,17 @@ rehype_katex = __toESM(rehype_katex, 1);
40
36
  let rehype_slug = require("rehype-slug");
41
37
  rehype_slug = __toESM(rehype_slug, 1);
42
38
  let unist_util_visit = require("unist-util-visit");
39
+ let remark_gfm = require("remark-gfm");
40
+ remark_gfm = __toESM(remark_gfm, 1);
41
+ let remark_math = require("remark-math");
42
+ remark_math = __toESM(remark_math, 1);
43
43
  let yaml = require("yaml");
44
44
  yaml = __toESM(yaml, 1);
45
45
  let smol_toml = require("smol-toml");
46
46
  let react_jsx_runtime = require("react/jsx-runtime");
47
- let unified = require("unified");
48
47
  let remark_parse = require("remark-parse");
49
48
  remark_parse = __toESM(remark_parse, 1);
49
+ let unified = require("unified");
50
50
  let remark_rehype = require("remark-rehype");
51
51
  remark_rehype = __toESM(remark_rehype, 1);
52
52
  let rehype_stringify = require("rehype-stringify");
@@ -96,21 +96,21 @@ const BLOCK_TAGS = /* @__PURE__ */ new Set([
96
96
  "hr",
97
97
  "div"
98
98
  ]);
99
- function visit$1(node, offset) {
99
+ function visit$2(node, offset) {
100
100
  if ("children" in node) {
101
101
  for (const child of node.children) if (child.type === "element") {
102
102
  if (BLOCK_TAGS.has(child.tagName) && child.position?.start?.line) {
103
103
  child.properties = child.properties || {};
104
104
  child.properties["dataSourceLine"] = child.position.start.line + offset;
105
105
  }
106
- visit$1(child, offset);
106
+ visit$2(child, offset);
107
107
  }
108
108
  }
109
109
  }
110
110
  const rehypeSourceLines = (options) => {
111
111
  const offset = options?.offset ?? 0;
112
112
  return (tree) => {
113
- visit$1(tree, offset);
113
+ visit$2(tree, offset);
114
114
  };
115
115
  };
116
116
  //#endregion
@@ -163,6 +163,12 @@ const VANTAGE_ALERTS = [
163
163
  "warning",
164
164
  "caution"
165
165
  ];
166
+ /**
167
+ * The class on the title element this plugin injects, which
168
+ * `styles/directives.css` styles. Exported because the sanitizer runs after
169
+ * this plugin and admits a `div`'s class only when it is this one.
170
+ */
171
+ const ALERT_TITLE_CLASS = "vantage-alert-title";
166
172
  /** The visible label per kind. Title case, as GitHub renders it. */
167
173
  const ALERT_TITLES = {
168
174
  note: "Note",
@@ -186,7 +192,7 @@ const ALERT_TITLES = {
186
192
  * anchored test on the first text node is enough, and the plugin does not have
187
193
  * to reassemble the marker across siblings.
188
194
  */
189
- const MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/;
195
+ const ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/;
190
196
  /** The first child, if it is an element. */
191
197
  function firstElement(node) {
192
198
  const child = node.children.find((c) => c.type === "element" || c.type === "text" && c.value.trim() !== "");
@@ -213,7 +219,7 @@ function rehypeVantageAlerts() {
213
219
  if (paragraph === void 0 || paragraph.tagName !== "p") return;
214
220
  const lead = paragraph.children[0];
215
221
  if (lead === void 0 || lead.type !== "text") return;
216
- const match = MARKER.exec(lead.value);
222
+ const match = ALERT_MARKER.exec(lead.value);
217
223
  if (match === null) return;
218
224
  const kind = match[1].toLowerCase();
219
225
  lead.value = lead.value.slice(match[0].length);
@@ -225,7 +231,7 @@ function rehypeVantageAlerts() {
225
231
  node.children.unshift({
226
232
  type: "element",
227
233
  tagName: "div",
228
- properties: { className: ["vantage-alert-title"] },
234
+ properties: { className: [ALERT_TITLE_CLASS] },
229
235
  children: [{
230
236
  type: "text",
231
237
  value: ALERT_TITLES[kind]
@@ -236,6 +242,10 @@ function rehypeVantageAlerts() {
236
242
  }
237
243
  //#endregion
238
244
  //#region src/vantageDirectives.ts
245
+ /** Whether `name` declares a question, `question` or the deprecated `oq`. */
246
+ function isQuestionDirective(name) {
247
+ return name === "oq" || name === "question";
248
+ }
239
249
  /**
240
250
  * The `tone` vocabulary: GitHub's alert words plus `muted`.
241
251
  *
@@ -328,13 +338,32 @@ const VANTAGE_STYLE_TARGETS = [
328
338
  "div"
329
339
  ];
330
340
  /**
331
- * The tags an `oq` directive may stamp — strictly the tags the review system
332
- * can resolve an anchor on (`ANCHOR_TAGS` in the app's `MarkdownViewer`, and the
333
- * block map in `useReviewHighlights`). `ul`, `ol`, `tr`, `hr` and `div` are in
334
- * neither, so a button on one of them would build an anchor no review pass can
335
- * find — the "mis-wired button" D6 forbids.
341
+ * The tags a `fallback` directive may **withhold**: `VANTAGE_STYLE_TARGETS`
342
+ * minus the six headings, written as a subtraction so the narrowing stays
343
+ * visible.
344
+ *
345
+ * A heading is refused because it is the document's structure rather than a
346
+ * stand-in for anything: the contents column lists it, other documents link to
347
+ * its slug, and the planning index reads sections under it. A fallback heading
348
+ * would be an anchor that exists on GitHub and nowhere in Vantage. What a
349
+ * fallback holds — a sentence naming the release a drawing needs, a link to
350
+ * the drawing as a file, an ASCII sketch in a fence — is a paragraph, a list,
351
+ * a quote, a code block, a table or a `<div>`, and those are all here.
352
+ *
353
+ * Three callers read it, and they must agree: `rehypeVantageDirectives`, which
354
+ * withholds the block; the planning index's scan, which must not read a
355
+ * question or a link from a block the page never shows; and the checker's
356
+ * `vantage/orphan`, which reports a fallback that withholds nothing.
357
+ */
358
+ const VANTAGE_FALLBACK_TARGETS = VANTAGE_STYLE_TARGETS.filter((tag) => !/^h[1-6]$/.test(tag));
359
+ /**
360
+ * The tags a question directive, `oq` or `question`, may stamp — strictly the
361
+ * tags the review system can resolve an anchor on (`ANCHOR_TAGS` in the app's
362
+ * `MarkdownViewer`, and the block map in `useReviewHighlights`). `ul`, `ol`,
363
+ * `tr`, `hr` and `div` are in neither, so a button on one of them would build
364
+ * an anchor no review pass can find — the "mis-wired button" D6 forbids.
336
365
  *
337
- * The gap between this list and `VANTAGE_STYLE_TARGETS` is why an `oq`
366
+ * The gap between this list and `VANTAGE_STYLE_TARGETS` is why a question
338
367
  * directive at column 0 above a list silently does nothing: the target is the
339
368
  * `<ul>`, not the `<li>`. The checker says so.
340
369
  */
@@ -353,9 +382,9 @@ const VANTAGE_ANCHOR_TARGETS = [
353
382
  ];
354
383
  VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
355
384
  /**
356
- * The shape of an `oq` directive's `id`: `OQ-` then an optional short uppercase
357
- * prefix then digits. `OQ-9`, `OQ-TP6` and `OQ-A03` are ids; `OQ-foo`, `OQ-tp6`
358
- * and a bare `OQ6` are not.
385
+ * The shape of a question directive's `id`, on `oq` and `question` alike:
386
+ * `OQ-` then an optional short uppercase prefix then digits. `OQ-9`, `OQ-TP6`
387
+ * and `OQ-A03` are ids; `OQ-foo`, `OQ-tp6` and a bare `OQ6` are not.
359
388
  *
360
389
  * The prefix is what keeps ids distinct once one document references another's
361
390
  * questions — `trust-paths.md`'s `OQ-4` and a design sketch's `OQ-4` are
@@ -363,10 +392,15 @@ VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
363
392
  * reference means. It is optional because most documents never leave their own
364
393
  * file, and requiring it everywhere would fire on every single-doc sketch.
365
394
  *
366
- * Three consumers read it from here and none of them may re-spell it: the
367
- * plugin that stamps the anchor, the sanitizer that allowlists the value, and
368
- * the checker's `vantage/oq-id-format`. A fourth copy is how the checker starts
369
- * calling a working anchor malformed.
395
+ * Read from here, never re-spelled, by the sanitizer that allowlists the value,
396
+ * by the checker's `collectOqIds` (which feeds `vantage/oq-id-format` and the
397
+ * anchors the checker accepts) and by the planning index. The directive plugin
398
+ * that stamps the anchor deliberately does not test it: it stamps any non-empty
399
+ * id and leaves refusing a malformed one to the sanitizer. A re-spelled copy is
400
+ * how the checker starts calling a working anchor malformed — and the patterns
401
+ * that look for an id in text (`OQ_REFERENCE` and `OQ_EXACT` in the checker's
402
+ * `rules/references.ts`, `OQ_TOKEN` in `planning/scan.ts` and in
403
+ * `FrontmatterDisplay.tsx`) restate this shape, so they change with it.
370
404
  */
371
405
  const VANTAGE_OQ_ID = /^OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+$/;
372
406
  const STYLE_KEYS = {
@@ -379,10 +413,21 @@ const STYLE_KEYS = {
379
413
  * Name → key → the closed value set for that key.
380
414
  *
381
415
  * `section` and `block` share their keys: they differ in *extent*, not in what
382
- * they can say. `oq`'s two keys are the design's only values with no closed set
383
- * — `id` is a token an author chose and `leaning` is a sentence (§8.3) — so
384
- * neither can be value-allowlisted, which is recorded here as `null` rather
385
- * than left to a caller to guess.
416
+ * they can say. The two question names share theirs too, and they are the
417
+ * design's only values with no closed set — `id` is a token an author chose
418
+ * and `leaning` is a sentence (§8.3) — so neither can be value-allowlisted,
419
+ * which is recorded here as `null` rather than left to a caller to guess.
420
+ *
421
+ * `question` takes exactly `oq`'s keys and nothing more. Starting narrow is
422
+ * the forward-compatible choice: a key added later is one older viewers drop
423
+ * pair by pair, while a key accepted now could never be given a meaning (P0).
424
+ * A `leaning` on a 🔒 or ✅ question is allowed and simply not offered
425
+ * (`questionOffersTake`), so changing a question's state changes only its
426
+ * marker.
427
+ *
428
+ * `fallback` takes no keys: whether a block is withheld is the whole of what
429
+ * it says, and a key added later is dropped pair by pair by this release, as
430
+ * D2 drops any other.
386
431
  */
387
432
  const DIRECTIVE_VOCABULARY = {
388
433
  section: STYLE_KEYS,
@@ -390,9 +435,59 @@ const DIRECTIVE_VOCABULARY = {
390
435
  oq: {
391
436
  id: null,
392
437
  leaning: null
393
- }
438
+ },
439
+ question: {
440
+ id: null,
441
+ leaning: null
442
+ },
443
+ fallback: {}
394
444
  };
395
445
  /**
446
+ * Merge the question directives of one run — the comments up to the block they
447
+ * land on — into the one question they declare, or `undefined` when the run
448
+ * holds none. Directives of any other name are skipped.
449
+ *
450
+ * A run holding an `oq` is read exactly as a viewer that predates `question`
451
+ * reads it: that viewer drops every `question` and reads the `oq` alone, so
452
+ * the `oq` wins wherever it appears in the run, and only its keys apply — a
453
+ * key only a `question` in the same run sets applies nowhere, in no release.
454
+ * That is the one reading every viewer agrees on (`VANTAGE_QUESTION_NAMES`),
455
+ * so the same bytes file the same Take in 0.7 and in 0.8. Within one name, the
456
+ * last value of a key wins, as `section` and `block` resolve theirs. A pair
457
+ * its name does not accept, or a value outside a closed set, is dropped (D2).
458
+ *
459
+ * One function for every reader of a run: the plugin that stamps the question
460
+ * and the planning index that reads it, which the checker reads a question
461
+ * through, so none can disagree about which leaning or which id a run carries
462
+ * (D5).
463
+ */
464
+ function mergeQuestionRun(run) {
465
+ let name;
466
+ for (const directive of run) {
467
+ if (!isQuestionDirective(directive.name)) continue;
468
+ if (name !== "oq") name = directive.name;
469
+ }
470
+ if (name === void 0) return void 0;
471
+ const accepted = DIRECTIVE_VOCABULARY[name] ?? {};
472
+ const keys = /* @__PURE__ */ new Map();
473
+ const sources = /* @__PURE__ */ new Map();
474
+ run.forEach((directive, index) => {
475
+ if (directive.name !== name) return;
476
+ for (const pair of directive.pairs) {
477
+ const values = accepted[pair.key];
478
+ if (values === void 0) continue;
479
+ if (values !== null && !values.includes(pair.value)) continue;
480
+ keys.set(pair.key, pair.value);
481
+ sources.set(pair.key, index);
482
+ }
483
+ });
484
+ return {
485
+ name,
486
+ keys,
487
+ sources
488
+ };
489
+ }
490
+ /**
396
491
  * `ws` is `[ \t\r\n]` — the design's grammar leaves it undefined, and `\n` has
397
492
  * to be in the set because a directive may legally wrap: a multi-line comment
398
493
  * is one node whose value contains the newlines.
@@ -498,10 +593,24 @@ function parseVantageDirective(comment) {
498
593
  pairs
499
594
  };
500
595
  }
596
+ /**
597
+ * A question directive's `leaning=` value as the page carries it: whitespace
598
+ * runs collapsed to one space, trimmed, and capped at `MAX_LEANING`.
599
+ *
600
+ * A wrapped directive puts newlines and indentation into the value. Two readers
601
+ * need the result and must agree on it: `rehypeVantageDirectives`, which stamps
602
+ * it as `data-vantage-leaning` for the one-click answer, and the planning index,
603
+ * which reports the leaning a question carries. An empty result means the
604
+ * question states no leaning.
605
+ */
606
+ function normalizeLeaning(raw) {
607
+ return raw.replace(/\s+/g, " ").trim().slice(0, 500);
608
+ }
501
609
  //#endregion
502
610
  //#region src/rehypeVantageDirectives.ts
503
611
  /**
504
- * What a `section`/`block` and an `oq` directive may **target**.
612
+ * What a `section`/`block` and a question directive (`oq`, `question`) may
613
+ * **target**.
505
614
  *
506
615
  * Both lists live in `vantageDirectives.ts`, with the reasoning for each tag,
507
616
  * because the CLI checker resolves the same question over mdast and must reach
@@ -511,6 +620,8 @@ function parseVantageDirective(comment) {
511
620
  */
512
621
  const STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
513
622
  const ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
623
+ /** What a `fallback` may withhold: every style target but a heading. */
624
+ const FALLBACK_TARGET_TAGS = new Set(VANTAGE_FALLBACK_TARGETS);
514
625
  const HEADING_DEPTHS = /* @__PURE__ */ new Map([
515
626
  ["h1", 1],
516
627
  ["h2", 2],
@@ -551,6 +662,19 @@ const RANGE_PROPERTIES = /* @__PURE__ */ new Map([["tone", "dataVantageTone"], [
551
662
  */
552
663
  const POINT_PROPERTIES = /* @__PURE__ */ new Map([["badge", "dataVantageBadge"]]);
553
664
  const RUN_PROPERTY = "dataVantageRun";
665
+ /**
666
+ * What a question stamps, whichever name declared it: `data-vantage-question`
667
+ * on every question, and `data-vantage-oq` besides on one an `oq` declared
668
+ * (`VANTAGE_QUESTION_ATTRIBUTE`, `VANTAGE_OQ_ATTRIBUTE`).
669
+ *
670
+ * Two attributes, and neither one ever means more than it says. Every release
671
+ * since 0.7 stamps `data-vantage-oq` on an `oq` and on nothing else, so a
672
+ * reader of this package's markup that has ever read it keeps reading exactly
673
+ * what it read; `data-vantage-question` is the one to read for "a question",
674
+ * and the question's state, from its marker, is what decides whether anything
675
+ * offers to answer it (`questionOffersTake`).
676
+ */
677
+ const QUESTION_PROPERTY = "dataVantageQuestion";
554
678
  const OQ_PROPERTY = "dataVantageOq";
555
679
  const LEANING_PROPERTY = "dataVantageLeaning";
556
680
  /**
@@ -585,8 +709,6 @@ const OQ_ID_PROPERTY = "dataVantageOqId";
585
709
  const COLLAPSED_PROPERTY = "dataVantageCollapsed";
586
710
  const COLLAPSE_GROUP_PROPERTY = "dataVantageCollapseGroup";
587
711
  const COLLAPSE_TOGGLE_PROPERTY = "dataVantageCollapseToggle";
588
- /** A review-comment body, not prose. Bounds the attribute; 500 is generous. */
589
- const MAX_LEANING = 500;
590
712
  /**
591
713
  * Nodes that may sit between a directive and its target.
592
714
  *
@@ -720,13 +842,14 @@ function stampStyle(children, targetIndex, name, pairs, state) {
720
842
  }
721
843
  }
722
844
  }
723
- function stampOq(target, pairs) {
724
- setProperty(target, OQ_PROPERTY, "true");
845
+ function stampQuestion(target, name, pairs) {
846
+ setProperty(target, QUESTION_PROPERTY, "true");
847
+ if (name === "oq") setProperty(target, OQ_PROPERTY, "true");
725
848
  const id = pairs.get("id");
726
849
  if (id !== void 0 && id !== "") setProperty(target, OQ_ID_PROPERTY, id);
727
850
  const leaning = pairs.get("leaning");
728
851
  if (leaning === void 0) return;
729
- const text = leaning.replace(/\s+/g, " ").trim().slice(0, MAX_LEANING);
852
+ const text = normalizeLeaning(leaning);
730
853
  if (text !== "") setProperty(target, LEANING_PROPERTY, text);
731
854
  }
732
855
  /**
@@ -741,18 +864,14 @@ function stampOq(target, pairs) {
741
864
  function stampRun(children, targetIndex, run, state) {
742
865
  const target = children[targetIndex];
743
866
  const style = /* @__PURE__ */ new Map();
744
- const oq = /* @__PURE__ */ new Map();
745
867
  let styleName;
746
- let hasOq = false;
747
868
  for (const directive of run) if (directive.name === "section" || directive.name === "block") {
748
869
  styleName = directive.name;
749
870
  for (const pair of directive.pairs) style.set(pair.key, pair.value);
750
- } else if (directive.name === "oq") {
751
- hasOq = true;
752
- for (const pair of directive.pairs) oq.set(pair.key, pair.value);
753
871
  }
754
872
  if (styleName !== void 0) stampStyle(children, targetIndex, styleName, style, state);
755
- if (hasOq && ANCHOR_TARGET_TAGS.has(target.tagName)) stampOq(target, oq);
873
+ const question = mergeQuestionRun(run);
874
+ if (question !== void 0 && ANCHOR_TARGET_TAGS.has(target.tagName)) stampQuestion(target, question.name, question.keys);
756
875
  }
757
876
  /** A directive comment, or `undefined` for anything else — malformed included. */
758
877
  function directiveOf(node) {
@@ -765,7 +884,7 @@ function directiveOf(node) {
765
884
  *
766
885
  * The whole tree, not just the root: `rehype-raw` leaves comment nodes inside
767
886
  * `blockquote`, inside `li`, inside `td` and inline inside `p`, and the real
768
- * Open Questions layout puts the `oq` directive inside a list item — so a
887
+ * Open Questions layout puts the question directive inside a list item — so a
769
888
  * root-only walk finds none of them.
770
889
  *
771
890
  * Pass order is also what resolves a nested section: an inner heading's
@@ -804,8 +923,66 @@ function processChildren(parent, state) {
804
923
  i = j;
805
924
  }
806
925
  }
926
+ /**
927
+ * Remove every block a `fallback` run lands on, with the run itself.
928
+ *
929
+ * A fallback block is written for the renderers that cannot show something
930
+ * Vantage shows — "This drawing needs Vantage 0.8 or later", beside an inline
931
+ * `<svg>`. Every renderer that does not know the name drops the comment and
932
+ * shows the block; this one never renders it, in any mode, in print, or
933
+ * through any of the renderers that share this plugin (D5).
934
+ *
935
+ * The run is found exactly as `processChildren` finds one — the same skip
936
+ * rules, the same first-element target, the same whole-tree walk — and it
937
+ * withholds when any directive in it is a `fallback` and the target is on
938
+ * `VANTAGE_FALLBACK_TARGETS`. Anything else is left for the stamping pass,
939
+ * where a `fallback` is inert.
940
+ *
941
+ * A pass of its own, and first, because a `section` reaches *forward* over its
942
+ * siblings: stamped in one pass, a toned section would count a fallback block
943
+ * after it among its members, give it the run's last `end` marker, and leave
944
+ * the last block anyone sees marked `middle`. Withheld first, the block was
945
+ * never there for any later decision — the section's extent, its run markers,
946
+ * a collapse group's members, a review anchor or an outline entry.
947
+ */
948
+ function withholdFallbacks(parent) {
949
+ const children = parent.children;
950
+ let i = 0;
951
+ while (i < children.length) {
952
+ const node = children[i];
953
+ if (node.type === "element") {
954
+ withholdFallbacks(node);
955
+ i++;
956
+ continue;
957
+ }
958
+ const first = directiveOf(node);
959
+ if (first === void 0) {
960
+ i++;
961
+ continue;
962
+ }
963
+ let withholds = first.name === "fallback";
964
+ let j = i + 1;
965
+ let targetIndex = -1;
966
+ for (; j < children.length; j++) {
967
+ const next = children[j];
968
+ if (next.type === "element") {
969
+ targetIndex = j;
970
+ break;
971
+ }
972
+ if (!isSkippable(next)) break;
973
+ if (directiveOf(next)?.name === "fallback") withholds = true;
974
+ }
975
+ const target = targetIndex >= 0 ? children[targetIndex] : void 0;
976
+ if (withholds && target?.type === "element" && FALLBACK_TARGET_TAGS.has(target.tagName)) {
977
+ children.splice(i, targetIndex - i + 1);
978
+ continue;
979
+ }
980
+ i = j;
981
+ }
982
+ }
807
983
  const rehypeVantageDirectives = () => {
808
984
  return (tree) => {
985
+ withholdFallbacks(tree);
809
986
  processChildren(tree, { nextGroup: 1 });
810
987
  };
811
988
  };
@@ -898,12 +1075,144 @@ const rehypeRestoreMathStamps = () => {
898
1075
  };
899
1076
  //#endregion
900
1077
  //#region src/sanitize.ts
1078
+ /** Whether an attribute definition is for `className`. */
1079
+ const isClassName = (definition) => (typeof definition === "string" ? definition : definition[0]) === "className";
1080
+ /**
1081
+ * The value list the default schema gives `className` on `tag`, or an empty
1082
+ * list if it gives none.
1083
+ */
1084
+ function defaultClasses(tag) {
1085
+ const entry = rehype_sanitize.defaultSchema.attributes?.[tag]?.find(isClassName);
1086
+ return Array.isArray(entry) ? entry.slice(1) : [];
1087
+ }
1088
+ /**
1089
+ * The only classes an element keeps: the ones the pipeline itself emits on it
1090
+ * before `rehypeSanitize` runs. Every other element keeps none.
1091
+ *
1092
+ * **A class is a style by another name.** The app ships Tailwind's utility CSS
1093
+ * for its own markup, so `<div class="fixed inset-0 z-50 bg-white">` in a
1094
+ * document laid a white sheet over the whole Vantage window, header and
1095
+ * sidebar included. That is the overlay `SAFE_STYLE` bans `position` to
1096
+ * prevent, spelled as a class instead of a declaration, and `className` used
1097
+ * to be admitted with any value on every element. A document could also
1098
+ * borrow a class the app's own code looks for: `.overflow-y-auto` is how an
1099
+ * anchor link finds the element to scroll when no `data-content-scroll` is in
1100
+ * reach, and `.heading-anchor` is what the outline strips from a heading's
1101
+ * text. The comment-body sanitizer (`frontend/src/lib/commentMarkdown.ts`)
1102
+ * already refused `class` for the same reason.
1103
+ *
1104
+ * What each entry is for, and who emits it:
1105
+ *
1106
+ * - `code`: `language-*`, from a fence's info string (`mdast-util-to-hast`).
1107
+ * `rehype-highlight` reads it, `rehype-katex` keys on `language-math`, and
1108
+ * the viewers render `language-mermaid` as a diagram.
1109
+ * - `ul`, `ol` and `li`: `contains-task-list` and `task-list-item`, from GFM
1110
+ * task lists. `styles/task-list.css` draws the checkbox from them.
1111
+ * - `section`, `h2` and `a`: GFM footnotes (`mdast-util-to-hast`), on the
1112
+ * section, its label and each back-reference.
1113
+ * - `div`: `ALERT_TITLE_CLASS`, on the title `rehypeVantageAlerts` injects.
1114
+ *
1115
+ * `remark-math` also emits `math-display` and `math-inline` on its `code`.
1116
+ * They were never kept, because the default schema's `code` entry takes only
1117
+ * `language-*`, and nothing needs them: `rehype-katex` keys on `language-math`
1118
+ * and tells display from inline by whether the `code` sits in a `pre`.
1119
+ *
1120
+ * **A class added after the sanitizer needs no entry**, and that is most of
1121
+ * the classes the page styles: `rehype-highlight`'s `hljs-*`, KaTeX's output,
1122
+ * Mermaid's diagrams, and everything the app's components and hooks add. A
1123
+ * document that writes one of those on an element keeps nothing.
1124
+ *
1125
+ * **Mermaid source is the route this list cannot close.** Mermaid renders
1126
+ * after the sanitizer and copies the names a diagram's source gives a node
1127
+ * (`:::name`, `class A name`, `classDef`) onto that node, so a diagram can
1128
+ * carry any of the app's utilities on its own nodes. The diagram bounds it:
1129
+ * `position` does nothing on an SVG group, and the diagram's `svg` clips what
1130
+ * is inside it. `frontend/e2e/mermaid.spec.ts` measures that bound. The
1131
+ * stylesheet a diagram's source could write did reach past the diagram, and
1132
+ * `MERMAID_SECURE_KEYS` in `mermaidLoader.ts` refuses the settings that write
1133
+ * it.
1134
+ *
1135
+ * A document may still write these names on these elements. By the time the
1136
+ * sanitizer runs, a hand-written `<li class="task-list-item">` and the one GFM
1137
+ * emitted are the same node, and none of the names can lay an element over
1138
+ * anything outside the document's scroll container. `styles/task-list.css`
1139
+ * floats the checkbox of a `task-list-item` instead of positioning it
1140
+ * absolutely, so it stays in the document's flow whatever the item's own
1141
+ * `style` does, and the checkbox takes no `style` itself (see
1142
+ * `UNSTYLED_TAGS`). The footnote label's class, where a stylesheet defines it,
1143
+ * clips its element to a single pixel.
1144
+ *
1145
+ * **The footnote label's class is read from the default schema, never
1146
+ * spelled here, and the tests spell it in halves.** It is a Tailwind utility,
1147
+ * and Tailwind generates a utility for every class name it finds in the files
1148
+ * it scans, comments included — this directory and all of `frontend/`. The app
1149
+ * renders the label as a visible heading only because nothing it scans spells
1150
+ * that name. Spelled whole in a test, it generated the rule, and the label
1151
+ * would have been hidden from sight as it is on GitHub: a change of rendering,
1152
+ * not a sanitizer fix.
1153
+ *
1154
+ * The list is measured, not remembered. The test "takes no class from
1155
+ * anything the pipeline emits" in `frontend/src/lib/sanitize.test.ts` records
1156
+ * every class on the tree on both sides of `rehypeSanitize`, over a document
1157
+ * that uses every feature that emits one, so a plugin that starts emitting a
1158
+ * new class fails there instead of losing it in every renderer.
1159
+ */
1160
+ const PIPELINE_CLASSES = {
1161
+ code: [/^language-./],
1162
+ ul: ["contains-task-list"],
1163
+ ol: ["contains-task-list"],
1164
+ li: ["task-list-item"],
1165
+ section: ["footnotes"],
1166
+ h2: defaultClasses("h2"),
1167
+ a: ["data-footnote-backref"],
1168
+ div: [ALERT_TITLE_CLASS]
1169
+ };
901
1170
  /**
902
- * Sanitization schema for the rendering pipeline.
903
- * Allows GFM, KaTeX MathML, syntax highlighting classes, and
904
- * data-source-line attributes while blocking XSS vectors.
905
- */
906
- const DECLARATION = `(?:(?:${[
1171
+ * `tag`'s attributes: the default schema's, less any `className`, then its
1172
+ * `PIPELINE_CLASSES` entry, then `extra`.
1173
+ *
1174
+ * **Always a value list, never a bare `"className"`.** `hast-util-sanitize`
1175
+ * reads a bare name, or a tuple holding no values, as "any value", and an
1176
+ * element's own entry is consulted before `*`'s — the first match wins, and
1177
+ * `*` is not consulted when that element's own entry filters a class list down
1178
+ * to nothing. An element with no entry has none added.
1179
+ */
1180
+ function withClasses(tag, ...extra) {
1181
+ const classes = PIPELINE_CLASSES[tag] ?? [];
1182
+ const own = (rehype_sanitize.defaultSchema.attributes?.[tag] ?? []).filter((definition) => !isClassName(definition));
1183
+ if (classes.length > 0) own.push(["className", ...classes]);
1184
+ return [...own, ...extra];
1185
+ }
1186
+ /**
1187
+ * CSS properties an inline `style` may set.
1188
+ *
1189
+ * **The only `style` this list ever filters is one a document wrote by hand.**
1190
+ * Nothing the pipeline generates reaches it: `rehypeKatex` and `rehypeHighlight`
1191
+ * both run *after* `rehypeSanitize` (`pipeline.ts`), so their output is trusted
1192
+ * rather than filtered, and `remark-gfm` emits table alignment as an `align`
1193
+ * attribute rather than as CSS. So this is a filter on untrusted author HTML and
1194
+ * nothing else — which is exactly the hole that made it necessary: `<div
1195
+ * style="position:fixed;inset:0">` covered the viewport and
1196
+ * `style="background:url(https://…)"` called home on render, both verbatim,
1197
+ * because `rehype-sanitize` does not parse CSS. Scripts were never the risk
1198
+ * here; layout and network were.
1199
+ *
1200
+ * The list is therefore deliberately typographic: the styling a prose document
1201
+ * has any business asking for. It is *not* sized to KaTeX, and a KaTeX release
1202
+ * that starts using a new property is a non-event here — `\pmb` already emits
1203
+ * `text-shadow`, which is not on this list and renders anyway.
1204
+ *
1205
+ * **The design doc used to argue the opposite — that `style` had to be allowed
1206
+ * and `position` enumerated because KaTeX needs them — and it was wrong.** The
1207
+ * measurement behind it was real (KaTeX does emit `position:relative` on every
1208
+ * integral) but the inference was not, because the sanitizer has finished before
1209
+ * the first KaTeX span exists. Rebuilding the shipped rehype order with a filter
1210
+ * that rejects *every* value leaves all ten of the integral's style attributes
1211
+ * untouched. The "Security" section of `docs/reference/inline-markup.md`
1212
+ * records the correction; the test that would catch a reordering is in
1213
+ * `frontend/src/lib/sanitize.test.ts`.
1214
+ */
1215
+ const SAFE_STYLE_PROPERTIES = [
907
1216
  "height",
908
1217
  "min-height",
909
1218
  "max-height",
@@ -958,12 +1267,89 @@ const DECLARATION = `(?:(?:${[
958
1267
  "white-space",
959
1268
  "vertical-align",
960
1269
  "list-style-type",
961
- "display",
962
1270
  "float",
963
1271
  "clear",
964
1272
  "opacity",
965
1273
  "overflow"
966
- ].join("|")})\\s*:[^;:()"'\\\\]*)`;
1274
+ ];
1275
+ /**
1276
+ * A `style` value we will keep, as a whole.
1277
+ *
1278
+ * One rule beyond the property list does the work: **no parentheses anywhere**,
1279
+ * which closes `url(…)` and `expression(…)` in one stroke — the network and
1280
+ * legacy-script vectors. Its cost is borne entirely by authors, who lose
1281
+ * `calc()`, `rgb()` and `var()` along with them; that is the trade, and it is
1282
+ * worth it for a filter this small.
1283
+ *
1284
+ * **`position` is not on the property list at all**, so every value of it is
1285
+ * refused — `static` and `relative` along with `fixed` and `sticky`. It used to
1286
+ * be enumerated, on the belief that KaTeX needed `relative`; KaTeX renders after
1287
+ * the sanitizer and never meets this regex, so the enumeration was buying
1288
+ * nothing but the residual it conceded. Banning the property closes that
1289
+ * residual, and it is bigger than "overlaps its neighbors" made it sound:
1290
+ * measured in Chrome against the viewer's real ancestor chain, an author's
1291
+ * `position:absolute;top:0;left:0;width:100%;height:100%` is sized to the whole
1292
+ * content pane (the nearest positioned ancestor is outside the scroll
1293
+ * container), is not clipped by the scroller, and survives scrolling to the end
1294
+ * of the document. It was `position:fixed` in all but the keyword.
1295
+ *
1296
+ * Matching is all-or-nothing: one unrecognized declaration drops the whole
1297
+ * attribute, and the element renders unstyled rather than partly styled. That
1298
+ * is the safe direction to fail, and it degrades to plain text rather than to a
1299
+ * broken page.
1300
+ *
1301
+ * **The grammar must stay unambiguous, and `;` is what keeps it so.** The value
1302
+ * class is "anything but the delimiters", which includes whitespace — a value
1303
+ * legitimately contains it (`margin: 0 auto`). So if whitespace could *also*
1304
+ * end a declaration, both constructs would compete for the same characters and
1305
+ * the match would fork at every declaration; on a value that ultimately fails,
1306
+ * the engine explores every fork. An earlier form of this regex separated
1307
+ * declarations with `\s*;?\s*`, and 200 document-controlled characters took the
1308
+ * renderer — and the CLI checker, and therefore CI — 94 seconds. Requiring `;`
1309
+ * pins each declaration's extent to the delimiter positions, so there is exactly
1310
+ * one way to parse any input and rejection is linear. `VALUE` absorbs the
1311
+ * padding on both sides for the same reason: a separate `\\s*` next to it would
1312
+ * put the ambiguity straight back. Pinned by the flat-time test in
1313
+ * `frontend/src/lib/sanitize.test.ts` — do not loosen the separator.
1314
+ *
1315
+ * Residual, stated plainly and now genuinely small: negative `margin` still lets
1316
+ * an element overlap its neighbors *inside the flow*. That one scrolls with the
1317
+ * content and is clipped by the scroll container, and closing it means giving up
1318
+ * margins, which prose actually uses. Containment in the stylesheet, not another
1319
+ * rule here, is what would close it. `overflow: visible` on an inline `<svg>` is
1320
+ * the same residual by another route — see `SVG_ROOT_ATTRIBUTES`.
1321
+ *
1322
+ * **The app's table of contents is inside that scroll container too**, beside
1323
+ * the document column, so a block carried over it took its clicks whenever the
1324
+ * block painted in the positioned layer: one with `opacity`, a toned block, a
1325
+ * heading, or a link to anywhere. The contents sit at `z-10` for that
1326
+ * (`frontend/src/components/TableOfContents.tsx`), and a document cannot climb
1327
+ * back over them only because `z-index` is not on the property list.
1328
+ */
1329
+ const VALUE = `[^;:()"'\\\\]*`;
1330
+ const DISPLAY_DECLARATION = `display\\s*:\\s*(?:${[
1331
+ "none",
1332
+ "block",
1333
+ "inline-block",
1334
+ "inline-flex",
1335
+ "inline-grid",
1336
+ "inline-table",
1337
+ "inline",
1338
+ "flow-root",
1339
+ "flex",
1340
+ "grid",
1341
+ "table-row-group",
1342
+ "table-row",
1343
+ "table-header-group",
1344
+ "table-footer-group",
1345
+ "table-cell",
1346
+ "table-column-group",
1347
+ "table-column",
1348
+ "table-caption",
1349
+ "table",
1350
+ "list-item"
1351
+ ].join("|")})\\s*(?:!\\s*important\\s*)?`;
1352
+ const DECLARATION = `(?:(?:${SAFE_STYLE_PROPERTIES.join("|")})\\s*:${VALUE}|${DISPLAY_DECLARATION})`;
967
1353
  const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "i");
968
1354
  /**
969
1355
  * A collapse group id: one or more digits, anchored.
@@ -975,6 +1361,281 @@ const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "
975
1361
  */
976
1362
  const COLLAPSE_GROUP_ID = /^[0-9]+$/;
977
1363
  /**
1364
+ * Inline SVG, admitted as static drawing and nothing else.
1365
+ *
1366
+ * The allowlist is shapes, text and grouping. Everything in SVG that can run
1367
+ * code, fetch, or reach outside the element is refused: `script`,
1368
+ * `foreignObject`, `use` and `image` (both take an `href`), `xlink:href` on
1369
+ * `a`, the `animate`/`set` family (which can rewrite an `href` after the
1370
+ * sanitizer has looked), `style` elements, and every attribute that takes a
1371
+ * `url(…)` reference — `filter`, `mask`, `clip-path`, `marker-*`, `cursor`.
1372
+ *
1373
+ * Gradients, patterns and `<defs>` are out for a second reason: they are only
1374
+ * reachable through `url(#id)`, and the sanitizer prefixes every `id` with
1375
+ * `user-content-`, so the reference would dangle even if it were allowed.
1376
+ *
1377
+ * **Refusing an element is not the same as removing it.** `hast-util-sanitize`
1378
+ * *unwraps* an element it does not allow — drops the tag and keeps its
1379
+ * children — unless the tag is on `strip`. See `SVG_STRIPPED` for why that
1380
+ * matters here, and for the pass that removes those containers instead.
1381
+ *
1382
+ * `switch` is admitted, as a plain container, because draw.io wraps every label
1383
+ * in one: `<switch><foreignObject>…</foreignObject><text>…</text></switch>`. A
1384
+ * `switch` renders only its first child whose conditions hold, and with the
1385
+ * `foreignObject` removed and the conditional attributes (`requiredFeatures`,
1386
+ * `systemLanguage`) refused, that is the `<text>` fallback. Unwrapped instead,
1387
+ * it would render *every* branch — including the "Text is not SVG - cannot
1388
+ * display" notice draw.io appends for viewers without `foreignObject`.
1389
+ *
1390
+ * Every child requires an `svg` ancestor, so no shape or text element is
1391
+ * admitted into HTML flow on its own.
1392
+ *
1393
+ * `title` and `desc` are not children at all. They are admitted nowhere, inside
1394
+ * a drawing they are removed with their contents (see `SVG_STRIPPED`), and
1395
+ * `aria-label` on the root is the drawing's accessible name. Both are HTML
1396
+ * integration points — the parser reads what is inside them as HTML — and an
1397
+ * `svg` ancestor turned out not to be enough to keep a `<title>` in SVG
1398
+ * context. `<math><svg><title>` keeps MathML context, and React hoists any
1399
+ * `title` outside SVG context into the page's `<head>`, so the document set the
1400
+ * tab title. In `renderMarkdown`'s string output, `<svg><desc><title>`,
1401
+ * `<svg><title><title>` and a `title` inside a `foreignObject` all re-parse into
1402
+ * an HTML `title`, which is what `document.title` reads on a page that has none
1403
+ * of its own.
1404
+ */
1405
+ const SVG_CHILD_TAGS = [
1406
+ "g",
1407
+ "path",
1408
+ "rect",
1409
+ "circle",
1410
+ "ellipse",
1411
+ "line",
1412
+ "polyline",
1413
+ "polygon",
1414
+ "text",
1415
+ "tspan",
1416
+ "switch"
1417
+ ];
1418
+ /**
1419
+ * Refused SVG elements removed *with their contents* when they sit inside an
1420
+ * `<svg>`, rather than unwrapped.
1421
+ *
1422
+ * Each of these holds children that are never meant to be painted where they
1423
+ * stand: a clip region, a mask, a gradient's stops, a marker's arrowhead, a
1424
+ * symbol's body, a filter's primitives, an exporter's RDF, the text of a
1425
+ * `title` or `desc` (see `SVG_CHILD_TAGS`), or — for `foreignObject` — HTML.
1426
+ * Unwrapped, those children land in the drawing as ordinary shapes. A default
1427
+ * Figma export ends in `<defs><clipPath><rect fill="white"/>`, and unwrapping
1428
+ * it painted that white rect over the whole drawing; a `<marker>` arrowhead
1429
+ * became a stray triangle at the origin. The HTML inside a `foreignObject`
1430
+ * survived as descendants of `svg`, which React then created as invisible
1431
+ * SVG-namespace `div`s and `p`s — the anchor an Open Question button looks for
1432
+ * — and which a browser re-parsing `renderMarkdown`'s string output breaks out
1433
+ * of the `svg` at, spilling the rest of the drawing into the page.
1434
+ *
1435
+ * **Only inside an `<svg>`, which is why this is a pass and not the schema's
1436
+ * `strip`.** `strip` removes a tag wherever it stands, and most of these names
1437
+ * are lowercase in prose too. There a bare `<pattern>` on a line of its own is
1438
+ * an HTML element nothing closes, so every Markdown block after it was parsed
1439
+ * into it, and stripping it removed the rest of the document — in every
1440
+ * renderer, with `vantage-check` silent. Inside a drawing the same container is
1441
+ * bounded by the `svg`, because a Markdown paragraph or heading breaks out of
1442
+ * SVG. Outside one the sanitizer unwraps these like any tag it does not know:
1443
+ * `title` and `desc` are not admitted anywhere, so no `title` element survives
1444
+ * to be hoisted, and what they held is text or ordinary sanitized HTML.
1445
+ *
1446
+ * Names compare case-insensitively. The camel-cased ones are how the HTML
1447
+ * parser spells these elements in SVG, but under `<math><svg>` the parser stays
1448
+ * in MathML and leaves them lowercase.
1449
+ */
1450
+ const SVG_STRIPPED = [
1451
+ "foreignObject",
1452
+ "defs",
1453
+ "clipPath",
1454
+ "mask",
1455
+ "pattern",
1456
+ "marker",
1457
+ "symbol",
1458
+ "linearGradient",
1459
+ "radialGradient",
1460
+ "filter",
1461
+ "metadata",
1462
+ "title",
1463
+ "desc"
1464
+ ];
1465
+ const SVG_STRIPPED_NAMES = new Set(SVG_STRIPPED.map((name) => name.toLowerCase()));
1466
+ /**
1467
+ * The camel-cased `SVG_STRIPPED` names, which the schema strips on its own.
1468
+ *
1469
+ * Only an SVG-namespace element carries one of these spellings, so stripping
1470
+ * them everywhere costs prose nothing. It keeps the worst of the list — the
1471
+ * HTML inside a `foreignObject` — out of a drawing for a consumer who hands
1472
+ * `sanitizeSchema` to `rehype-sanitize` without the rest of `buildPipeline`.
1473
+ */
1474
+ const SVG_STRIPPED_EVERYWHERE = SVG_STRIPPED.filter((name) => name !== name.toLowerCase());
1475
+ function stripSvgContainers(parent, inSvg) {
1476
+ if (inSvg) parent.children = parent.children.filter((child) => child.type !== "element" || !SVG_STRIPPED_NAMES.has(child.tagName.toLowerCase()));
1477
+ for (const child of parent.children) if (child.type === "element") stripSvgContainers(child, inSvg || child.tagName.toLowerCase() === "svg");
1478
+ }
1479
+ /**
1480
+ * Remove every `SVG_STRIPPED` element below an `svg`, with its contents.
1481
+ *
1482
+ * Runs immediately before `rehypeSanitize` (`pipeline.ts`), and only when the
1483
+ * pipeline sanitizes: with the sanitizer off these elements are left to work
1484
+ * as SVG defines them.
1485
+ */
1486
+ function rehypeStripSvgContainers() {
1487
+ return (tree) => {
1488
+ stripSvgContainers(tree, false);
1489
+ };
1490
+ }
1491
+ /**
1492
+ * A paint value: a keyword, a named color, or a hex color. No parentheses, for
1493
+ * the same reason `SAFE_STYLE` refuses them — `fill="url(https://…)"` is a
1494
+ * render-time fetch, and `rgb()` is the price of closing it.
1495
+ */
1496
+ const SVG_PAINT = /^(?:#[0-9a-f]{3,8}|[a-z]+)$/i;
1497
+ /**
1498
+ * Attributes any SVG element may carry, drawing and typography alike.
1499
+ *
1500
+ * **Each entry is a hast property name, which is not always the camel case of
1501
+ * the attribute.** `property-information` decides the spelling, and it has
1502
+ * `strokeLineCap` for `stroke-linecap` — a naive `strokeLinecap` matches
1503
+ * nothing, and the attribute is dropped in every renderer without a word. The
1504
+ * table-driven test in `frontend/src/lib/sanitize.test.ts` renders every entry
1505
+ * in its written spelling, so a misspelled one fails there.
1506
+ *
1507
+ * `stroke-dasharray` (and with it `stroke-dashoffset`, which does nothing
1508
+ * alone) is refused, because it lets a few bytes buy an unbounded paint. The
1509
+ * number of dashes is the path's length over the dash period, both in user
1510
+ * units the document chooses. Measured in headless Chromium, fifty 90-byte
1511
+ * paths with a 0.0011 dash took 21 s to paint and a hundred took 37 s. A value
1512
+ * grammar cannot bound it: refusing sub-unit dashes is undone by scaling the
1513
+ * user units, and the same fifty paths with `stroke-dasharray="1.1"` under a
1514
+ * `viewBox` a thousand times larger still took 15 s.
1515
+ */
1516
+ const SVG_ATTRIBUTES = [
1517
+ ["fill", SVG_PAINT],
1518
+ ["stroke", SVG_PAINT],
1519
+ "fillOpacity",
1520
+ "fillRule",
1521
+ "strokeOpacity",
1522
+ "strokeWidth",
1523
+ "strokeLineCap",
1524
+ "strokeLineJoin",
1525
+ "opacity",
1526
+ "transform",
1527
+ "fontFamily",
1528
+ "fontSize",
1529
+ "fontWeight",
1530
+ "fontStyle",
1531
+ "letterSpacing",
1532
+ "textAnchor",
1533
+ "dominantBaseline",
1534
+ "x",
1535
+ "y",
1536
+ "x1",
1537
+ "y1",
1538
+ "x2",
1539
+ "y2",
1540
+ "cx",
1541
+ "cy",
1542
+ "r",
1543
+ "rx",
1544
+ "ry",
1545
+ "dx",
1546
+ "dy",
1547
+ "d",
1548
+ "points"
1549
+ ];
1550
+ /**
1551
+ * The root `<svg>`'s attributes: `SVG_ATTRIBUTES` less `transform`, plus the
1552
+ * viewport, a `role` from a closed list, and the accessible name.
1553
+ *
1554
+ * `transform` on the root is not an SVG transform at all. The root is an
1555
+ * in-flow CSS box, and the attribute becomes a CSS transform of that box, so
1556
+ * `<svg width="10" height="10" transform="translate(-300 -300) scale(80)">`
1557
+ * laid out as a 10px box and painted as an 800px one over the paragraphs
1558
+ * around it. On a child it moves shapes inside the viewport, which clips them.
1559
+ *
1560
+ * Residual: that clip is the root's `overflow`, which `SAFE_STYLE` lets a
1561
+ * document set. `style="overflow:visible"` on the root lets a child with a
1562
+ * large `transform` or large coordinates paint over its neighbors — measured in
1563
+ * Chromium, the same overlap. It is the negative-`margin` residual described at
1564
+ * `SAFE_STYLE` by another route: it stays in the flow, scrolls with the content,
1565
+ * and the scroll container clips it.
1566
+ */
1567
+ const SVG_ROOT_ATTRIBUTES = [
1568
+ ...SVG_ATTRIBUTES.filter((attribute) => attribute !== "transform"),
1569
+ "xmlns",
1570
+ "viewBox",
1571
+ "preserveAspectRatio",
1572
+ [
1573
+ "role",
1574
+ "img",
1575
+ "presentation",
1576
+ "none"
1577
+ ],
1578
+ "ariaLabel"
1579
+ ];
1580
+ /** Every element the sanitizer admits. */
1581
+ const TAG_NAMES = [
1582
+ ...rehype_sanitize.defaultSchema.tagNames || [],
1583
+ "math",
1584
+ "semantics",
1585
+ "mrow",
1586
+ "mi",
1587
+ "mo",
1588
+ "mn",
1589
+ "msup",
1590
+ "msub",
1591
+ "mfrac",
1592
+ "mover",
1593
+ "munder",
1594
+ "msqrt",
1595
+ "mroot",
1596
+ "mtable",
1597
+ "mtr",
1598
+ "mtd",
1599
+ "mtext",
1600
+ "mspace",
1601
+ "annotation",
1602
+ "figure",
1603
+ "figcaption",
1604
+ "summary",
1605
+ "details",
1606
+ "svg",
1607
+ ...SVG_CHILD_TAGS
1608
+ ];
1609
+ /**
1610
+ * The admitted elements that take no `style`: only `input`, which GFM emits as
1611
+ * a task list's checkbox, and never with a style.
1612
+ *
1613
+ * `styles/task-list.css` draws that checkbox and positions it, so on it `top`
1614
+ * and `left` are not the no-ops they are on an element nothing positions, and
1615
+ * with `width`, `height` and `background-color` beside them a document laid a
1616
+ * box of its own over the page. Measured in Chromium at 1280x800 while the
1617
+ * checkbox was positioned absolutely,
1618
+ * `top:-3000px;left:-3000px;width:9000px;height:9000px;background-color:white`
1619
+ * on it covered the whole content pane. It needed no class written:
1620
+ * GFM puts `task-list-item` on the item, and a checkbox typed into the item's
1621
+ * text is a child of that item too.
1622
+ *
1623
+ * **This is why `style` is on each element's own entry, never on `*`.**
1624
+ * `hast-util-sanitize` consults `*` whenever an element's own entry yields
1625
+ * nothing, so no entry on `input` could refuse a value that `*` admits. An
1626
+ * element takes `style` only by being in `TAG_NAMES` and not in this set.
1627
+ */
1628
+ const UNSTYLED_TAGS = /* @__PURE__ */ new Set(["input"]);
1629
+ /**
1630
+ * `attributes`, with `["style", SAFE_STYLE]` added to the entry of every
1631
+ * element in `TAG_NAMES` but the `UNSTYLED_TAGS`.
1632
+ */
1633
+ function withStyle(attributes) {
1634
+ const styled = { ...attributes };
1635
+ for (const tag of new Set(TAG_NAMES)) if (!UNSTYLED_TAGS.has(tag)) styled[tag] = [...styled[tag] ?? [], ["style", SAFE_STYLE]];
1636
+ return styled;
1637
+ }
1638
+ /**
978
1639
  * Never set `allowComments` here.
979
1640
  *
980
1641
  * `hast-util-sanitize` drops comment nodes because that boolean defaults to
@@ -988,38 +1649,16 @@ const COLLAPSE_GROUP_ID = /^[0-9]+$/;
988
1649
  */
989
1650
  const sanitizeSchema = {
990
1651
  ...rehype_sanitize.defaultSchema,
991
- tagNames: [
992
- ...rehype_sanitize.defaultSchema.tagNames || [],
993
- "math",
994
- "semantics",
995
- "mrow",
996
- "mi",
997
- "mo",
998
- "mn",
999
- "msup",
1000
- "msub",
1001
- "mfrac",
1002
- "mover",
1003
- "munder",
1004
- "msqrt",
1005
- "mroot",
1006
- "mtable",
1007
- "mtr",
1008
- "mtd",
1009
- "mtext",
1010
- "mspace",
1011
- "annotation",
1012
- "figure",
1013
- "figcaption",
1014
- "summary",
1015
- "details"
1016
- ],
1017
- attributes: {
1652
+ strip: [...rehype_sanitize.defaultSchema.strip || [], ...SVG_STRIPPED_EVERYWHERE],
1653
+ tagNames: TAG_NAMES,
1654
+ ancestors: {
1655
+ ...rehype_sanitize.defaultSchema.ancestors,
1656
+ ...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, ["svg"]]))
1657
+ },
1658
+ attributes: withStyle({
1018
1659
  ...rehype_sanitize.defaultSchema.attributes,
1019
1660
  "*": [
1020
1661
  ...rehype_sanitize.defaultSchema.attributes?.["*"] || [],
1021
- "className",
1022
- ["style", SAFE_STYLE],
1023
1662
  "dataSourceLine",
1024
1663
  ["dataVantageTone", ...VANTAGE_TONES],
1025
1664
  ["dataVantageEmphasis", ...VANTAGE_EMPHASIS],
@@ -1029,35 +1668,22 @@ const sanitizeSchema = {
1029
1668
  ["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
1030
1669
  ["dataVantageRun", ...VANTAGE_RUNS],
1031
1670
  ["dataVantageOq", "true"],
1671
+ ["dataVantageQuestion", "true"],
1032
1672
  ["dataVantageOqId", VANTAGE_OQ_ID],
1033
1673
  ["dataVantageAlert", ...VANTAGE_ALERTS],
1034
1674
  "dataVantageLeaning"
1035
1675
  ],
1036
- code: [...rehype_sanitize.defaultSchema.attributes?.code || [], "className"],
1037
- span: [
1038
- ...rehype_sanitize.defaultSchema.attributes?.span || [],
1039
- "className",
1040
- ["style", SAFE_STYLE]
1041
- ],
1042
- div: [
1043
- ...rehype_sanitize.defaultSchema.attributes?.div || [],
1044
- "className",
1045
- ["style", SAFE_STYLE]
1046
- ],
1047
- a: [
1048
- ...rehype_sanitize.defaultSchema.attributes?.a || [],
1049
- "id",
1050
- "className"
1051
- ],
1676
+ ...Object.fromEntries(Object.keys(PIPELINE_CLASSES).map((tag) => [tag, withClasses(tag)])),
1677
+ a: withClasses("a", "id"),
1052
1678
  math: ["xmlns"],
1053
1679
  annotation: ["encoding"],
1054
1680
  img: [...rehype_sanitize.defaultSchema.attributes?.img || [], "loading"],
1055
- td: [...rehype_sanitize.defaultSchema.attributes?.td || [], ["style", SAFE_STYLE]],
1056
- th: [...rehype_sanitize.defaultSchema.attributes?.th || [], ["style", SAFE_STYLE]]
1057
- }
1681
+ svg: SVG_ROOT_ATTRIBUTES,
1682
+ ...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, SVG_ATTRIBUTES]))
1683
+ })
1058
1684
  };
1059
1685
  //#endregion
1060
- //#region src/pipeline.ts
1686
+ //#region src/remarkPlugins.ts
1061
1687
  /**
1062
1688
  * The mdast half of the chain. Exported on its own because there is a real
1063
1689
  * mdast-only consumer: the CLI checker parses documents without ever running
@@ -1071,6 +1697,8 @@ function buildRemarkPlugins(options = {}) {
1071
1697
  if (math) plugins.push([remark_math.default, { singleDollarTextMath: false }]);
1072
1698
  return plugins;
1073
1699
  }
1700
+ //#endregion
1701
+ //#region src/pipeline.ts
1074
1702
  /** The hast half. Deliberately not exported: see `buildPipeline`. */
1075
1703
  function buildRehypePlugins(options = {}) {
1076
1704
  const { math = true, highlight = true, sourceLines = true, sanitize = true, bodyLineOffset = 0 } = options;
@@ -1078,7 +1706,7 @@ function buildRehypePlugins(options = {}) {
1078
1706
  if (sourceLines) plugins.push([rehypeSourceLines, { offset: bodyLineOffset }]);
1079
1707
  plugins.push(rehypeVantageAlerts);
1080
1708
  plugins.push(rehypeVantageDirectives);
1081
- if (sanitize) plugins.push([rehype_sanitize.default, sanitizeSchema]);
1709
+ if (sanitize) plugins.push(rehypeStripSvgContainers, [rehype_sanitize.default, sanitizeSchema]);
1082
1710
  plugins.push(rehypeVantageAnchors);
1083
1711
  plugins.push(rehype_slug.default);
1084
1712
  if (highlight) plugins.push(rehype_highlight.default);
@@ -1362,6 +1990,9 @@ const cacheKey = (code, theme) => `${theme}\u0000${code}`;
1362
1990
  function getCachedSvg(code, theme = currentMermaidPalette()) {
1363
1991
  return svgCache.get(cacheKey(code, theme));
1364
1992
  }
1993
+ function hasCachedSvg(code, theme = currentMermaidPalette()) {
1994
+ return svgCache.has(cacheKey(code, theme));
1995
+ }
1365
1996
  function setCachedSvg(code, svg, theme = currentMermaidPalette()) {
1366
1997
  svgCache.set(cacheKey(code, theme), svg);
1367
1998
  }
@@ -1374,6 +2005,43 @@ let mermaidLoading = null;
1374
2005
  * configured for, `null` until loaded.
1375
2006
  */
1376
2007
  let configuredTheme = null;
2008
+ /**
2009
+ * The configuration keys a diagram's own source may not set, from an `init`
2010
+ * directive or from frontmatter: Mermaid's default list, then the three
2011
+ * that write a diagram's stylesheet.
2012
+ *
2013
+ * **A diagram's stylesheet reaches past the diagram.** Mermaid puts every
2014
+ * selector of it under the diagram's id, but leaves the names of its
2015
+ * `@keyframes` global, so a diagram that defined `flash-update` redefined the
2016
+ * animation the viewer plays on a block that just changed. Given
2017
+ * `position: fixed` at the size of the window, the next flash laid that block,
2018
+ * and the link it held, over the header and the sidebar. The same stylesheet
2019
+ * fetched images from any host, which is why `SAFE_STYLE` refuses parentheses.
2020
+ *
2021
+ * - `themeCSS` is a stylesheet, and Mermaid checks only that its braces pair.
2022
+ * - `fontFamily` is copied into `themeVariables` *after* Mermaid's check on
2023
+ * theme variables has run, and lands in the stylesheet as a declaration's
2024
+ * value, where a nested block becomes a rule of its own.
2025
+ * - `altFontFamily` lands in a rule the browser parses first, which drops
2026
+ * what does not belong. It is refused anyway: nothing needs it, and that
2027
+ * parse is all that stands in the way.
2028
+ *
2029
+ * Mermaid applies the list at every depth, so `themeVariables.fontFamily` in
2030
+ * a diagram is refused too. The list is spelled whole rather than relying on
2031
+ * Mermaid merging it with its default. `frontend/e2e/mermaid.spec.ts` measures
2032
+ * all four routes; see "Mermaid" in `docs/reference/inline-markup.md`.
2033
+ */
2034
+ const MERMAID_SECURE_KEYS = [
2035
+ "secure",
2036
+ "securityLevel",
2037
+ "startOnLoad",
2038
+ "maxTextSize",
2039
+ "suppressErrorRendering",
2040
+ "maxEdges",
2041
+ "themeCSS",
2042
+ "fontFamily",
2043
+ "altFontFamily"
2044
+ ];
1377
2045
  function configure(m, palette) {
1378
2046
  const theme = mermaidThemeOf(palette);
1379
2047
  m.initialize({
@@ -1381,7 +2049,8 @@ function configure(m, palette) {
1381
2049
  theme,
1382
2050
  themeVariables: mermaidThemeVariables(theme),
1383
2051
  securityLevel: "strict",
1384
- suppressErrorRendering: true
2052
+ suppressErrorRendering: true,
2053
+ secure: MERMAID_SECURE_KEYS
1385
2054
  });
1386
2055
  configuredTheme = palette;
1387
2056
  }
@@ -1424,7 +2093,7 @@ const AlertTriangleIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg
1424
2093
  strokeWidth: "2",
1425
2094
  strokeLinecap: "round",
1426
2095
  strokeLinejoin: "round",
1427
- className: "w-4 h-4 shrink-0",
2096
+ className: "block w-4 h-4 shrink-0",
1428
2097
  children: [
1429
2098
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }),
1430
2099
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 9v4" }),
@@ -1440,7 +2109,7 @@ const ChevronDownIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg",
1440
2109
  strokeWidth: "2",
1441
2110
  strokeLinecap: "round",
1442
2111
  strokeLinejoin: "round",
1443
- className: "w-3 h-3",
2112
+ className: "block w-3 h-3",
1444
2113
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 9 6 6 6-6" })
1445
2114
  });
1446
2115
  const ChevronUpIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
@@ -1452,7 +2121,7 @@ const ChevronUpIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1452
2121
  strokeWidth: "2",
1453
2122
  strokeLinecap: "round",
1454
2123
  strokeLinejoin: "round",
1455
- className: "w-3 h-3",
2124
+ className: "block w-3 h-3",
1456
2125
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m18 15-6-6-6 6" })
1457
2126
  });
1458
2127
  const MaximizeIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
@@ -1464,7 +2133,7 @@ const MaximizeIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1464
2133
  strokeWidth: "2",
1465
2134
  strokeLinecap: "round",
1466
2135
  strokeLinejoin: "round",
1467
- className: "w-4 h-4 text-gray-600 dark:text-slate-200",
2136
+ className: "block w-4 h-4 text-gray-600 dark:text-slate-200",
1468
2137
  children: [
1469
2138
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("polyline", { points: "15 3 21 3 21 9" }),
1470
2139
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("polyline", { points: "9 21 3 21 3 15" }),
@@ -1491,7 +2160,7 @@ const CloseIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1491
2160
  strokeWidth: "2",
1492
2161
  strokeLinecap: "round",
1493
2162
  strokeLinejoin: "round",
1494
- className: "w-5 h-5",
2163
+ className: "block w-5 h-5",
1495
2164
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 6 12 12" })]
1496
2165
  });
1497
2166
  const PlusIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
@@ -1503,7 +2172,7 @@ const PlusIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1503
2172
  strokeWidth: "2",
1504
2173
  strokeLinecap: "round",
1505
2174
  strokeLinejoin: "round",
1506
- className: "w-4 h-4",
2175
+ className: "block w-4 h-4",
1507
2176
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12h14" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 5v14" })]
1508
2177
  });
1509
2178
  const MinusIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
@@ -1515,7 +2184,7 @@ const MinusIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1515
2184
  strokeWidth: "2",
1516
2185
  strokeLinecap: "round",
1517
2186
  strokeLinejoin: "round",
1518
- className: "w-4 h-4",
2187
+ className: "block w-4 h-4",
1519
2188
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12h14" })
1520
2189
  });
1521
2190
  const ResetIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
@@ -1527,7 +2196,7 @@ const ResetIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1527
2196
  strokeWidth: "2",
1528
2197
  strokeLinecap: "round",
1529
2198
  strokeLinejoin: "round",
1530
- className: "w-4 h-4",
2199
+ className: "block w-4 h-4",
1531
2200
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 3v5h5" })]
1532
2201
  });
1533
2202
  const MIN_SCALE = .2;
@@ -1796,8 +2465,9 @@ const MermaidDiagramInner = ({ code }) => {
1796
2465
  });
1797
2466
  }
1798
2467
  } catch (err) {
2468
+ if (!mounted) return;
1799
2469
  console.error("Mermaid render error:", err);
1800
- if (mounted) setFailure({
2470
+ setFailure({
1801
2471
  key,
1802
2472
  message: extractErrorMessage(err)
1803
2473
  });
@@ -2055,6 +2725,34 @@ function TagList({ items }) {
2055
2725
  }, item))
2056
2726
  });
2057
2727
  }
2728
+ /**
2729
+ * An `OQ-…` token, shaped as `VANTAGE_OQ_ID` shapes an id and bounded as the
2730
+ * planning scan bounds one: by what may not touch it, so an id inside a longer
2731
+ * token (`OQ-B12x`, `xOQ-B1`) is not a token at all. The leading bound is a
2732
+ * group rather than a lookbehind, which Safari parses only since 16.4.
2733
+ */
2734
+ const OQ_TOKEN = /(^|[^A-Za-z0-9])(OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+)(?![A-Za-z0-9])/g;
2735
+ /** `text`, with each token in `ids` as a link to its question. */
2736
+ function LinkedIds({ text, ids }) {
2737
+ const out = [];
2738
+ let last = 0;
2739
+ for (const match of text.matchAll(OQ_TOKEN)) {
2740
+ const id = match[2];
2741
+ if (id === void 0 || !ids.includes(id)) continue;
2742
+ const start = match.index + match[1].length;
2743
+ if (start > last) out.push(text.slice(last, start));
2744
+ out.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2745
+ href: `#${id}`,
2746
+ children: id
2747
+ }, start));
2748
+ last = start + id.length;
2749
+ }
2750
+ if (last < text.length) out.push(text.slice(last));
2751
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2752
+ className: "font-medium",
2753
+ children: out
2754
+ });
2755
+ }
2058
2756
  function ValueCell({ value }) {
2059
2757
  if (isStringArray(value) && value.length > 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TagList, { items: value });
2060
2758
  if (isPlainObject(value)) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
@@ -2076,7 +2774,7 @@ function flattenEntries(entries) {
2076
2774
  }
2077
2775
  return result;
2078
2776
  }
2079
- const FrontmatterDisplayInner = ({ frontmatter }) => {
2777
+ const FrontmatterDisplayInner = ({ frontmatter, linkIds }) => {
2080
2778
  const entries = flattenEntries(Object.entries(frontmatter));
2081
2779
  const { statusChip } = readVantageFrontmatter(frontmatter);
2082
2780
  if (entries.length === 0 && statusChip === void 0) return null;
@@ -2102,7 +2800,10 @@ const FrontmatterDisplayInner = ({ frontmatter }) => {
2102
2800
  children: key
2103
2801
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
2104
2802
  className: "py-2 text-slate-800 dark:text-slate-200 align-top",
2105
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ValueCell, { value })
2803
+ children: key === "next" && linkIds !== void 0 && typeof value === "string" && !/[\r\n]/.test(value.trim()) && value === frontmatter["next"] ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LinkedIds, {
2804
+ text: value,
2805
+ ids: linkIds
2806
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ValueCell, { value })
2106
2807
  })]
2107
2808
  }, key)) })
2108
2809
  })
@@ -2378,17 +3079,16 @@ const MarkdownViewerInner = ({ content, currentPath = "", hash = "", baseUrl = "
2378
3079
  imageApiBase
2379
3080
  ]);
2380
3081
  const markdownComponents = (0, react.useMemo)(() => ({
2381
- a({ href, children, ...props }) {
2382
- const resolvedHref = resolveHref(href);
3082
+ a({ node, href, children, ...props }) {
2383
3083
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2384
- href: resolvedHref,
3084
+ href: href === void 0 ? void 0 : resolveHref(href),
2385
3085
  onClick: (e) => href && handleLinkClick(e, href),
2386
3086
  ...props,
2387
3087
  children
2388
3088
  });
2389
3089
  },
2390
3090
  code(props) {
2391
- const { children, className: codeClassName, ...rest } = props;
3091
+ const { node, children, className: codeClassName, ...rest } = props;
2392
3092
  const match = /language-(\w+)/.exec(codeClassName || "");
2393
3093
  if (match && match[1] === "mermaid") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MermaidDiagram, { code: String(children).replace(/\n$/, "") });
2394
3094
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
@@ -2432,61 +3132,8 @@ const MarkdownViewerInner = ({ content, currentPath = "", hash = "", baseUrl = "
2432
3132
  };
2433
3133
  const MarkdownViewer = (0, react.memo)(MarkdownViewerInner, (prevProps, nextProps) => prevProps.content === nextProps.content && prevProps.currentPath === nextProps.currentPath && prevProps.hash === nextProps.hash && prevProps.className === nextProps.className);
2434
3134
  //#endregion
2435
- //#region src/renderMarkdown.ts
2436
- /**
2437
- * Render a markdown string to HTML using the full Vantage pipeline.
2438
- *
2439
- * Features (all enabled by default):
2440
- * - GitHub Flavored Markdown (tables, strikethrough, task lists)
2441
- * - KaTeX math rendering, inline and block ($$...$$ only; single $ is not a delimiter)
2442
- * - Syntax highlighting via highlight.js
2443
- * - `data-source-line` attributes for line anchors
2444
- * - XSS sanitization
2445
- * - Heading slugs/anchors
2446
- * - YAML/TOML frontmatter parsing
2447
- *
2448
- * Mermaid diagrams are NOT rendered server-side (they require a browser).
2449
- * Mermaid code blocks are preserved as `<pre><code class="language-mermaid">`.
2450
- * Use the React `<MarkdownViewer>` component for client-side mermaid rendering.
2451
- */
2452
- async function renderMarkdown(content, options = {}) {
2453
- const { gfm = true, math = true, highlight = true, sourceLines = true, sanitize = true, frontmatter: parseFm = true, tree } = options;
2454
- let parsed;
2455
- if (parseFm) parsed = parseFrontmatter(content);
2456
- else parsed = {
2457
- frontmatter: {},
2458
- body: content,
2459
- format: "none",
2460
- bodyLineOffset: 0
2461
- };
2462
- const { remarkPlugins, rehypePlugins } = buildPipeline({
2463
- gfm,
2464
- math,
2465
- highlight,
2466
- sourceLines,
2467
- sanitize,
2468
- bodyLineOffset: parsed.bodyLineOffset
2469
- });
2470
- const processor = (0, unified.unified)().use(remark_parse.default).use(remarkPlugins).use(remark_rehype.default, { allowDangerousHtml: true }).use(rehypePlugins).use(rehype_stringify.default);
2471
- const result = tree === void 0 ? String(await processor.process(parsed.body)) : processor.stringify(await processor.run(tree, parsed.body), parsed.body);
2472
- return {
2473
- html: String(result),
2474
- frontmatter: parsed.frontmatter,
2475
- body: parsed.body
2476
- };
2477
- }
2478
- //#endregion
2479
3135
  //#region src/renderMermaidBlocks.ts
2480
3136
  /**
2481
- * Client-side utility to find and render mermaid code blocks in a container.
2482
- *
2483
- * After calling `renderMarkdown()`, mermaid blocks come through as
2484
- * `<pre><code class="language-mermaid">...</code></pre>`. This function
2485
- * finds those blocks and replaces them with rendered SVG diagrams.
2486
- *
2487
- * Framework-agnostic — works in any browser environment.
2488
- */
2489
- /**
2490
3137
  * Find all `<pre><code class="language-mermaid">` blocks in a container
2491
3138
  * and replace them with rendered SVG diagrams.
2492
3139
  *
@@ -2567,6 +3214,104 @@ function replaceWithSvg(preEl, svg, className) {
2567
3214
  wrapper.innerHTML = svg;
2568
3215
  preEl.replaceWith(wrapper);
2569
3216
  }
3217
+ /** The viewer's parser: the remark half of `buildPipeline`. */
3218
+ const fenceParser = (0, unified.unified)().use(remark_parse.default).use(buildRemarkPlugins());
3219
+ /**
3220
+ * The code of every Mermaid fence in `markdown`, in source order, exactly as
3221
+ * a viewer's `MermaidDiagram` receives it: the fence's content, without its
3222
+ * container's indentation or its closing newline.
3223
+ *
3224
+ * A fence is a diagram when its info string's first word begins with the word
3225
+ * `mermaid`, which is the test the viewer applies to the `language-…` class
3226
+ * the fence renders with (`/language-(\w+)/`). Text that never says
3227
+ * `mermaid` is not parsed at all.
3228
+ */
3229
+ function mermaidFences(markdown) {
3230
+ if (!markdown.includes("mermaid")) return [];
3231
+ const codes = [];
3232
+ (0, unist_util_visit.visit)(fenceParser.parse(markdown), "code", (node) => {
3233
+ if (/^\w+/.exec(node.lang ?? "")?.[0] === "mermaid") codes.push(node.value);
3234
+ });
3235
+ return codes;
3236
+ }
3237
+ /** Draws under way, by palette and code, so asking twice draws once. */
3238
+ const drawing = /* @__PURE__ */ new Map();
3239
+ let drawn = 0;
3240
+ /**
3241
+ * Draw one diagram into the SVG cache that `MermaidDiagram` reads when it
3242
+ * mounts, so a diagram rendered after this has settled is drawn at its full
3243
+ * size on its first paint instead of growing into place once Mermaid answers.
3244
+ * The planning page draws every diagram of the cards it is about to show this
3245
+ * way, before it commits them.
3246
+ *
3247
+ * Mermaid is loaded through `getMermaid`, so the drawing is configured, and
3248
+ * made safe, exactly as every other diagram of the page is. `theme` is the
3249
+ * palette key (`currentMermaidPalette`) to draw for; when the page has moved
3250
+ * to another palette by the time Mermaid is ready, nothing is cached, since
3251
+ * Mermaid now draws for the page's palette and not for `theme`.
3252
+ *
3253
+ * Resolves once the diagram is in the cache, at once when it already was, and
3254
+ * rejects when Mermaid cannot draw it.
3255
+ */
3256
+ function prerenderMermaid(code, theme = currentMermaidPalette()) {
3257
+ if (!code.trim() || hasCachedSvg(code, theme)) return Promise.resolve();
3258
+ const key = `${theme}\u0000${code}`;
3259
+ let pending = drawing.get(key);
3260
+ if (pending === void 0) {
3261
+ pending = (async () => {
3262
+ const mermaid = await getMermaid();
3263
+ if (currentMermaidPalette() !== theme) return;
3264
+ const { svg } = await mermaid.render(`mermaid-ahead-${(++drawn).toString(36)}`, code);
3265
+ setCachedSvg(code, svg, theme);
3266
+ })().finally(() => drawing.delete(key));
3267
+ drawing.set(key, pending);
3268
+ }
3269
+ return pending;
3270
+ }
3271
+ //#endregion
3272
+ //#region src/renderMarkdown.ts
3273
+ /**
3274
+ * Render a markdown string to HTML using the full Vantage pipeline.
3275
+ *
3276
+ * Features (all enabled by default):
3277
+ * - GitHub Flavored Markdown (tables, strikethrough, task lists)
3278
+ * - KaTeX math rendering, inline and block ($$...$$ only; single $ is not a delimiter)
3279
+ * - Syntax highlighting via highlight.js
3280
+ * - `data-source-line` attributes for line anchors
3281
+ * - XSS sanitization
3282
+ * - Heading slugs/anchors
3283
+ * - YAML/TOML frontmatter parsing
3284
+ *
3285
+ * Mermaid diagrams are NOT rendered server-side (they require a browser).
3286
+ * Mermaid code blocks are preserved as `<pre><code class="language-mermaid">`.
3287
+ * Use the React `<MarkdownViewer>` component for client-side mermaid rendering.
3288
+ */
3289
+ async function renderMarkdown(content, options = {}) {
3290
+ const { gfm = true, math = true, highlight = true, sourceLines = true, sanitize = true, frontmatter: parseFm = true, tree } = options;
3291
+ let parsed;
3292
+ if (parseFm) parsed = parseFrontmatter(content);
3293
+ else parsed = {
3294
+ frontmatter: {},
3295
+ body: content,
3296
+ format: "none",
3297
+ bodyLineOffset: 0
3298
+ };
3299
+ const { remarkPlugins, rehypePlugins } = buildPipeline({
3300
+ gfm,
3301
+ math,
3302
+ highlight,
3303
+ sourceLines,
3304
+ sanitize,
3305
+ bodyLineOffset: parsed.bodyLineOffset
3306
+ });
3307
+ const processor = (0, unified.unified)().use(remark_parse.default).use(remarkPlugins).use(remark_rehype.default, { allowDangerousHtml: true }).use(rehypePlugins).use(rehype_stringify.default);
3308
+ const result = tree === void 0 ? String(await processor.process(parsed.body)) : processor.stringify(await processor.run(tree, parsed.body), parsed.body);
3309
+ return {
3310
+ html: String(result),
3311
+ frontmatter: parsed.frontmatter,
3312
+ body: parsed.body
3313
+ };
3314
+ }
2570
3315
  //#endregion
2571
3316
  //#region src/resolveLinks.ts
2572
3317
  /**
@@ -2623,8 +3368,10 @@ exports.buildPipeline = buildPipeline;
2623
3368
  exports.buildRemarkPlugins = buildRemarkPlugins;
2624
3369
  exports.clearLineAnchorHighlights = clearLineAnchorHighlights;
2625
3370
  exports.isDocStatus = isDocStatus;
3371
+ exports.mermaidFences = mermaidFences;
2626
3372
  exports.parseFrontmatter = parseFrontmatter;
2627
3373
  exports.parseLineAnchor = parseLineAnchor;
3374
+ exports.prerenderMermaid = prerenderMermaid;
2628
3375
  exports.readVantageFrontmatter = readVantageFrontmatter;
2629
3376
  exports.rehypeSourceLines = rehypeSourceLines;
2630
3377
  exports.renderMarkdown = renderMarkdown;