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.js CHANGED
@@ -1,18 +1,18 @@
1
1
  import { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
2
  import ReactMarkdown from "react-markdown";
3
- import remarkGfm from "remark-gfm";
4
- import remarkMath from "remark-math";
5
3
  import rehypeRaw from "rehype-raw";
6
4
  import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
7
5
  import rehypeHighlight from "rehype-highlight";
8
6
  import rehypeKatex from "rehype-katex";
9
7
  import rehypeSlug from "rehype-slug";
10
8
  import { visit } from "unist-util-visit";
9
+ import remarkGfm from "remark-gfm";
10
+ import remarkMath from "remark-math";
11
11
  import YAML from "yaml";
12
12
  import { parse } from "smol-toml";
13
13
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
14
- import { unified } from "unified";
15
14
  import remarkParse from "remark-parse";
15
+ import { unified } from "unified";
16
16
  import remarkRehype from "remark-rehype";
17
17
  import rehypeStringify from "rehype-stringify";
18
18
  //#region src/rehypeVantageAnchors.ts
@@ -127,6 +127,12 @@ const VANTAGE_ALERTS = [
127
127
  "warning",
128
128
  "caution"
129
129
  ];
130
+ /**
131
+ * The class on the title element this plugin injects, which
132
+ * `styles/directives.css` styles. Exported because the sanitizer runs after
133
+ * this plugin and admits a `div`'s class only when it is this one.
134
+ */
135
+ const ALERT_TITLE_CLASS = "vantage-alert-title";
130
136
  /** The visible label per kind. Title case, as GitHub renders it. */
131
137
  const ALERT_TITLES = {
132
138
  note: "Note",
@@ -150,7 +156,7 @@ const ALERT_TITLES = {
150
156
  * anchored test on the first text node is enough, and the plugin does not have
151
157
  * to reassemble the marker across siblings.
152
158
  */
153
- const MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/;
159
+ const ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/;
154
160
  /** The first child, if it is an element. */
155
161
  function firstElement(node) {
156
162
  const child = node.children.find((c) => c.type === "element" || c.type === "text" && c.value.trim() !== "");
@@ -177,7 +183,7 @@ function rehypeVantageAlerts() {
177
183
  if (paragraph === void 0 || paragraph.tagName !== "p") return;
178
184
  const lead = paragraph.children[0];
179
185
  if (lead === void 0 || lead.type !== "text") return;
180
- const match = MARKER.exec(lead.value);
186
+ const match = ALERT_MARKER.exec(lead.value);
181
187
  if (match === null) return;
182
188
  const kind = match[1].toLowerCase();
183
189
  lead.value = lead.value.slice(match[0].length);
@@ -189,7 +195,7 @@ function rehypeVantageAlerts() {
189
195
  node.children.unshift({
190
196
  type: "element",
191
197
  tagName: "div",
192
- properties: { className: ["vantage-alert-title"] },
198
+ properties: { className: [ALERT_TITLE_CLASS] },
193
199
  children: [{
194
200
  type: "text",
195
201
  value: ALERT_TITLES[kind]
@@ -200,6 +206,10 @@ function rehypeVantageAlerts() {
200
206
  }
201
207
  //#endregion
202
208
  //#region src/vantageDirectives.ts
209
+ /** Whether `name` declares a question, `question` or the deprecated `oq`. */
210
+ function isQuestionDirective(name) {
211
+ return name === "oq" || name === "question";
212
+ }
203
213
  /**
204
214
  * The `tone` vocabulary: GitHub's alert words plus `muted`.
205
215
  *
@@ -292,13 +302,32 @@ const VANTAGE_STYLE_TARGETS = [
292
302
  "div"
293
303
  ];
294
304
  /**
295
- * The tags an `oq` directive may stamp — strictly the tags the review system
296
- * can resolve an anchor on (`ANCHOR_TAGS` in the app's `MarkdownViewer`, and the
297
- * block map in `useReviewHighlights`). `ul`, `ol`, `tr`, `hr` and `div` are in
298
- * neither, so a button on one of them would build an anchor no review pass can
299
- * find — the "mis-wired button" D6 forbids.
305
+ * The tags a `fallback` directive may **withhold**: `VANTAGE_STYLE_TARGETS`
306
+ * minus the six headings, written as a subtraction so the narrowing stays
307
+ * visible.
300
308
  *
301
- * The gap between this list and `VANTAGE_STYLE_TARGETS` is why an `oq`
309
+ * A heading is refused because it is the document's structure rather than a
310
+ * stand-in for anything: the contents column lists it, other documents link to
311
+ * its slug, and the planning index reads sections under it. A fallback heading
312
+ * would be an anchor that exists on GitHub and nowhere in Vantage. What a
313
+ * fallback holds — a sentence naming the release a drawing needs, a link to
314
+ * the drawing as a file, an ASCII sketch in a fence — is a paragraph, a list,
315
+ * a quote, a code block, a table or a `<div>`, and those are all here.
316
+ *
317
+ * Three callers read it, and they must agree: `rehypeVantageDirectives`, which
318
+ * withholds the block; the planning index's scan, which must not read a
319
+ * question or a link from a block the page never shows; and the checker's
320
+ * `vantage/orphan`, which reports a fallback that withholds nothing.
321
+ */
322
+ const VANTAGE_FALLBACK_TARGETS = VANTAGE_STYLE_TARGETS.filter((tag) => !/^h[1-6]$/.test(tag));
323
+ /**
324
+ * The tags a question directive, `oq` or `question`, may stamp — strictly the
325
+ * tags the review system can resolve an anchor on (`ANCHOR_TAGS` in the app's
326
+ * `MarkdownViewer`, and the block map in `useReviewHighlights`). `ul`, `ol`,
327
+ * `tr`, `hr` and `div` are in neither, so a button on one of them would build
328
+ * an anchor no review pass can find — the "mis-wired button" D6 forbids.
329
+ *
330
+ * The gap between this list and `VANTAGE_STYLE_TARGETS` is why a question
302
331
  * directive at column 0 above a list silently does nothing: the target is the
303
332
  * `<ul>`, not the `<li>`. The checker says so.
304
333
  */
@@ -317,9 +346,9 @@ const VANTAGE_ANCHOR_TARGETS = [
317
346
  ];
318
347
  VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
319
348
  /**
320
- * The shape of an `oq` directive's `id`: `OQ-` then an optional short uppercase
321
- * prefix then digits. `OQ-9`, `OQ-TP6` and `OQ-A03` are ids; `OQ-foo`, `OQ-tp6`
322
- * and a bare `OQ6` are not.
349
+ * The shape of a question directive's `id`, on `oq` and `question` alike:
350
+ * `OQ-` then an optional short uppercase prefix then digits. `OQ-9`, `OQ-TP6`
351
+ * and `OQ-A03` are ids; `OQ-foo`, `OQ-tp6` and a bare `OQ6` are not.
323
352
  *
324
353
  * The prefix is what keeps ids distinct once one document references another's
325
354
  * questions — `trust-paths.md`'s `OQ-4` and a design sketch's `OQ-4` are
@@ -327,10 +356,15 @@ VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
327
356
  * reference means. It is optional because most documents never leave their own
328
357
  * file, and requiring it everywhere would fire on every single-doc sketch.
329
358
  *
330
- * Three consumers read it from here and none of them may re-spell it: the
331
- * plugin that stamps the anchor, the sanitizer that allowlists the value, and
332
- * the checker's `vantage/oq-id-format`. A fourth copy is how the checker starts
333
- * calling a working anchor malformed.
359
+ * Read from here, never re-spelled, by the sanitizer that allowlists the value,
360
+ * by the checker's `collectOqIds` (which feeds `vantage/oq-id-format` and the
361
+ * anchors the checker accepts) and by the planning index. The directive plugin
362
+ * that stamps the anchor deliberately does not test it: it stamps any non-empty
363
+ * id and leaves refusing a malformed one to the sanitizer. A re-spelled copy is
364
+ * how the checker starts calling a working anchor malformed — and the patterns
365
+ * that look for an id in text (`OQ_REFERENCE` and `OQ_EXACT` in the checker's
366
+ * `rules/references.ts`, `OQ_TOKEN` in `planning/scan.ts` and in
367
+ * `FrontmatterDisplay.tsx`) restate this shape, so they change with it.
334
368
  */
335
369
  const VANTAGE_OQ_ID = /^OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+$/;
336
370
  const STYLE_KEYS = {
@@ -343,10 +377,21 @@ const STYLE_KEYS = {
343
377
  * Name → key → the closed value set for that key.
344
378
  *
345
379
  * `section` and `block` share their keys: they differ in *extent*, not in what
346
- * they can say. `oq`'s two keys are the design's only values with no closed set
347
- * — `id` is a token an author chose and `leaning` is a sentence (§8.3) — so
348
- * neither can be value-allowlisted, which is recorded here as `null` rather
349
- * than left to a caller to guess.
380
+ * they can say. The two question names share theirs too, and they are the
381
+ * design's only values with no closed set — `id` is a token an author chose
382
+ * and `leaning` is a sentence (§8.3) — so neither can be value-allowlisted,
383
+ * which is recorded here as `null` rather than left to a caller to guess.
384
+ *
385
+ * `question` takes exactly `oq`'s keys and nothing more. Starting narrow is
386
+ * the forward-compatible choice: a key added later is one older viewers drop
387
+ * pair by pair, while a key accepted now could never be given a meaning (P0).
388
+ * A `leaning` on a 🔒 or ✅ question is allowed and simply not offered
389
+ * (`questionOffersTake`), so changing a question's state changes only its
390
+ * marker.
391
+ *
392
+ * `fallback` takes no keys: whether a block is withheld is the whole of what
393
+ * it says, and a key added later is dropped pair by pair by this release, as
394
+ * D2 drops any other.
350
395
  */
351
396
  const DIRECTIVE_VOCABULARY = {
352
397
  section: STYLE_KEYS,
@@ -354,9 +399,59 @@ const DIRECTIVE_VOCABULARY = {
354
399
  oq: {
355
400
  id: null,
356
401
  leaning: null
357
- }
402
+ },
403
+ question: {
404
+ id: null,
405
+ leaning: null
406
+ },
407
+ fallback: {}
358
408
  };
359
409
  /**
410
+ * Merge the question directives of one run — the comments up to the block they
411
+ * land on — into the one question they declare, or `undefined` when the run
412
+ * holds none. Directives of any other name are skipped.
413
+ *
414
+ * A run holding an `oq` is read exactly as a viewer that predates `question`
415
+ * reads it: that viewer drops every `question` and reads the `oq` alone, so
416
+ * the `oq` wins wherever it appears in the run, and only its keys apply — a
417
+ * key only a `question` in the same run sets applies nowhere, in no release.
418
+ * That is the one reading every viewer agrees on (`VANTAGE_QUESTION_NAMES`),
419
+ * so the same bytes file the same Take in 0.7 and in 0.8. Within one name, the
420
+ * last value of a key wins, as `section` and `block` resolve theirs. A pair
421
+ * its name does not accept, or a value outside a closed set, is dropped (D2).
422
+ *
423
+ * One function for every reader of a run: the plugin that stamps the question
424
+ * and the planning index that reads it, which the checker reads a question
425
+ * through, so none can disagree about which leaning or which id a run carries
426
+ * (D5).
427
+ */
428
+ function mergeQuestionRun(run) {
429
+ let name;
430
+ for (const directive of run) {
431
+ if (!isQuestionDirective(directive.name)) continue;
432
+ if (name !== "oq") name = directive.name;
433
+ }
434
+ if (name === void 0) return void 0;
435
+ const accepted = DIRECTIVE_VOCABULARY[name] ?? {};
436
+ const keys = /* @__PURE__ */ new Map();
437
+ const sources = /* @__PURE__ */ new Map();
438
+ run.forEach((directive, index) => {
439
+ if (directive.name !== name) return;
440
+ for (const pair of directive.pairs) {
441
+ const values = accepted[pair.key];
442
+ if (values === void 0) continue;
443
+ if (values !== null && !values.includes(pair.value)) continue;
444
+ keys.set(pair.key, pair.value);
445
+ sources.set(pair.key, index);
446
+ }
447
+ });
448
+ return {
449
+ name,
450
+ keys,
451
+ sources
452
+ };
453
+ }
454
+ /**
360
455
  * `ws` is `[ \t\r\n]` — the design's grammar leaves it undefined, and `\n` has
361
456
  * to be in the set because a directive may legally wrap: a multi-line comment
362
457
  * is one node whose value contains the newlines.
@@ -462,10 +557,24 @@ function parseVantageDirective(comment) {
462
557
  pairs
463
558
  };
464
559
  }
560
+ /**
561
+ * A question directive's `leaning=` value as the page carries it: whitespace
562
+ * runs collapsed to one space, trimmed, and capped at `MAX_LEANING`.
563
+ *
564
+ * A wrapped directive puts newlines and indentation into the value. Two readers
565
+ * need the result and must agree on it: `rehypeVantageDirectives`, which stamps
566
+ * it as `data-vantage-leaning` for the one-click answer, and the planning index,
567
+ * which reports the leaning a question carries. An empty result means the
568
+ * question states no leaning.
569
+ */
570
+ function normalizeLeaning(raw) {
571
+ return raw.replace(/\s+/g, " ").trim().slice(0, 500);
572
+ }
465
573
  //#endregion
466
574
  //#region src/rehypeVantageDirectives.ts
467
575
  /**
468
- * What a `section`/`block` and an `oq` directive may **target**.
576
+ * What a `section`/`block` and a question directive (`oq`, `question`) may
577
+ * **target**.
469
578
  *
470
579
  * Both lists live in `vantageDirectives.ts`, with the reasoning for each tag,
471
580
  * because the CLI checker resolves the same question over mdast and must reach
@@ -475,6 +584,8 @@ function parseVantageDirective(comment) {
475
584
  */
476
585
  const STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
477
586
  const ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
587
+ /** What a `fallback` may withhold: every style target but a heading. */
588
+ const FALLBACK_TARGET_TAGS = new Set(VANTAGE_FALLBACK_TARGETS);
478
589
  const HEADING_DEPTHS = /* @__PURE__ */ new Map([
479
590
  ["h1", 1],
480
591
  ["h2", 2],
@@ -515,6 +626,19 @@ const RANGE_PROPERTIES = /* @__PURE__ */ new Map([["tone", "dataVantageTone"], [
515
626
  */
516
627
  const POINT_PROPERTIES = /* @__PURE__ */ new Map([["badge", "dataVantageBadge"]]);
517
628
  const RUN_PROPERTY = "dataVantageRun";
629
+ /**
630
+ * What a question stamps, whichever name declared it: `data-vantage-question`
631
+ * on every question, and `data-vantage-oq` besides on one an `oq` declared
632
+ * (`VANTAGE_QUESTION_ATTRIBUTE`, `VANTAGE_OQ_ATTRIBUTE`).
633
+ *
634
+ * Two attributes, and neither one ever means more than it says. Every release
635
+ * since 0.7 stamps `data-vantage-oq` on an `oq` and on nothing else, so a
636
+ * reader of this package's markup that has ever read it keeps reading exactly
637
+ * what it read; `data-vantage-question` is the one to read for "a question",
638
+ * and the question's state, from its marker, is what decides whether anything
639
+ * offers to answer it (`questionOffersTake`).
640
+ */
641
+ const QUESTION_PROPERTY = "dataVantageQuestion";
518
642
  const OQ_PROPERTY = "dataVantageOq";
519
643
  const LEANING_PROPERTY = "dataVantageLeaning";
520
644
  /**
@@ -549,8 +673,6 @@ const OQ_ID_PROPERTY = "dataVantageOqId";
549
673
  const COLLAPSED_PROPERTY = "dataVantageCollapsed";
550
674
  const COLLAPSE_GROUP_PROPERTY = "dataVantageCollapseGroup";
551
675
  const COLLAPSE_TOGGLE_PROPERTY = "dataVantageCollapseToggle";
552
- /** A review-comment body, not prose. Bounds the attribute; 500 is generous. */
553
- const MAX_LEANING = 500;
554
676
  /**
555
677
  * Nodes that may sit between a directive and its target.
556
678
  *
@@ -684,13 +806,14 @@ function stampStyle(children, targetIndex, name, pairs, state) {
684
806
  }
685
807
  }
686
808
  }
687
- function stampOq(target, pairs) {
688
- setProperty(target, OQ_PROPERTY, "true");
809
+ function stampQuestion(target, name, pairs) {
810
+ setProperty(target, QUESTION_PROPERTY, "true");
811
+ if (name === "oq") setProperty(target, OQ_PROPERTY, "true");
689
812
  const id = pairs.get("id");
690
813
  if (id !== void 0 && id !== "") setProperty(target, OQ_ID_PROPERTY, id);
691
814
  const leaning = pairs.get("leaning");
692
815
  if (leaning === void 0) return;
693
- const text = leaning.replace(/\s+/g, " ").trim().slice(0, MAX_LEANING);
816
+ const text = normalizeLeaning(leaning);
694
817
  if (text !== "") setProperty(target, LEANING_PROPERTY, text);
695
818
  }
696
819
  /**
@@ -705,18 +828,14 @@ function stampOq(target, pairs) {
705
828
  function stampRun(children, targetIndex, run, state) {
706
829
  const target = children[targetIndex];
707
830
  const style = /* @__PURE__ */ new Map();
708
- const oq = /* @__PURE__ */ new Map();
709
831
  let styleName;
710
- let hasOq = false;
711
832
  for (const directive of run) if (directive.name === "section" || directive.name === "block") {
712
833
  styleName = directive.name;
713
834
  for (const pair of directive.pairs) style.set(pair.key, pair.value);
714
- } else if (directive.name === "oq") {
715
- hasOq = true;
716
- for (const pair of directive.pairs) oq.set(pair.key, pair.value);
717
835
  }
718
836
  if (styleName !== void 0) stampStyle(children, targetIndex, styleName, style, state);
719
- if (hasOq && ANCHOR_TARGET_TAGS.has(target.tagName)) stampOq(target, oq);
837
+ const question = mergeQuestionRun(run);
838
+ if (question !== void 0 && ANCHOR_TARGET_TAGS.has(target.tagName)) stampQuestion(target, question.name, question.keys);
720
839
  }
721
840
  /** A directive comment, or `undefined` for anything else — malformed included. */
722
841
  function directiveOf(node) {
@@ -729,7 +848,7 @@ function directiveOf(node) {
729
848
  *
730
849
  * The whole tree, not just the root: `rehype-raw` leaves comment nodes inside
731
850
  * `blockquote`, inside `li`, inside `td` and inline inside `p`, and the real
732
- * Open Questions layout puts the `oq` directive inside a list item — so a
851
+ * Open Questions layout puts the question directive inside a list item — so a
733
852
  * root-only walk finds none of them.
734
853
  *
735
854
  * Pass order is also what resolves a nested section: an inner heading's
@@ -768,8 +887,66 @@ function processChildren(parent, state) {
768
887
  i = j;
769
888
  }
770
889
  }
890
+ /**
891
+ * Remove every block a `fallback` run lands on, with the run itself.
892
+ *
893
+ * A fallback block is written for the renderers that cannot show something
894
+ * Vantage shows — "This drawing needs Vantage 0.8 or later", beside an inline
895
+ * `<svg>`. Every renderer that does not know the name drops the comment and
896
+ * shows the block; this one never renders it, in any mode, in print, or
897
+ * through any of the renderers that share this plugin (D5).
898
+ *
899
+ * The run is found exactly as `processChildren` finds one — the same skip
900
+ * rules, the same first-element target, the same whole-tree walk — and it
901
+ * withholds when any directive in it is a `fallback` and the target is on
902
+ * `VANTAGE_FALLBACK_TARGETS`. Anything else is left for the stamping pass,
903
+ * where a `fallback` is inert.
904
+ *
905
+ * A pass of its own, and first, because a `section` reaches *forward* over its
906
+ * siblings: stamped in one pass, a toned section would count a fallback block
907
+ * after it among its members, give it the run's last `end` marker, and leave
908
+ * the last block anyone sees marked `middle`. Withheld first, the block was
909
+ * never there for any later decision — the section's extent, its run markers,
910
+ * a collapse group's members, a review anchor or an outline entry.
911
+ */
912
+ function withholdFallbacks(parent) {
913
+ const children = parent.children;
914
+ let i = 0;
915
+ while (i < children.length) {
916
+ const node = children[i];
917
+ if (node.type === "element") {
918
+ withholdFallbacks(node);
919
+ i++;
920
+ continue;
921
+ }
922
+ const first = directiveOf(node);
923
+ if (first === void 0) {
924
+ i++;
925
+ continue;
926
+ }
927
+ let withholds = first.name === "fallback";
928
+ let j = i + 1;
929
+ let targetIndex = -1;
930
+ for (; j < children.length; j++) {
931
+ const next = children[j];
932
+ if (next.type === "element") {
933
+ targetIndex = j;
934
+ break;
935
+ }
936
+ if (!isSkippable(next)) break;
937
+ if (directiveOf(next)?.name === "fallback") withholds = true;
938
+ }
939
+ const target = targetIndex >= 0 ? children[targetIndex] : void 0;
940
+ if (withholds && target?.type === "element" && FALLBACK_TARGET_TAGS.has(target.tagName)) {
941
+ children.splice(i, targetIndex - i + 1);
942
+ continue;
943
+ }
944
+ i = j;
945
+ }
946
+ }
771
947
  const rehypeVantageDirectives = () => {
772
948
  return (tree) => {
949
+ withholdFallbacks(tree);
773
950
  processChildren(tree, { nextGroup: 1 });
774
951
  };
775
952
  };
@@ -862,12 +1039,144 @@ const rehypeRestoreMathStamps = () => {
862
1039
  };
863
1040
  //#endregion
864
1041
  //#region src/sanitize.ts
1042
+ /** Whether an attribute definition is for `className`. */
1043
+ const isClassName = (definition) => (typeof definition === "string" ? definition : definition[0]) === "className";
1044
+ /**
1045
+ * The value list the default schema gives `className` on `tag`, or an empty
1046
+ * list if it gives none.
1047
+ */
1048
+ function defaultClasses(tag) {
1049
+ const entry = defaultSchema.attributes?.[tag]?.find(isClassName);
1050
+ return Array.isArray(entry) ? entry.slice(1) : [];
1051
+ }
1052
+ /**
1053
+ * The only classes an element keeps: the ones the pipeline itself emits on it
1054
+ * before `rehypeSanitize` runs. Every other element keeps none.
1055
+ *
1056
+ * **A class is a style by another name.** The app ships Tailwind's utility CSS
1057
+ * for its own markup, so `<div class="fixed inset-0 z-50 bg-white">` in a
1058
+ * document laid a white sheet over the whole Vantage window, header and
1059
+ * sidebar included. That is the overlay `SAFE_STYLE` bans `position` to
1060
+ * prevent, spelled as a class instead of a declaration, and `className` used
1061
+ * to be admitted with any value on every element. A document could also
1062
+ * borrow a class the app's own code looks for: `.overflow-y-auto` is how an
1063
+ * anchor link finds the element to scroll when no `data-content-scroll` is in
1064
+ * reach, and `.heading-anchor` is what the outline strips from a heading's
1065
+ * text. The comment-body sanitizer (`frontend/src/lib/commentMarkdown.ts`)
1066
+ * already refused `class` for the same reason.
1067
+ *
1068
+ * What each entry is for, and who emits it:
1069
+ *
1070
+ * - `code`: `language-*`, from a fence's info string (`mdast-util-to-hast`).
1071
+ * `rehype-highlight` reads it, `rehype-katex` keys on `language-math`, and
1072
+ * the viewers render `language-mermaid` as a diagram.
1073
+ * - `ul`, `ol` and `li`: `contains-task-list` and `task-list-item`, from GFM
1074
+ * task lists. `styles/task-list.css` draws the checkbox from them.
1075
+ * - `section`, `h2` and `a`: GFM footnotes (`mdast-util-to-hast`), on the
1076
+ * section, its label and each back-reference.
1077
+ * - `div`: `ALERT_TITLE_CLASS`, on the title `rehypeVantageAlerts` injects.
1078
+ *
1079
+ * `remark-math` also emits `math-display` and `math-inline` on its `code`.
1080
+ * They were never kept, because the default schema's `code` entry takes only
1081
+ * `language-*`, and nothing needs them: `rehype-katex` keys on `language-math`
1082
+ * and tells display from inline by whether the `code` sits in a `pre`.
1083
+ *
1084
+ * **A class added after the sanitizer needs no entry**, and that is most of
1085
+ * the classes the page styles: `rehype-highlight`'s `hljs-*`, KaTeX's output,
1086
+ * Mermaid's diagrams, and everything the app's components and hooks add. A
1087
+ * document that writes one of those on an element keeps nothing.
1088
+ *
1089
+ * **Mermaid source is the route this list cannot close.** Mermaid renders
1090
+ * after the sanitizer and copies the names a diagram's source gives a node
1091
+ * (`:::name`, `class A name`, `classDef`) onto that node, so a diagram can
1092
+ * carry any of the app's utilities on its own nodes. The diagram bounds it:
1093
+ * `position` does nothing on an SVG group, and the diagram's `svg` clips what
1094
+ * is inside it. `frontend/e2e/mermaid.spec.ts` measures that bound. The
1095
+ * stylesheet a diagram's source could write did reach past the diagram, and
1096
+ * `MERMAID_SECURE_KEYS` in `mermaidLoader.ts` refuses the settings that write
1097
+ * it.
1098
+ *
1099
+ * A document may still write these names on these elements. By the time the
1100
+ * sanitizer runs, a hand-written `<li class="task-list-item">` and the one GFM
1101
+ * emitted are the same node, and none of the names can lay an element over
1102
+ * anything outside the document's scroll container. `styles/task-list.css`
1103
+ * floats the checkbox of a `task-list-item` instead of positioning it
1104
+ * absolutely, so it stays in the document's flow whatever the item's own
1105
+ * `style` does, and the checkbox takes no `style` itself (see
1106
+ * `UNSTYLED_TAGS`). The footnote label's class, where a stylesheet defines it,
1107
+ * clips its element to a single pixel.
1108
+ *
1109
+ * **The footnote label's class is read from the default schema, never
1110
+ * spelled here, and the tests spell it in halves.** It is a Tailwind utility,
1111
+ * and Tailwind generates a utility for every class name it finds in the files
1112
+ * it scans, comments included — this directory and all of `frontend/`. The app
1113
+ * renders the label as a visible heading only because nothing it scans spells
1114
+ * that name. Spelled whole in a test, it generated the rule, and the label
1115
+ * would have been hidden from sight as it is on GitHub: a change of rendering,
1116
+ * not a sanitizer fix.
1117
+ *
1118
+ * The list is measured, not remembered. The test "takes no class from
1119
+ * anything the pipeline emits" in `frontend/src/lib/sanitize.test.ts` records
1120
+ * every class on the tree on both sides of `rehypeSanitize`, over a document
1121
+ * that uses every feature that emits one, so a plugin that starts emitting a
1122
+ * new class fails there instead of losing it in every renderer.
1123
+ */
1124
+ const PIPELINE_CLASSES = {
1125
+ code: [/^language-./],
1126
+ ul: ["contains-task-list"],
1127
+ ol: ["contains-task-list"],
1128
+ li: ["task-list-item"],
1129
+ section: ["footnotes"],
1130
+ h2: defaultClasses("h2"),
1131
+ a: ["data-footnote-backref"],
1132
+ div: [ALERT_TITLE_CLASS]
1133
+ };
865
1134
  /**
866
- * Sanitization schema for the rendering pipeline.
867
- * Allows GFM, KaTeX MathML, syntax highlighting classes, and
868
- * data-source-line attributes while blocking XSS vectors.
869
- */
870
- const DECLARATION = `(?:(?:${[
1135
+ * `tag`'s attributes: the default schema's, less any `className`, then its
1136
+ * `PIPELINE_CLASSES` entry, then `extra`.
1137
+ *
1138
+ * **Always a value list, never a bare `"className"`.** `hast-util-sanitize`
1139
+ * reads a bare name, or a tuple holding no values, as "any value", and an
1140
+ * element's own entry is consulted before `*`'s — the first match wins, and
1141
+ * `*` is not consulted when that element's own entry filters a class list down
1142
+ * to nothing. An element with no entry has none added.
1143
+ */
1144
+ function withClasses(tag, ...extra) {
1145
+ const classes = PIPELINE_CLASSES[tag] ?? [];
1146
+ const own = (defaultSchema.attributes?.[tag] ?? []).filter((definition) => !isClassName(definition));
1147
+ if (classes.length > 0) own.push(["className", ...classes]);
1148
+ return [...own, ...extra];
1149
+ }
1150
+ /**
1151
+ * CSS properties an inline `style` may set.
1152
+ *
1153
+ * **The only `style` this list ever filters is one a document wrote by hand.**
1154
+ * Nothing the pipeline generates reaches it: `rehypeKatex` and `rehypeHighlight`
1155
+ * both run *after* `rehypeSanitize` (`pipeline.ts`), so their output is trusted
1156
+ * rather than filtered, and `remark-gfm` emits table alignment as an `align`
1157
+ * attribute rather than as CSS. So this is a filter on untrusted author HTML and
1158
+ * nothing else — which is exactly the hole that made it necessary: `<div
1159
+ * style="position:fixed;inset:0">` covered the viewport and
1160
+ * `style="background:url(https://…)"` called home on render, both verbatim,
1161
+ * because `rehype-sanitize` does not parse CSS. Scripts were never the risk
1162
+ * here; layout and network were.
1163
+ *
1164
+ * The list is therefore deliberately typographic: the styling a prose document
1165
+ * has any business asking for. It is *not* sized to KaTeX, and a KaTeX release
1166
+ * that starts using a new property is a non-event here — `\pmb` already emits
1167
+ * `text-shadow`, which is not on this list and renders anyway.
1168
+ *
1169
+ * **The design doc used to argue the opposite — that `style` had to be allowed
1170
+ * and `position` enumerated because KaTeX needs them — and it was wrong.** The
1171
+ * measurement behind it was real (KaTeX does emit `position:relative` on every
1172
+ * integral) but the inference was not, because the sanitizer has finished before
1173
+ * the first KaTeX span exists. Rebuilding the shipped rehype order with a filter
1174
+ * that rejects *every* value leaves all ten of the integral's style attributes
1175
+ * untouched. The "Security" section of `docs/reference/inline-markup.md`
1176
+ * records the correction; the test that would catch a reordering is in
1177
+ * `frontend/src/lib/sanitize.test.ts`.
1178
+ */
1179
+ const SAFE_STYLE_PROPERTIES = [
871
1180
  "height",
872
1181
  "min-height",
873
1182
  "max-height",
@@ -922,12 +1231,89 @@ const DECLARATION = `(?:(?:${[
922
1231
  "white-space",
923
1232
  "vertical-align",
924
1233
  "list-style-type",
925
- "display",
926
1234
  "float",
927
1235
  "clear",
928
1236
  "opacity",
929
1237
  "overflow"
930
- ].join("|")})\\s*:[^;:()"'\\\\]*)`;
1238
+ ];
1239
+ /**
1240
+ * A `style` value we will keep, as a whole.
1241
+ *
1242
+ * One rule beyond the property list does the work: **no parentheses anywhere**,
1243
+ * which closes `url(…)` and `expression(…)` in one stroke — the network and
1244
+ * legacy-script vectors. Its cost is borne entirely by authors, who lose
1245
+ * `calc()`, `rgb()` and `var()` along with them; that is the trade, and it is
1246
+ * worth it for a filter this small.
1247
+ *
1248
+ * **`position` is not on the property list at all**, so every value of it is
1249
+ * refused — `static` and `relative` along with `fixed` and `sticky`. It used to
1250
+ * be enumerated, on the belief that KaTeX needed `relative`; KaTeX renders after
1251
+ * the sanitizer and never meets this regex, so the enumeration was buying
1252
+ * nothing but the residual it conceded. Banning the property closes that
1253
+ * residual, and it is bigger than "overlaps its neighbors" made it sound:
1254
+ * measured in Chrome against the viewer's real ancestor chain, an author's
1255
+ * `position:absolute;top:0;left:0;width:100%;height:100%` is sized to the whole
1256
+ * content pane (the nearest positioned ancestor is outside the scroll
1257
+ * container), is not clipped by the scroller, and survives scrolling to the end
1258
+ * of the document. It was `position:fixed` in all but the keyword.
1259
+ *
1260
+ * Matching is all-or-nothing: one unrecognized declaration drops the whole
1261
+ * attribute, and the element renders unstyled rather than partly styled. That
1262
+ * is the safe direction to fail, and it degrades to plain text rather than to a
1263
+ * broken page.
1264
+ *
1265
+ * **The grammar must stay unambiguous, and `;` is what keeps it so.** The value
1266
+ * class is "anything but the delimiters", which includes whitespace — a value
1267
+ * legitimately contains it (`margin: 0 auto`). So if whitespace could *also*
1268
+ * end a declaration, both constructs would compete for the same characters and
1269
+ * the match would fork at every declaration; on a value that ultimately fails,
1270
+ * the engine explores every fork. An earlier form of this regex separated
1271
+ * declarations with `\s*;?\s*`, and 200 document-controlled characters took the
1272
+ * renderer — and the CLI checker, and therefore CI — 94 seconds. Requiring `;`
1273
+ * pins each declaration's extent to the delimiter positions, so there is exactly
1274
+ * one way to parse any input and rejection is linear. `VALUE` absorbs the
1275
+ * padding on both sides for the same reason: a separate `\\s*` next to it would
1276
+ * put the ambiguity straight back. Pinned by the flat-time test in
1277
+ * `frontend/src/lib/sanitize.test.ts` — do not loosen the separator.
1278
+ *
1279
+ * Residual, stated plainly and now genuinely small: negative `margin` still lets
1280
+ * an element overlap its neighbors *inside the flow*. That one scrolls with the
1281
+ * content and is clipped by the scroll container, and closing it means giving up
1282
+ * margins, which prose actually uses. Containment in the stylesheet, not another
1283
+ * rule here, is what would close it. `overflow: visible` on an inline `<svg>` is
1284
+ * the same residual by another route — see `SVG_ROOT_ATTRIBUTES`.
1285
+ *
1286
+ * **The app's table of contents is inside that scroll container too**, beside
1287
+ * the document column, so a block carried over it took its clicks whenever the
1288
+ * block painted in the positioned layer: one with `opacity`, a toned block, a
1289
+ * heading, or a link to anywhere. The contents sit at `z-10` for that
1290
+ * (`frontend/src/components/TableOfContents.tsx`), and a document cannot climb
1291
+ * back over them only because `z-index` is not on the property list.
1292
+ */
1293
+ const VALUE = `[^;:()"'\\\\]*`;
1294
+ const DISPLAY_DECLARATION = `display\\s*:\\s*(?:${[
1295
+ "none",
1296
+ "block",
1297
+ "inline-block",
1298
+ "inline-flex",
1299
+ "inline-grid",
1300
+ "inline-table",
1301
+ "inline",
1302
+ "flow-root",
1303
+ "flex",
1304
+ "grid",
1305
+ "table-row-group",
1306
+ "table-row",
1307
+ "table-header-group",
1308
+ "table-footer-group",
1309
+ "table-cell",
1310
+ "table-column-group",
1311
+ "table-column",
1312
+ "table-caption",
1313
+ "table",
1314
+ "list-item"
1315
+ ].join("|")})\\s*(?:!\\s*important\\s*)?`;
1316
+ const DECLARATION = `(?:(?:${SAFE_STYLE_PROPERTIES.join("|")})\\s*:${VALUE}|${DISPLAY_DECLARATION})`;
931
1317
  const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "i");
932
1318
  /**
933
1319
  * A collapse group id: one or more digits, anchored.
@@ -939,6 +1325,281 @@ const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "
939
1325
  */
940
1326
  const COLLAPSE_GROUP_ID = /^[0-9]+$/;
941
1327
  /**
1328
+ * Inline SVG, admitted as static drawing and nothing else.
1329
+ *
1330
+ * The allowlist is shapes, text and grouping. Everything in SVG that can run
1331
+ * code, fetch, or reach outside the element is refused: `script`,
1332
+ * `foreignObject`, `use` and `image` (both take an `href`), `xlink:href` on
1333
+ * `a`, the `animate`/`set` family (which can rewrite an `href` after the
1334
+ * sanitizer has looked), `style` elements, and every attribute that takes a
1335
+ * `url(…)` reference — `filter`, `mask`, `clip-path`, `marker-*`, `cursor`.
1336
+ *
1337
+ * Gradients, patterns and `<defs>` are out for a second reason: they are only
1338
+ * reachable through `url(#id)`, and the sanitizer prefixes every `id` with
1339
+ * `user-content-`, so the reference would dangle even if it were allowed.
1340
+ *
1341
+ * **Refusing an element is not the same as removing it.** `hast-util-sanitize`
1342
+ * *unwraps* an element it does not allow — drops the tag and keeps its
1343
+ * children — unless the tag is on `strip`. See `SVG_STRIPPED` for why that
1344
+ * matters here, and for the pass that removes those containers instead.
1345
+ *
1346
+ * `switch` is admitted, as a plain container, because draw.io wraps every label
1347
+ * in one: `<switch><foreignObject>…</foreignObject><text>…</text></switch>`. A
1348
+ * `switch` renders only its first child whose conditions hold, and with the
1349
+ * `foreignObject` removed and the conditional attributes (`requiredFeatures`,
1350
+ * `systemLanguage`) refused, that is the `<text>` fallback. Unwrapped instead,
1351
+ * it would render *every* branch — including the "Text is not SVG - cannot
1352
+ * display" notice draw.io appends for viewers without `foreignObject`.
1353
+ *
1354
+ * Every child requires an `svg` ancestor, so no shape or text element is
1355
+ * admitted into HTML flow on its own.
1356
+ *
1357
+ * `title` and `desc` are not children at all. They are admitted nowhere, inside
1358
+ * a drawing they are removed with their contents (see `SVG_STRIPPED`), and
1359
+ * `aria-label` on the root is the drawing's accessible name. Both are HTML
1360
+ * integration points — the parser reads what is inside them as HTML — and an
1361
+ * `svg` ancestor turned out not to be enough to keep a `<title>` in SVG
1362
+ * context. `<math><svg><title>` keeps MathML context, and React hoists any
1363
+ * `title` outside SVG context into the page's `<head>`, so the document set the
1364
+ * tab title. In `renderMarkdown`'s string output, `<svg><desc><title>`,
1365
+ * `<svg><title><title>` and a `title` inside a `foreignObject` all re-parse into
1366
+ * an HTML `title`, which is what `document.title` reads on a page that has none
1367
+ * of its own.
1368
+ */
1369
+ const SVG_CHILD_TAGS = [
1370
+ "g",
1371
+ "path",
1372
+ "rect",
1373
+ "circle",
1374
+ "ellipse",
1375
+ "line",
1376
+ "polyline",
1377
+ "polygon",
1378
+ "text",
1379
+ "tspan",
1380
+ "switch"
1381
+ ];
1382
+ /**
1383
+ * Refused SVG elements removed *with their contents* when they sit inside an
1384
+ * `<svg>`, rather than unwrapped.
1385
+ *
1386
+ * Each of these holds children that are never meant to be painted where they
1387
+ * stand: a clip region, a mask, a gradient's stops, a marker's arrowhead, a
1388
+ * symbol's body, a filter's primitives, an exporter's RDF, the text of a
1389
+ * `title` or `desc` (see `SVG_CHILD_TAGS`), or — for `foreignObject` — HTML.
1390
+ * Unwrapped, those children land in the drawing as ordinary shapes. A default
1391
+ * Figma export ends in `<defs><clipPath><rect fill="white"/>`, and unwrapping
1392
+ * it painted that white rect over the whole drawing; a `<marker>` arrowhead
1393
+ * became a stray triangle at the origin. The HTML inside a `foreignObject`
1394
+ * survived as descendants of `svg`, which React then created as invisible
1395
+ * SVG-namespace `div`s and `p`s — the anchor an Open Question button looks for
1396
+ * — and which a browser re-parsing `renderMarkdown`'s string output breaks out
1397
+ * of the `svg` at, spilling the rest of the drawing into the page.
1398
+ *
1399
+ * **Only inside an `<svg>`, which is why this is a pass and not the schema's
1400
+ * `strip`.** `strip` removes a tag wherever it stands, and most of these names
1401
+ * are lowercase in prose too. There a bare `<pattern>` on a line of its own is
1402
+ * an HTML element nothing closes, so every Markdown block after it was parsed
1403
+ * into it, and stripping it removed the rest of the document — in every
1404
+ * renderer, with `vantage-check` silent. Inside a drawing the same container is
1405
+ * bounded by the `svg`, because a Markdown paragraph or heading breaks out of
1406
+ * SVG. Outside one the sanitizer unwraps these like any tag it does not know:
1407
+ * `title` and `desc` are not admitted anywhere, so no `title` element survives
1408
+ * to be hoisted, and what they held is text or ordinary sanitized HTML.
1409
+ *
1410
+ * Names compare case-insensitively. The camel-cased ones are how the HTML
1411
+ * parser spells these elements in SVG, but under `<math><svg>` the parser stays
1412
+ * in MathML and leaves them lowercase.
1413
+ */
1414
+ const SVG_STRIPPED = [
1415
+ "foreignObject",
1416
+ "defs",
1417
+ "clipPath",
1418
+ "mask",
1419
+ "pattern",
1420
+ "marker",
1421
+ "symbol",
1422
+ "linearGradient",
1423
+ "radialGradient",
1424
+ "filter",
1425
+ "metadata",
1426
+ "title",
1427
+ "desc"
1428
+ ];
1429
+ const SVG_STRIPPED_NAMES = new Set(SVG_STRIPPED.map((name) => name.toLowerCase()));
1430
+ /**
1431
+ * The camel-cased `SVG_STRIPPED` names, which the schema strips on its own.
1432
+ *
1433
+ * Only an SVG-namespace element carries one of these spellings, so stripping
1434
+ * them everywhere costs prose nothing. It keeps the worst of the list — the
1435
+ * HTML inside a `foreignObject` — out of a drawing for a consumer who hands
1436
+ * `sanitizeSchema` to `rehype-sanitize` without the rest of `buildPipeline`.
1437
+ */
1438
+ const SVG_STRIPPED_EVERYWHERE = SVG_STRIPPED.filter((name) => name !== name.toLowerCase());
1439
+ function stripSvgContainers(parent, inSvg) {
1440
+ if (inSvg) parent.children = parent.children.filter((child) => child.type !== "element" || !SVG_STRIPPED_NAMES.has(child.tagName.toLowerCase()));
1441
+ for (const child of parent.children) if (child.type === "element") stripSvgContainers(child, inSvg || child.tagName.toLowerCase() === "svg");
1442
+ }
1443
+ /**
1444
+ * Remove every `SVG_STRIPPED` element below an `svg`, with its contents.
1445
+ *
1446
+ * Runs immediately before `rehypeSanitize` (`pipeline.ts`), and only when the
1447
+ * pipeline sanitizes: with the sanitizer off these elements are left to work
1448
+ * as SVG defines them.
1449
+ */
1450
+ function rehypeStripSvgContainers() {
1451
+ return (tree) => {
1452
+ stripSvgContainers(tree, false);
1453
+ };
1454
+ }
1455
+ /**
1456
+ * A paint value: a keyword, a named color, or a hex color. No parentheses, for
1457
+ * the same reason `SAFE_STYLE` refuses them — `fill="url(https://…)"` is a
1458
+ * render-time fetch, and `rgb()` is the price of closing it.
1459
+ */
1460
+ const SVG_PAINT = /^(?:#[0-9a-f]{3,8}|[a-z]+)$/i;
1461
+ /**
1462
+ * Attributes any SVG element may carry, drawing and typography alike.
1463
+ *
1464
+ * **Each entry is a hast property name, which is not always the camel case of
1465
+ * the attribute.** `property-information` decides the spelling, and it has
1466
+ * `strokeLineCap` for `stroke-linecap` — a naive `strokeLinecap` matches
1467
+ * nothing, and the attribute is dropped in every renderer without a word. The
1468
+ * table-driven test in `frontend/src/lib/sanitize.test.ts` renders every entry
1469
+ * in its written spelling, so a misspelled one fails there.
1470
+ *
1471
+ * `stroke-dasharray` (and with it `stroke-dashoffset`, which does nothing
1472
+ * alone) is refused, because it lets a few bytes buy an unbounded paint. The
1473
+ * number of dashes is the path's length over the dash period, both in user
1474
+ * units the document chooses. Measured in headless Chromium, fifty 90-byte
1475
+ * paths with a 0.0011 dash took 21 s to paint and a hundred took 37 s. A value
1476
+ * grammar cannot bound it: refusing sub-unit dashes is undone by scaling the
1477
+ * user units, and the same fifty paths with `stroke-dasharray="1.1"` under a
1478
+ * `viewBox` a thousand times larger still took 15 s.
1479
+ */
1480
+ const SVG_ATTRIBUTES = [
1481
+ ["fill", SVG_PAINT],
1482
+ ["stroke", SVG_PAINT],
1483
+ "fillOpacity",
1484
+ "fillRule",
1485
+ "strokeOpacity",
1486
+ "strokeWidth",
1487
+ "strokeLineCap",
1488
+ "strokeLineJoin",
1489
+ "opacity",
1490
+ "transform",
1491
+ "fontFamily",
1492
+ "fontSize",
1493
+ "fontWeight",
1494
+ "fontStyle",
1495
+ "letterSpacing",
1496
+ "textAnchor",
1497
+ "dominantBaseline",
1498
+ "x",
1499
+ "y",
1500
+ "x1",
1501
+ "y1",
1502
+ "x2",
1503
+ "y2",
1504
+ "cx",
1505
+ "cy",
1506
+ "r",
1507
+ "rx",
1508
+ "ry",
1509
+ "dx",
1510
+ "dy",
1511
+ "d",
1512
+ "points"
1513
+ ];
1514
+ /**
1515
+ * The root `<svg>`'s attributes: `SVG_ATTRIBUTES` less `transform`, plus the
1516
+ * viewport, a `role` from a closed list, and the accessible name.
1517
+ *
1518
+ * `transform` on the root is not an SVG transform at all. The root is an
1519
+ * in-flow CSS box, and the attribute becomes a CSS transform of that box, so
1520
+ * `<svg width="10" height="10" transform="translate(-300 -300) scale(80)">`
1521
+ * laid out as a 10px box and painted as an 800px one over the paragraphs
1522
+ * around it. On a child it moves shapes inside the viewport, which clips them.
1523
+ *
1524
+ * Residual: that clip is the root's `overflow`, which `SAFE_STYLE` lets a
1525
+ * document set. `style="overflow:visible"` on the root lets a child with a
1526
+ * large `transform` or large coordinates paint over its neighbors — measured in
1527
+ * Chromium, the same overlap. It is the negative-`margin` residual described at
1528
+ * `SAFE_STYLE` by another route: it stays in the flow, scrolls with the content,
1529
+ * and the scroll container clips it.
1530
+ */
1531
+ const SVG_ROOT_ATTRIBUTES = [
1532
+ ...SVG_ATTRIBUTES.filter((attribute) => attribute !== "transform"),
1533
+ "xmlns",
1534
+ "viewBox",
1535
+ "preserveAspectRatio",
1536
+ [
1537
+ "role",
1538
+ "img",
1539
+ "presentation",
1540
+ "none"
1541
+ ],
1542
+ "ariaLabel"
1543
+ ];
1544
+ /** Every element the sanitizer admits. */
1545
+ const TAG_NAMES = [
1546
+ ...defaultSchema.tagNames || [],
1547
+ "math",
1548
+ "semantics",
1549
+ "mrow",
1550
+ "mi",
1551
+ "mo",
1552
+ "mn",
1553
+ "msup",
1554
+ "msub",
1555
+ "mfrac",
1556
+ "mover",
1557
+ "munder",
1558
+ "msqrt",
1559
+ "mroot",
1560
+ "mtable",
1561
+ "mtr",
1562
+ "mtd",
1563
+ "mtext",
1564
+ "mspace",
1565
+ "annotation",
1566
+ "figure",
1567
+ "figcaption",
1568
+ "summary",
1569
+ "details",
1570
+ "svg",
1571
+ ...SVG_CHILD_TAGS
1572
+ ];
1573
+ /**
1574
+ * The admitted elements that take no `style`: only `input`, which GFM emits as
1575
+ * a task list's checkbox, and never with a style.
1576
+ *
1577
+ * `styles/task-list.css` draws that checkbox and positions it, so on it `top`
1578
+ * and `left` are not the no-ops they are on an element nothing positions, and
1579
+ * with `width`, `height` and `background-color` beside them a document laid a
1580
+ * box of its own over the page. Measured in Chromium at 1280x800 while the
1581
+ * checkbox was positioned absolutely,
1582
+ * `top:-3000px;left:-3000px;width:9000px;height:9000px;background-color:white`
1583
+ * on it covered the whole content pane. It needed no class written:
1584
+ * GFM puts `task-list-item` on the item, and a checkbox typed into the item's
1585
+ * text is a child of that item too.
1586
+ *
1587
+ * **This is why `style` is on each element's own entry, never on `*`.**
1588
+ * `hast-util-sanitize` consults `*` whenever an element's own entry yields
1589
+ * nothing, so no entry on `input` could refuse a value that `*` admits. An
1590
+ * element takes `style` only by being in `TAG_NAMES` and not in this set.
1591
+ */
1592
+ const UNSTYLED_TAGS = /* @__PURE__ */ new Set(["input"]);
1593
+ /**
1594
+ * `attributes`, with `["style", SAFE_STYLE]` added to the entry of every
1595
+ * element in `TAG_NAMES` but the `UNSTYLED_TAGS`.
1596
+ */
1597
+ function withStyle(attributes) {
1598
+ const styled = { ...attributes };
1599
+ for (const tag of new Set(TAG_NAMES)) if (!UNSTYLED_TAGS.has(tag)) styled[tag] = [...styled[tag] ?? [], ["style", SAFE_STYLE]];
1600
+ return styled;
1601
+ }
1602
+ /**
942
1603
  * Never set `allowComments` here.
943
1604
  *
944
1605
  * `hast-util-sanitize` drops comment nodes because that boolean defaults to
@@ -952,38 +1613,16 @@ const COLLAPSE_GROUP_ID = /^[0-9]+$/;
952
1613
  */
953
1614
  const sanitizeSchema = {
954
1615
  ...defaultSchema,
955
- tagNames: [
956
- ...defaultSchema.tagNames || [],
957
- "math",
958
- "semantics",
959
- "mrow",
960
- "mi",
961
- "mo",
962
- "mn",
963
- "msup",
964
- "msub",
965
- "mfrac",
966
- "mover",
967
- "munder",
968
- "msqrt",
969
- "mroot",
970
- "mtable",
971
- "mtr",
972
- "mtd",
973
- "mtext",
974
- "mspace",
975
- "annotation",
976
- "figure",
977
- "figcaption",
978
- "summary",
979
- "details"
980
- ],
981
- attributes: {
1616
+ strip: [...defaultSchema.strip || [], ...SVG_STRIPPED_EVERYWHERE],
1617
+ tagNames: TAG_NAMES,
1618
+ ancestors: {
1619
+ ...defaultSchema.ancestors,
1620
+ ...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, ["svg"]]))
1621
+ },
1622
+ attributes: withStyle({
982
1623
  ...defaultSchema.attributes,
983
1624
  "*": [
984
1625
  ...defaultSchema.attributes?.["*"] || [],
985
- "className",
986
- ["style", SAFE_STYLE],
987
1626
  "dataSourceLine",
988
1627
  ["dataVantageTone", ...VANTAGE_TONES],
989
1628
  ["dataVantageEmphasis", ...VANTAGE_EMPHASIS],
@@ -993,35 +1632,22 @@ const sanitizeSchema = {
993
1632
  ["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
994
1633
  ["dataVantageRun", ...VANTAGE_RUNS],
995
1634
  ["dataVantageOq", "true"],
1635
+ ["dataVantageQuestion", "true"],
996
1636
  ["dataVantageOqId", VANTAGE_OQ_ID],
997
1637
  ["dataVantageAlert", ...VANTAGE_ALERTS],
998
1638
  "dataVantageLeaning"
999
1639
  ],
1000
- code: [...defaultSchema.attributes?.code || [], "className"],
1001
- span: [
1002
- ...defaultSchema.attributes?.span || [],
1003
- "className",
1004
- ["style", SAFE_STYLE]
1005
- ],
1006
- div: [
1007
- ...defaultSchema.attributes?.div || [],
1008
- "className",
1009
- ["style", SAFE_STYLE]
1010
- ],
1011
- a: [
1012
- ...defaultSchema.attributes?.a || [],
1013
- "id",
1014
- "className"
1015
- ],
1640
+ ...Object.fromEntries(Object.keys(PIPELINE_CLASSES).map((tag) => [tag, withClasses(tag)])),
1641
+ a: withClasses("a", "id"),
1016
1642
  math: ["xmlns"],
1017
1643
  annotation: ["encoding"],
1018
1644
  img: [...defaultSchema.attributes?.img || [], "loading"],
1019
- td: [...defaultSchema.attributes?.td || [], ["style", SAFE_STYLE]],
1020
- th: [...defaultSchema.attributes?.th || [], ["style", SAFE_STYLE]]
1021
- }
1645
+ svg: SVG_ROOT_ATTRIBUTES,
1646
+ ...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, SVG_ATTRIBUTES]))
1647
+ })
1022
1648
  };
1023
1649
  //#endregion
1024
- //#region src/pipeline.ts
1650
+ //#region src/remarkPlugins.ts
1025
1651
  /**
1026
1652
  * The mdast half of the chain. Exported on its own because there is a real
1027
1653
  * mdast-only consumer: the CLI checker parses documents without ever running
@@ -1035,6 +1661,8 @@ function buildRemarkPlugins(options = {}) {
1035
1661
  if (math) plugins.push([remarkMath, { singleDollarTextMath: false }]);
1036
1662
  return plugins;
1037
1663
  }
1664
+ //#endregion
1665
+ //#region src/pipeline.ts
1038
1666
  /** The hast half. Deliberately not exported: see `buildPipeline`. */
1039
1667
  function buildRehypePlugins(options = {}) {
1040
1668
  const { math = true, highlight = true, sourceLines = true, sanitize = true, bodyLineOffset = 0 } = options;
@@ -1042,7 +1670,7 @@ function buildRehypePlugins(options = {}) {
1042
1670
  if (sourceLines) plugins.push([rehypeSourceLines, { offset: bodyLineOffset }]);
1043
1671
  plugins.push(rehypeVantageAlerts);
1044
1672
  plugins.push(rehypeVantageDirectives);
1045
- if (sanitize) plugins.push([rehypeSanitize, sanitizeSchema]);
1673
+ if (sanitize) plugins.push(rehypeStripSvgContainers, [rehypeSanitize, sanitizeSchema]);
1046
1674
  plugins.push(rehypeVantageAnchors);
1047
1675
  plugins.push(rehypeSlug);
1048
1676
  if (highlight) plugins.push(rehypeHighlight);
@@ -1326,6 +1954,9 @@ const cacheKey = (code, theme) => `${theme}\u0000${code}`;
1326
1954
  function getCachedSvg(code, theme = currentMermaidPalette()) {
1327
1955
  return svgCache.get(cacheKey(code, theme));
1328
1956
  }
1957
+ function hasCachedSvg(code, theme = currentMermaidPalette()) {
1958
+ return svgCache.has(cacheKey(code, theme));
1959
+ }
1329
1960
  function setCachedSvg(code, svg, theme = currentMermaidPalette()) {
1330
1961
  svgCache.set(cacheKey(code, theme), svg);
1331
1962
  }
@@ -1338,6 +1969,43 @@ let mermaidLoading = null;
1338
1969
  * configured for, `null` until loaded.
1339
1970
  */
1340
1971
  let configuredTheme = null;
1972
+ /**
1973
+ * The configuration keys a diagram's own source may not set, from an `init`
1974
+ * directive or from frontmatter: Mermaid's default list, then the three
1975
+ * that write a diagram's stylesheet.
1976
+ *
1977
+ * **A diagram's stylesheet reaches past the diagram.** Mermaid puts every
1978
+ * selector of it under the diagram's id, but leaves the names of its
1979
+ * `@keyframes` global, so a diagram that defined `flash-update` redefined the
1980
+ * animation the viewer plays on a block that just changed. Given
1981
+ * `position: fixed` at the size of the window, the next flash laid that block,
1982
+ * and the link it held, over the header and the sidebar. The same stylesheet
1983
+ * fetched images from any host, which is why `SAFE_STYLE` refuses parentheses.
1984
+ *
1985
+ * - `themeCSS` is a stylesheet, and Mermaid checks only that its braces pair.
1986
+ * - `fontFamily` is copied into `themeVariables` *after* Mermaid's check on
1987
+ * theme variables has run, and lands in the stylesheet as a declaration's
1988
+ * value, where a nested block becomes a rule of its own.
1989
+ * - `altFontFamily` lands in a rule the browser parses first, which drops
1990
+ * what does not belong. It is refused anyway: nothing needs it, and that
1991
+ * parse is all that stands in the way.
1992
+ *
1993
+ * Mermaid applies the list at every depth, so `themeVariables.fontFamily` in
1994
+ * a diagram is refused too. The list is spelled whole rather than relying on
1995
+ * Mermaid merging it with its default. `frontend/e2e/mermaid.spec.ts` measures
1996
+ * all four routes; see "Mermaid" in `docs/reference/inline-markup.md`.
1997
+ */
1998
+ const MERMAID_SECURE_KEYS = [
1999
+ "secure",
2000
+ "securityLevel",
2001
+ "startOnLoad",
2002
+ "maxTextSize",
2003
+ "suppressErrorRendering",
2004
+ "maxEdges",
2005
+ "themeCSS",
2006
+ "fontFamily",
2007
+ "altFontFamily"
2008
+ ];
1341
2009
  function configure(m, palette) {
1342
2010
  const theme = mermaidThemeOf(palette);
1343
2011
  m.initialize({
@@ -1345,7 +2013,8 @@ function configure(m, palette) {
1345
2013
  theme,
1346
2014
  themeVariables: mermaidThemeVariables(theme),
1347
2015
  securityLevel: "strict",
1348
- suppressErrorRendering: true
2016
+ suppressErrorRendering: true,
2017
+ secure: MERMAID_SECURE_KEYS
1349
2018
  });
1350
2019
  configuredTheme = palette;
1351
2020
  }
@@ -1388,7 +2057,7 @@ const AlertTriangleIcon = () => /* @__PURE__ */ jsxs("svg", {
1388
2057
  strokeWidth: "2",
1389
2058
  strokeLinecap: "round",
1390
2059
  strokeLinejoin: "round",
1391
- className: "w-4 h-4 shrink-0",
2060
+ className: "block w-4 h-4 shrink-0",
1392
2061
  children: [
1393
2062
  /* @__PURE__ */ 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" }),
1394
2063
  /* @__PURE__ */ jsx("path", { d: "M12 9v4" }),
@@ -1404,7 +2073,7 @@ const ChevronDownIcon = () => /* @__PURE__ */ jsx("svg", {
1404
2073
  strokeWidth: "2",
1405
2074
  strokeLinecap: "round",
1406
2075
  strokeLinejoin: "round",
1407
- className: "w-3 h-3",
2076
+ className: "block w-3 h-3",
1408
2077
  children: /* @__PURE__ */ jsx("path", { d: "m6 9 6 6 6-6" })
1409
2078
  });
1410
2079
  const ChevronUpIcon = () => /* @__PURE__ */ jsx("svg", {
@@ -1416,7 +2085,7 @@ const ChevronUpIcon = () => /* @__PURE__ */ jsx("svg", {
1416
2085
  strokeWidth: "2",
1417
2086
  strokeLinecap: "round",
1418
2087
  strokeLinejoin: "round",
1419
- className: "w-3 h-3",
2088
+ className: "block w-3 h-3",
1420
2089
  children: /* @__PURE__ */ jsx("path", { d: "m18 15-6-6-6 6" })
1421
2090
  });
1422
2091
  const MaximizeIcon = () => /* @__PURE__ */ jsxs("svg", {
@@ -1428,7 +2097,7 @@ const MaximizeIcon = () => /* @__PURE__ */ jsxs("svg", {
1428
2097
  strokeWidth: "2",
1429
2098
  strokeLinecap: "round",
1430
2099
  strokeLinejoin: "round",
1431
- className: "w-4 h-4 text-gray-600 dark:text-slate-200",
2100
+ className: "block w-4 h-4 text-gray-600 dark:text-slate-200",
1432
2101
  children: [
1433
2102
  /* @__PURE__ */ jsx("polyline", { points: "15 3 21 3 21 9" }),
1434
2103
  /* @__PURE__ */ jsx("polyline", { points: "9 21 3 21 3 15" }),
@@ -1455,7 +2124,7 @@ const CloseIcon = () => /* @__PURE__ */ jsxs("svg", {
1455
2124
  strokeWidth: "2",
1456
2125
  strokeLinecap: "round",
1457
2126
  strokeLinejoin: "round",
1458
- className: "w-5 h-5",
2127
+ className: "block w-5 h-5",
1459
2128
  children: [/* @__PURE__ */ jsx("path", { d: "M18 6 6 18" }), /* @__PURE__ */ jsx("path", { d: "m6 6 12 12" })]
1460
2129
  });
1461
2130
  const PlusIcon = () => /* @__PURE__ */ jsxs("svg", {
@@ -1467,7 +2136,7 @@ const PlusIcon = () => /* @__PURE__ */ jsxs("svg", {
1467
2136
  strokeWidth: "2",
1468
2137
  strokeLinecap: "round",
1469
2138
  strokeLinejoin: "round",
1470
- className: "w-4 h-4",
2139
+ className: "block w-4 h-4",
1471
2140
  children: [/* @__PURE__ */ jsx("path", { d: "M5 12h14" }), /* @__PURE__ */ jsx("path", { d: "M12 5v14" })]
1472
2141
  });
1473
2142
  const MinusIcon = () => /* @__PURE__ */ jsx("svg", {
@@ -1479,7 +2148,7 @@ const MinusIcon = () => /* @__PURE__ */ jsx("svg", {
1479
2148
  strokeWidth: "2",
1480
2149
  strokeLinecap: "round",
1481
2150
  strokeLinejoin: "round",
1482
- className: "w-4 h-4",
2151
+ className: "block w-4 h-4",
1483
2152
  children: /* @__PURE__ */ jsx("path", { d: "M5 12h14" })
1484
2153
  });
1485
2154
  const ResetIcon = () => /* @__PURE__ */ jsxs("svg", {
@@ -1491,7 +2160,7 @@ const ResetIcon = () => /* @__PURE__ */ jsxs("svg", {
1491
2160
  strokeWidth: "2",
1492
2161
  strokeLinecap: "round",
1493
2162
  strokeLinejoin: "round",
1494
- className: "w-4 h-4",
2163
+ className: "block w-4 h-4",
1495
2164
  children: [/* @__PURE__ */ jsx("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }), /* @__PURE__ */ jsx("path", { d: "M3 3v5h5" })]
1496
2165
  });
1497
2166
  const MIN_SCALE = .2;
@@ -1760,8 +2429,9 @@ const MermaidDiagramInner = ({ code }) => {
1760
2429
  });
1761
2430
  }
1762
2431
  } catch (err) {
2432
+ if (!mounted) return;
1763
2433
  console.error("Mermaid render error:", err);
1764
- if (mounted) setFailure({
2434
+ setFailure({
1765
2435
  key,
1766
2436
  message: extractErrorMessage(err)
1767
2437
  });
@@ -2019,6 +2689,34 @@ function TagList({ items }) {
2019
2689
  }, item))
2020
2690
  });
2021
2691
  }
2692
+ /**
2693
+ * An `OQ-…` token, shaped as `VANTAGE_OQ_ID` shapes an id and bounded as the
2694
+ * planning scan bounds one: by what may not touch it, so an id inside a longer
2695
+ * token (`OQ-B12x`, `xOQ-B1`) is not a token at all. The leading bound is a
2696
+ * group rather than a lookbehind, which Safari parses only since 16.4.
2697
+ */
2698
+ const OQ_TOKEN = /(^|[^A-Za-z0-9])(OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+)(?![A-Za-z0-9])/g;
2699
+ /** `text`, with each token in `ids` as a link to its question. */
2700
+ function LinkedIds({ text, ids }) {
2701
+ const out = [];
2702
+ let last = 0;
2703
+ for (const match of text.matchAll(OQ_TOKEN)) {
2704
+ const id = match[2];
2705
+ if (id === void 0 || !ids.includes(id)) continue;
2706
+ const start = match.index + match[1].length;
2707
+ if (start > last) out.push(text.slice(last, start));
2708
+ out.push(/* @__PURE__ */ jsx("a", {
2709
+ href: `#${id}`,
2710
+ children: id
2711
+ }, start));
2712
+ last = start + id.length;
2713
+ }
2714
+ if (last < text.length) out.push(text.slice(last));
2715
+ return /* @__PURE__ */ jsx("span", {
2716
+ className: "font-medium",
2717
+ children: out
2718
+ });
2719
+ }
2022
2720
  function ValueCell({ value }) {
2023
2721
  if (isStringArray(value) && value.length > 0) return /* @__PURE__ */ jsx(TagList, { items: value });
2024
2722
  if (isPlainObject(value)) return /* @__PURE__ */ jsx("pre", {
@@ -2040,7 +2738,7 @@ function flattenEntries(entries) {
2040
2738
  }
2041
2739
  return result;
2042
2740
  }
2043
- const FrontmatterDisplayInner = ({ frontmatter }) => {
2741
+ const FrontmatterDisplayInner = ({ frontmatter, linkIds }) => {
2044
2742
  const entries = flattenEntries(Object.entries(frontmatter));
2045
2743
  const { statusChip } = readVantageFrontmatter(frontmatter);
2046
2744
  if (entries.length === 0 && statusChip === void 0) return null;
@@ -2066,7 +2764,10 @@ const FrontmatterDisplayInner = ({ frontmatter }) => {
2066
2764
  children: key
2067
2765
  }), /* @__PURE__ */ jsx("td", {
2068
2766
  className: "py-2 text-slate-800 dark:text-slate-200 align-top",
2069
- children: /* @__PURE__ */ jsx(ValueCell, { value })
2767
+ children: key === "next" && linkIds !== void 0 && typeof value === "string" && !/[\r\n]/.test(value.trim()) && value === frontmatter["next"] ? /* @__PURE__ */ jsx(LinkedIds, {
2768
+ text: value,
2769
+ ids: linkIds
2770
+ }) : /* @__PURE__ */ jsx(ValueCell, { value })
2070
2771
  })]
2071
2772
  }, key)) })
2072
2773
  })
@@ -2342,17 +3043,16 @@ const MarkdownViewerInner = ({ content, currentPath = "", hash = "", baseUrl = "
2342
3043
  imageApiBase
2343
3044
  ]);
2344
3045
  const markdownComponents = useMemo(() => ({
2345
- a({ href, children, ...props }) {
2346
- const resolvedHref = resolveHref(href);
3046
+ a({ node, href, children, ...props }) {
2347
3047
  return /* @__PURE__ */ jsx("a", {
2348
- href: resolvedHref,
3048
+ href: href === void 0 ? void 0 : resolveHref(href),
2349
3049
  onClick: (e) => href && handleLinkClick(e, href),
2350
3050
  ...props,
2351
3051
  children
2352
3052
  });
2353
3053
  },
2354
3054
  code(props) {
2355
- const { children, className: codeClassName, ...rest } = props;
3055
+ const { node, children, className: codeClassName, ...rest } = props;
2356
3056
  const match = /language-(\w+)/.exec(codeClassName || "");
2357
3057
  if (match && match[1] === "mermaid") return /* @__PURE__ */ jsx(MermaidDiagram, { code: String(children).replace(/\n$/, "") });
2358
3058
  return /* @__PURE__ */ jsx("code", {
@@ -2396,61 +3096,8 @@ const MarkdownViewerInner = ({ content, currentPath = "", hash = "", baseUrl = "
2396
3096
  };
2397
3097
  const MarkdownViewer = memo(MarkdownViewerInner, (prevProps, nextProps) => prevProps.content === nextProps.content && prevProps.currentPath === nextProps.currentPath && prevProps.hash === nextProps.hash && prevProps.className === nextProps.className);
2398
3098
  //#endregion
2399
- //#region src/renderMarkdown.ts
2400
- /**
2401
- * Render a markdown string to HTML using the full Vantage pipeline.
2402
- *
2403
- * Features (all enabled by default):
2404
- * - GitHub Flavored Markdown (tables, strikethrough, task lists)
2405
- * - KaTeX math rendering, inline and block ($$...$$ only; single $ is not a delimiter)
2406
- * - Syntax highlighting via highlight.js
2407
- * - `data-source-line` attributes for line anchors
2408
- * - XSS sanitization
2409
- * - Heading slugs/anchors
2410
- * - YAML/TOML frontmatter parsing
2411
- *
2412
- * Mermaid diagrams are NOT rendered server-side (they require a browser).
2413
- * Mermaid code blocks are preserved as `<pre><code class="language-mermaid">`.
2414
- * Use the React `<MarkdownViewer>` component for client-side mermaid rendering.
2415
- */
2416
- async function renderMarkdown(content, options = {}) {
2417
- const { gfm = true, math = true, highlight = true, sourceLines = true, sanitize = true, frontmatter: parseFm = true, tree } = options;
2418
- let parsed;
2419
- if (parseFm) parsed = parseFrontmatter(content);
2420
- else parsed = {
2421
- frontmatter: {},
2422
- body: content,
2423
- format: "none",
2424
- bodyLineOffset: 0
2425
- };
2426
- const { remarkPlugins, rehypePlugins } = buildPipeline({
2427
- gfm,
2428
- math,
2429
- highlight,
2430
- sourceLines,
2431
- sanitize,
2432
- bodyLineOffset: parsed.bodyLineOffset
2433
- });
2434
- const processor = unified().use(remarkParse).use(remarkPlugins).use(remarkRehype, { allowDangerousHtml: true }).use(rehypePlugins).use(rehypeStringify);
2435
- const result = tree === void 0 ? String(await processor.process(parsed.body)) : processor.stringify(await processor.run(tree, parsed.body), parsed.body);
2436
- return {
2437
- html: String(result),
2438
- frontmatter: parsed.frontmatter,
2439
- body: parsed.body
2440
- };
2441
- }
2442
- //#endregion
2443
3099
  //#region src/renderMermaidBlocks.ts
2444
3100
  /**
2445
- * Client-side utility to find and render mermaid code blocks in a container.
2446
- *
2447
- * After calling `renderMarkdown()`, mermaid blocks come through as
2448
- * `<pre><code class="language-mermaid">...</code></pre>`. This function
2449
- * finds those blocks and replaces them with rendered SVG diagrams.
2450
- *
2451
- * Framework-agnostic — works in any browser environment.
2452
- */
2453
- /**
2454
3101
  * Find all `<pre><code class="language-mermaid">` blocks in a container
2455
3102
  * and replace them with rendered SVG diagrams.
2456
3103
  *
@@ -2531,6 +3178,104 @@ function replaceWithSvg(preEl, svg, className) {
2531
3178
  wrapper.innerHTML = svg;
2532
3179
  preEl.replaceWith(wrapper);
2533
3180
  }
3181
+ /** The viewer's parser: the remark half of `buildPipeline`. */
3182
+ const fenceParser = unified().use(remarkParse).use(buildRemarkPlugins());
3183
+ /**
3184
+ * The code of every Mermaid fence in `markdown`, in source order, exactly as
3185
+ * a viewer's `MermaidDiagram` receives it: the fence's content, without its
3186
+ * container's indentation or its closing newline.
3187
+ *
3188
+ * A fence is a diagram when its info string's first word begins with the word
3189
+ * `mermaid`, which is the test the viewer applies to the `language-…` class
3190
+ * the fence renders with (`/language-(\w+)/`). Text that never says
3191
+ * `mermaid` is not parsed at all.
3192
+ */
3193
+ function mermaidFences(markdown) {
3194
+ if (!markdown.includes("mermaid")) return [];
3195
+ const codes = [];
3196
+ visit(fenceParser.parse(markdown), "code", (node) => {
3197
+ if (/^\w+/.exec(node.lang ?? "")?.[0] === "mermaid") codes.push(node.value);
3198
+ });
3199
+ return codes;
3200
+ }
3201
+ /** Draws under way, by palette and code, so asking twice draws once. */
3202
+ const drawing = /* @__PURE__ */ new Map();
3203
+ let drawn = 0;
3204
+ /**
3205
+ * Draw one diagram into the SVG cache that `MermaidDiagram` reads when it
3206
+ * mounts, so a diagram rendered after this has settled is drawn at its full
3207
+ * size on its first paint instead of growing into place once Mermaid answers.
3208
+ * The planning page draws every diagram of the cards it is about to show this
3209
+ * way, before it commits them.
3210
+ *
3211
+ * Mermaid is loaded through `getMermaid`, so the drawing is configured, and
3212
+ * made safe, exactly as every other diagram of the page is. `theme` is the
3213
+ * palette key (`currentMermaidPalette`) to draw for; when the page has moved
3214
+ * to another palette by the time Mermaid is ready, nothing is cached, since
3215
+ * Mermaid now draws for the page's palette and not for `theme`.
3216
+ *
3217
+ * Resolves once the diagram is in the cache, at once when it already was, and
3218
+ * rejects when Mermaid cannot draw it.
3219
+ */
3220
+ function prerenderMermaid(code, theme = currentMermaidPalette()) {
3221
+ if (!code.trim() || hasCachedSvg(code, theme)) return Promise.resolve();
3222
+ const key = `${theme}\u0000${code}`;
3223
+ let pending = drawing.get(key);
3224
+ if (pending === void 0) {
3225
+ pending = (async () => {
3226
+ const mermaid = await getMermaid();
3227
+ if (currentMermaidPalette() !== theme) return;
3228
+ const { svg } = await mermaid.render(`mermaid-ahead-${(++drawn).toString(36)}`, code);
3229
+ setCachedSvg(code, svg, theme);
3230
+ })().finally(() => drawing.delete(key));
3231
+ drawing.set(key, pending);
3232
+ }
3233
+ return pending;
3234
+ }
3235
+ //#endregion
3236
+ //#region src/renderMarkdown.ts
3237
+ /**
3238
+ * Render a markdown string to HTML using the full Vantage pipeline.
3239
+ *
3240
+ * Features (all enabled by default):
3241
+ * - GitHub Flavored Markdown (tables, strikethrough, task lists)
3242
+ * - KaTeX math rendering, inline and block ($$...$$ only; single $ is not a delimiter)
3243
+ * - Syntax highlighting via highlight.js
3244
+ * - `data-source-line` attributes for line anchors
3245
+ * - XSS sanitization
3246
+ * - Heading slugs/anchors
3247
+ * - YAML/TOML frontmatter parsing
3248
+ *
3249
+ * Mermaid diagrams are NOT rendered server-side (they require a browser).
3250
+ * Mermaid code blocks are preserved as `<pre><code class="language-mermaid">`.
3251
+ * Use the React `<MarkdownViewer>` component for client-side mermaid rendering.
3252
+ */
3253
+ async function renderMarkdown(content, options = {}) {
3254
+ const { gfm = true, math = true, highlight = true, sourceLines = true, sanitize = true, frontmatter: parseFm = true, tree } = options;
3255
+ let parsed;
3256
+ if (parseFm) parsed = parseFrontmatter(content);
3257
+ else parsed = {
3258
+ frontmatter: {},
3259
+ body: content,
3260
+ format: "none",
3261
+ bodyLineOffset: 0
3262
+ };
3263
+ const { remarkPlugins, rehypePlugins } = buildPipeline({
3264
+ gfm,
3265
+ math,
3266
+ highlight,
3267
+ sourceLines,
3268
+ sanitize,
3269
+ bodyLineOffset: parsed.bodyLineOffset
3270
+ });
3271
+ const processor = unified().use(remarkParse).use(remarkPlugins).use(remarkRehype, { allowDangerousHtml: true }).use(rehypePlugins).use(rehypeStringify);
3272
+ const result = tree === void 0 ? String(await processor.process(parsed.body)) : processor.stringify(await processor.run(tree, parsed.body), parsed.body);
3273
+ return {
3274
+ html: String(result),
3275
+ frontmatter: parsed.frontmatter,
3276
+ body: parsed.body
3277
+ };
3278
+ }
2534
3279
  //#endregion
2535
3280
  //#region src/resolveLinks.ts
2536
3281
  /**
@@ -2576,6 +3321,6 @@ function resolveLinks(html, options = {}) {
2576
3321
  });
2577
3322
  }
2578
3323
  //#endregion
2579
- export { DOC_STATUSES, DOC_STATUS_TONES, DocumentStatusChip, FrontmatterDisplay, MarkdownViewer, MermaidDiagram, VANTAGE_FRONTMATTER_KEYS, buildPipeline, buildRemarkPlugins, clearLineAnchorHighlights, isDocStatus, parseFrontmatter, parseLineAnchor, readVantageFrontmatter, rehypeSourceLines, renderMarkdown, renderMermaidBlocks, resolveLinks, sanitizeSchema, scrollToLineAnchor, useLineAnchor };
3324
+ export { DOC_STATUSES, DOC_STATUS_TONES, DocumentStatusChip, FrontmatterDisplay, MarkdownViewer, MermaidDiagram, VANTAGE_FRONTMATTER_KEYS, buildPipeline, buildRemarkPlugins, clearLineAnchorHighlights, isDocStatus, mermaidFences, parseFrontmatter, parseLineAnchor, prerenderMermaid, readVantageFrontmatter, rehypeSourceLines, renderMarkdown, renderMermaidBlocks, resolveLinks, sanitizeSchema, scrollToLineAnchor, useLineAnchor };
2580
3325
 
2581
3326
  //# sourceMappingURL=react.js.map