vantage-md 0.5.7 → 0.5.9

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
@@ -51,7 +51,31 @@ let remark_rehype = require("remark-rehype");
51
51
  remark_rehype = __toESM(remark_rehype, 1);
52
52
  let rehype_stringify = require("rehype-stringify");
53
53
  rehype_stringify = __toESM(rehype_stringify, 1);
54
+ //#region src/rehypeVantageAnchors.ts
55
+ /** What `rehypeVantageDirectives` stamps, in hast property form. */
56
+ const OQ_ID_PROPERTY$1 = "dataVantageOqId";
57
+ function rehypeVantageAnchors() {
58
+ return (tree) => {
59
+ (0, unist_util_visit.visit)(tree, "element", (node) => {
60
+ const carried = node.properties?.[OQ_ID_PROPERTY$1];
61
+ if (typeof carried !== "string" || carried === "") return;
62
+ delete node.properties[OQ_ID_PROPERTY$1];
63
+ if (typeof node.properties.id === "string" && node.properties.id !== "") return;
64
+ node.properties.id = carried;
65
+ });
66
+ };
67
+ }
68
+ //#endregion
54
69
  //#region src/rehypeSourceLines.ts
70
+ /**
71
+ * Tags that get a `data-source-line`.
72
+ *
73
+ * `td`/`th` are here for review mode: a comment anchors to the cell it was
74
+ * written on, so the cell needs a line of its own to be found again. Every cell
75
+ * in a row reports the *row's* start line — a GFM row is one source line — so a
76
+ * line no longer names at most one anchorable element, and whatever resolves an
77
+ * anchor has to break the tie by block hash (`useReviewHighlights`).
78
+ */
55
79
  const BLOCK_TAGS = /* @__PURE__ */ new Set([
56
80
  "p",
57
81
  "h1",
@@ -64,6 +88,8 @@ const BLOCK_TAGS = /* @__PURE__ */ new Set([
64
88
  "blockquote",
65
89
  "pre",
66
90
  "table",
91
+ "td",
92
+ "th",
67
93
  "tr",
68
94
  "ul",
69
95
  "ol",
@@ -266,12 +292,17 @@ const VANTAGE_RUNS = [
266
292
  "only"
267
293
  ];
268
294
  /**
269
- * The tags a `section`/`block` directive may stamp.
295
+ * The tags a `section`/`block` directive may **target**.
270
296
  *
271
- * Deliberately `rehypeSourceLines`'s `BLOCK_TAGS`: a stamped block should also
272
- * be a block with a `data-source-line`, so the styling surface and the anchor
273
- * surface coincide. It also keeps an inline directive from stamping the `<em>`
274
- * that happens to follow it inside a paragraph.
297
+ * Deliberately `rehypeSourceLines`'s `BLOCK_TAGS`: a directive's target should
298
+ * also be a block with a `data-source-line`, so the styling surface and the
299
+ * anchor surface coincide. It also keeps an inline directive from stamping the
300
+ * `<em>` that happens to follow it inside a paragraph.
301
+ *
302
+ * It does **not** bound a `section`'s range. Every element in the span is
303
+ * stamped, on the tag list or not, because a member only has to be a box in the
304
+ * flow for the section's vertical rule to cross it — see `styleRange` in
305
+ * `rehypeVantageDirectives.ts` for the hole that restricting the range left.
275
306
  *
276
307
  * It lives here rather than in the plugin because the CLI checker has to answer
277
308
  * "will this directive stamp anything?" from an mdast tree with no hast in
@@ -321,6 +352,23 @@ const VANTAGE_ANCHOR_TARGETS = [
321
352
  "table"
322
353
  ];
323
354
  VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
355
+ /**
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.
359
+ *
360
+ * The prefix is what keeps ids distinct once one document references another's
361
+ * questions — `trust-paths.md`'s `OQ-4` and a design sketch's `OQ-4` are
362
+ * different questions, and a bare number cannot say which one a cross-document
363
+ * reference means. It is optional because most documents never leave their own
364
+ * file, and requiring it everywhere would fire on every single-doc sketch.
365
+ *
366
+ * Three consumers read it from here and none of them may re-spell it: the
367
+ * plugin that stamps the anchor, the sanitiser 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.
370
+ */
371
+ const VANTAGE_OQ_ID = /^OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+$/;
324
372
  const STYLE_KEYS = {
325
373
  tone: VANTAGE_TONES,
326
374
  emphasis: VANTAGE_EMPHASIS,
@@ -453,11 +501,13 @@ function parseVantageDirective(comment) {
453
501
  //#endregion
454
502
  //#region src/rehypeVantageDirectives.ts
455
503
  /**
456
- * What a `section`/`block` and an `oq` directive may stamp.
504
+ * What a `section`/`block` and an `oq` directive may **target**.
457
505
  *
458
506
  * Both lists live in `vantageDirectives.ts`, with the reasoning for each tag,
459
507
  * because the CLI checker resolves the same question over mdast and must reach
460
508
  * the same answer (D5).
509
+ *
510
+ * Neither list bounds a `section`'s range: see `styleRange`.
461
511
  */
462
512
  const STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
463
513
  const ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
@@ -504,6 +554,18 @@ const RUN_PROPERTY = "dataVantageRun";
504
554
  const OQ_PROPERTY = "dataVantageOq";
505
555
  const LEANING_PROPERTY = "dataVantageLeaning";
506
556
  /**
557
+ * The id, carried as a `data-` attribute rather than written straight to `id`.
558
+ *
559
+ * This plugin runs *before* `rehypeSanitize` — it has to, it reads comments and
560
+ * the sanitiser deletes them — and the sanitiser's default schema clobbers `id`
561
+ * with the prefix `user-content-`. A bare `id` set here would reach the page as
562
+ * `user-content-OQ-4`, every `#OQ-4` link in every document would land nowhere,
563
+ * and nothing would error. `rehypeVantageAnchors` promotes this to a real `id`
564
+ * on the other side of the sanitiser, which is the same reason `rehypeSlug` is
565
+ * registered there (`pipeline.ts`).
566
+ */
567
+ const OQ_ID_PROPERTY = "dataVantageOqId";
568
+ /**
507
569
  * The three properties `collapsed=true` stamps across a section.
508
570
  *
509
571
  * The heading takes a *different* attribute from the blocks it hides, and that
@@ -576,6 +638,22 @@ function accepts(name, key, value) {
576
638
  * `section` before anything else degrades to that one block, and `block` is
577
639
  * always that one block. A heading nested inside a stamped `blockquote` or
578
640
  * `li` does not end the section: the walk never descends.
641
+ *
642
+ * **Every element in the span, not only a `VANTAGE_STYLE_TARGETS` one.** That
643
+ * list gates the *target* and nothing else. Restricting the range to it as well
644
+ * used to leave a raw-HTML `<figure>`, `<dl>` or `<details>` unstamped between
645
+ * two stamped paragraphs — and the section's one continuous vertical rule is
646
+ * drawn per member, so an unstamped member is a hole the height of the block
647
+ * plus its margins. Measured over the real stylesheet: 44px for a one-line
648
+ * `<figure>`, against the 40px a neighbour can bleed upward, and arbitrarily
649
+ * large for anything taller. `collapsed=true` had the same shape of bug the
650
+ * other way round — it hid the paragraphs and left the figure on the page.
651
+ *
652
+ * The two lists answering different questions is the point, not an oversight:
653
+ * a *target* must be a block a review anchor can name, because a directive
654
+ * pointing at something unanchorable is a directive with no addressable effect.
655
+ * A *member* only has to be a box in the flow, because all it does is carry the
656
+ * run's tone across itself.
579
657
  */
580
658
  function styleRange(children, targetIndex, name) {
581
659
  const range = [targetIndex];
@@ -585,7 +663,7 @@ function styleRange(children, targetIndex, name) {
585
663
  const node = children[i];
586
664
  const nodeDepth = headingDepth(node);
587
665
  if (nodeDepth !== void 0 && nodeDepth <= depth) break;
588
- if (node.type === "element" && STYLE_TARGET_TAGS.has(node.tagName)) range.push(i);
666
+ if (node.type === "element") range.push(i);
589
667
  }
590
668
  return range;
591
669
  }
@@ -644,6 +722,8 @@ function stampStyle(children, targetIndex, name, pairs, state) {
644
722
  }
645
723
  function stampOq(target, pairs) {
646
724
  setProperty(target, OQ_PROPERTY, "true");
725
+ const id = pairs.get("id");
726
+ if (id !== void 0 && id !== "") setProperty(target, OQ_ID_PROPERTY, id);
647
727
  const leaning = pairs.get("leaning");
648
728
  if (leaning === void 0) return;
649
729
  const text = leaning.replace(/\s+/g, " ").trim().slice(0, MAX_LEANING);
@@ -949,6 +1029,7 @@ const sanitizeSchema = {
949
1029
  ["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
950
1030
  ["dataVantageRun", ...VANTAGE_RUNS],
951
1031
  ["dataVantageOq", "true"],
1032
+ ["dataVantageOqId", VANTAGE_OQ_ID],
952
1033
  ["dataVantageAlert", ...VANTAGE_ALERTS],
953
1034
  "dataVantageLeaning"
954
1035
  ],
@@ -998,6 +1079,7 @@ function buildRehypePlugins(options = {}) {
998
1079
  plugins.push(rehypeVantageAlerts);
999
1080
  plugins.push(rehypeVantageDirectives);
1000
1081
  if (sanitize) plugins.push([rehype_sanitize.default, sanitizeSchema]);
1082
+ plugins.push(rehypeVantageAnchors);
1001
1083
  plugins.push(rehype_slug.default);
1002
1084
  if (highlight) plugins.push(rehype_highlight.default);
1003
1085
  if (math) plugins.push(rehypeCaptureMathStamps, rehype_katex.default, rehypeRestoreMathStamps);
@@ -1120,27 +1202,92 @@ function errorPosition(error) {
1120
1202
  return { message };
1121
1203
  }
1122
1204
  //#endregion
1205
+ //#region src/mermaidTheme.ts
1206
+ /** Whether the document is asking for the dark palette right now. */
1207
+ function currentMermaidTheme() {
1208
+ return typeof document !== "undefined" && document.documentElement.classList.contains("dark") ? "dark" : "default";
1209
+ }
1210
+ /**
1211
+ * Theme variables per theme. Mermaid derives most of its palette from these, so
1212
+ * the set is deliberately small: the surfaces, the ink, and the lines.
1213
+ */
1214
+ const THEME_VARIABLES = {
1215
+ dark: {
1216
+ background: "#1d293d",
1217
+ mainBkg: "#314158",
1218
+ nodeBorder: "#90a1b9",
1219
+ nodeTextColor: "#f1f5f9",
1220
+ lineColor: "#90a1b9",
1221
+ textColor: "#e2e8f0",
1222
+ edgeLabelBackground: "#1d293d"
1223
+ },
1224
+ default: {
1225
+ background: "#f8fafc",
1226
+ mainBkg: "#f1f5f9",
1227
+ nodeBorder: "#62748e",
1228
+ nodeTextColor: "#0f172b",
1229
+ lineColor: "#62748e",
1230
+ textColor: "#1d293d",
1231
+ edgeLabelBackground: "#f8fafc"
1232
+ }
1233
+ };
1234
+ function mermaidThemeVariables(theme) {
1235
+ return THEME_VARIABLES[theme];
1236
+ }
1237
+ //#endregion
1123
1238
  //#region src/mermaidCache.ts
1124
1239
  const svgCache = /* @__PURE__ */ new Map();
1240
+ const cacheKey = (code, theme) => `${theme}${code}`;
1241
+ /** The SVG for this fence in the theme the page is currently asking for. */
1242
+ function getCachedSvg(code, theme = currentMermaidTheme()) {
1243
+ return svgCache.get(cacheKey(code, theme));
1244
+ }
1245
+ function setCachedSvg(code, svg, theme = currentMermaidTheme()) {
1246
+ svgCache.set(cacheKey(code, theme), svg);
1247
+ }
1125
1248
  //#endregion
1126
1249
  //#region src/mermaidLoader.ts
1127
1250
  let mermaidInstance = null;
1128
1251
  let mermaidLoading = null;
1129
- const isDark = () => typeof document !== "undefined" && document.documentElement.classList.contains("dark");
1252
+ /** The theme the loaded instance was last configured for, `null` until loaded. */
1253
+ let configuredTheme = null;
1254
+ function configure(m, theme) {
1255
+ m.initialize({
1256
+ startOnLoad: false,
1257
+ theme,
1258
+ themeVariables: mermaidThemeVariables(theme),
1259
+ securityLevel: "strict",
1260
+ suppressErrorRendering: true
1261
+ });
1262
+ configuredTheme = theme;
1263
+ }
1264
+ /**
1265
+ * The mermaid module, configured for the theme the page is asking for *now*.
1266
+ *
1267
+ * Re-configuring on a theme change is the point. `initialize` used to run once,
1268
+ * on first import, so every diagram rendered after a light/dark switch still
1269
+ * came out in the palette the session started in — a white slab of a flowchart
1270
+ * on the dark page, or a black one on the light page. `initialize` merges into
1271
+ * mermaid's global config, so calling it again is how the next `render` picks
1272
+ * the new palette up; the cache is keyed by theme so the old SVGs are not
1273
+ * served instead (`mermaidCache.ts`).
1274
+ */
1130
1275
  async function getMermaid() {
1131
- if (mermaidInstance) return mermaidInstance;
1276
+ const theme = currentMermaidTheme();
1277
+ if (mermaidInstance) {
1278
+ if (configuredTheme !== theme) configure(mermaidInstance, theme);
1279
+ return mermaidInstance;
1280
+ }
1132
1281
  if (!mermaidLoading) mermaidLoading = import("mermaid").then((mod) => {
1133
1282
  const m = mod.default;
1134
- m.initialize({
1135
- startOnLoad: false,
1136
- theme: isDark() ? "dark" : "default",
1137
- securityLevel: "strict",
1138
- suppressErrorRendering: true
1139
- });
1283
+ configure(m, currentMermaidTheme());
1140
1284
  mermaidInstance = m;
1141
1285
  return m;
1142
1286
  });
1143
- return mermaidLoading;
1287
+ const loaded = await mermaidLoading;
1288
+ const wanted = currentMermaidTheme();
1289
+ if (configuredTheme !== wanted) configure(loaded, wanted);
1290
+ return loaded;
1144
1291
  }
1145
1292
  //#endregion
1146
1293
  //#region src/MermaidDiagram.tsx
@@ -1193,7 +1340,7 @@ const MaximizeIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1193
1340
  strokeWidth: "2",
1194
1341
  strokeLinecap: "round",
1195
1342
  strokeLinejoin: "round",
1196
- className: "w-4 h-4 text-gray-600",
1343
+ className: "w-4 h-4 text-gray-600 dark:text-slate-200",
1197
1344
  children: [
1198
1345
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("polyline", { points: "15 3 21 3 21 9" }),
1199
1346
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("polyline", { points: "9 21 3 21 3 15" }),
@@ -1354,32 +1501,32 @@ function ZoomableSvg({ svg }) {
1354
1501
  dangerouslySetInnerHTML: { __html: svg }
1355
1502
  })
1356
1503
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1357
- className: "absolute bottom-4 right-4 flex items-center gap-1 rounded-lg bg-white/95 shadow-md border border-gray-200 px-1.5 py-1",
1504
+ className: "absolute bottom-4 right-4 flex items-center gap-1 rounded-lg bg-white/95 dark:bg-slate-800/95 shadow-md border border-gray-200 dark:border-slate-600 px-1.5 py-1",
1358
1505
  children: [
1359
1506
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1360
1507
  onClick: () => zoomBy(1 / 1.3),
1361
- className: "p-1.5 rounded hover:bg-gray-100 text-gray-600",
1508
+ className: "p-1.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-600 dark:text-slate-200",
1362
1509
  "aria-label": "Zoom out",
1363
1510
  title: "Zoom out",
1364
1511
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MinusIcon, {})
1365
1512
  }),
1366
1513
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1367
1514
  onClick: reset,
1368
- className: "px-2 py-1 text-xs font-medium rounded hover:bg-gray-100 text-gray-600 min-w-[3rem]",
1515
+ className: "px-2 py-1 text-xs font-medium rounded hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-600 dark:text-slate-200 min-w-[3rem]",
1369
1516
  title: "Reset zoom (or double-click)",
1370
1517
  children: [Math.round(scale * 100), "%"]
1371
1518
  }),
1372
1519
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1373
1520
  onClick: () => zoomBy(1.3),
1374
- className: "p-1.5 rounded hover:bg-gray-100 text-gray-600",
1521
+ className: "p-1.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-600 dark:text-slate-200",
1375
1522
  "aria-label": "Zoom in",
1376
1523
  title: "Zoom in",
1377
1524
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlusIcon, {})
1378
1525
  }),
1379
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "w-px h-5 bg-gray-200 mx-0.5" }),
1526
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "w-px h-5 bg-gray-200 dark:bg-slate-600 mx-0.5" }),
1380
1527
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1381
1528
  onClick: reset,
1382
- className: "p-1.5 rounded hover:bg-gray-100 text-gray-600",
1529
+ className: "p-1.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-600 dark:text-slate-200",
1383
1530
  "aria-label": "Reset view",
1384
1531
  title: "Reset view",
1385
1532
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ResetIcon, {})
@@ -1417,38 +1564,74 @@ function DiagramModal({ isOpen, onClose, children }) {
1417
1564
  className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4",
1418
1565
  onClick: onClose,
1419
1566
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1420
- className: "bg-white rounded-lg shadow-xl w-[96vw] h-[94vh] flex flex-col overflow-hidden",
1567
+ className: "bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 rounded-lg shadow-xl w-[96vw] h-[94vh] flex flex-col overflow-hidden",
1421
1568
  role: "dialog",
1422
1569
  "aria-modal": "true",
1423
1570
  onClick: (e) => e.stopPropagation(),
1424
1571
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1425
- className: "flex items-center justify-between p-4 border-b shrink-0",
1572
+ className: "flex items-center justify-between p-4 border-b border-gray-200 dark:border-slate-700 shrink-0",
1426
1573
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1427
1574
  className: "text-lg font-semibold",
1428
1575
  children: "Mermaid Diagram"
1429
1576
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1430
1577
  onClick: onClose,
1431
- className: "p-1 hover:bg-gray-100 rounded-full transition-colors",
1578
+ className: "p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-full transition-colors",
1432
1579
  "aria-label": "Close modal",
1433
1580
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CloseIcon, {})
1434
1581
  })]
1435
1582
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1436
- className: "flex-1 overflow-hidden bg-gray-50",
1583
+ className: "flex-1 overflow-hidden bg-gray-50 dark:bg-slate-800",
1437
1584
  children
1438
1585
  })]
1439
1586
  })
1440
1587
  });
1441
1588
  }
1589
+ /**
1590
+ * The palette the page is asking for, re-read whenever `<html>`'s class list
1591
+ * changes.
1592
+ *
1593
+ * A diagram is an SVG baked at render time, so unlike everything else on the
1594
+ * page it does not restyle when the theme flips — it has to be drawn again. The
1595
+ * observer is what notices; the effect below is what redraws. Without it a
1596
+ * session that started in light mode kept white boxes with black ink on the
1597
+ * dark page for as long as it lived.
1598
+ *
1599
+ * `useSyncExternalStore` rather than state plus an effect: the class list *is*
1600
+ * an external store, and reading it in an effect would both miss a flip that
1601
+ * landed before the effect ran and set state synchronously inside one.
1602
+ */
1603
+ function subscribeToTheme(onChange) {
1604
+ if (typeof MutationObserver === "undefined") return () => {};
1605
+ const observer = new MutationObserver(onChange);
1606
+ observer.observe(document.documentElement, {
1607
+ attributes: true,
1608
+ attributeFilter: ["class"]
1609
+ });
1610
+ return () => observer.disconnect();
1611
+ }
1612
+ /** Server render has no `<html>` to read, and no diagram to draw either. */
1613
+ const serverTheme = () => "default";
1614
+ function useMermaidTheme() {
1615
+ return (0, react.useSyncExternalStore)(subscribeToTheme, currentMermaidTheme, serverTheme);
1616
+ }
1442
1617
  const MermaidDiagramInner = ({ code }) => {
1443
- const hasCached = svgCache.has(code);
1444
- const [svg, setSvg] = (0, react.useState)(() => svgCache.get(code) || "");
1445
- const [errorMessage, setErrorMessage] = (0, react.useState)(null);
1618
+ const theme = useMermaidTheme();
1446
1619
  const [showSource, setShowSource] = (0, react.useState)(false);
1447
1620
  const [isModalOpen, setIsModalOpen] = (0, react.useState)(false);
1448
- const [isLoading, setIsLoading] = (0, react.useState)(!hasCached);
1449
1621
  const [minHeight, setMinHeight] = (0, react.useState)("auto");
1450
1622
  const containerRef = (0, react.useRef)(null);
1451
1623
  const lastHeightRef = (0, react.useRef)(null);
1624
+ /**
1625
+ * What this render produced, and which (code, theme) it was for.
1626
+ *
1627
+ * Keyed rather than plain, and read past the cache rather than instead of it,
1628
+ * because a theme flip has to invalidate both: the cache is a miss in the new
1629
+ * palette, and last render's SVG is the wrong palette. Deriving `svg` here
1630
+ * rather than mirroring the cache into state is also what keeps a cache hit
1631
+ * from being a `setState` inside an effect.
1632
+ */
1633
+ const [rendered, setRendered] = (0, react.useState)(null);
1634
+ const [failure, setFailure] = (0, react.useState)(null);
1452
1635
  const stableId = (0, react.useMemo)(() => {
1453
1636
  let hash = 0;
1454
1637
  for (let i = 0; i < code.length; i++) {
@@ -1458,9 +1641,14 @@ const MermaidDiagramInner = ({ code }) => {
1458
1641
  }
1459
1642
  return `mermaid-${Math.abs(hash).toString(36)}`;
1460
1643
  }, [code]);
1644
+ const attempt = `${theme} ${stableId}`;
1645
+ const svg = getCachedSvg(code, theme) ?? (rendered?.key === attempt ? rendered.svg : "");
1646
+ const errorMessage = failure?.key === attempt ? failure.message : null;
1647
+ const isLoading = svg === "" && errorMessage === null;
1461
1648
  (0, react.useEffect)(() => {
1462
- if (hasCached) return;
1649
+ if (getCachedSvg(code, theme) !== void 0) return;
1463
1650
  let mounted = true;
1651
+ const key = `${theme} ${stableId}`;
1464
1652
  const renderDiagram = async () => {
1465
1653
  try {
1466
1654
  if (containerRef.current) {
@@ -1472,17 +1660,18 @@ const MermaidDiagramInner = ({ code }) => {
1472
1660
  const id = `${stableId}-${Date.now()}`;
1473
1661
  const { svg: renderedSvg } = await m.render(id, code);
1474
1662
  if (mounted) {
1475
- svgCache.set(code, renderedSvg);
1476
- setSvg(renderedSvg);
1477
- setErrorMessage(null);
1478
- setIsLoading(false);
1663
+ setCachedSvg(code, renderedSvg, theme);
1664
+ setRendered({
1665
+ key,
1666
+ svg: renderedSvg
1667
+ });
1479
1668
  }
1480
1669
  } catch (err) {
1481
1670
  console.error("Mermaid render error:", err);
1482
- if (mounted) {
1483
- setErrorMessage(extractErrorMessage(err));
1484
- setIsLoading(false);
1485
- }
1671
+ if (mounted) setFailure({
1672
+ key,
1673
+ message: extractErrorMessage(err)
1674
+ });
1486
1675
  }
1487
1676
  };
1488
1677
  renderDiagram();
@@ -1492,7 +1681,7 @@ const MermaidDiagramInner = ({ code }) => {
1492
1681
  }, [
1493
1682
  code,
1494
1683
  stableId,
1495
- hasCached
1684
+ theme
1496
1685
  ]);
1497
1686
  if (errorMessage) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1498
1687
  "data-testid": "mermaid-container",
@@ -1532,7 +1721,7 @@ const MermaidDiagramInner = ({ code }) => {
1532
1721
  dangerouslySetInnerHTML: { __html: svg }
1533
1722
  }), svg && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1534
1723
  onClick: () => setIsModalOpen(true),
1535
- className: "absolute top-2 right-2 p-2 bg-white/90 shadow-sm border rounded-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-50",
1724
+ className: "absolute top-2 right-2 p-2 bg-white/90 dark:bg-slate-800/90 shadow-sm border border-gray-200 dark:border-slate-600 rounded-md opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-50 dark:hover:bg-slate-700",
1536
1725
  "aria-label": "Maximize diagram",
1537
1726
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaximizeIcon, {})
1538
1727
  })]
@@ -2192,18 +2381,20 @@ async function renderMermaidBlocks(container, options = {}) {
2192
2381
  const { className = "mermaid", onError } = options;
2193
2382
  const codeBlocks = container.querySelectorAll("pre > code.language-mermaid, pre > code[class*=\"language-mermaid\"]");
2194
2383
  if (codeBlocks.length === 0) return;
2195
- const mermaid = await getMermaid();
2384
+ let loading;
2385
+ const mermaidOnce = () => loading ??= getMermaid();
2196
2386
  const renderPromises = Array.from(codeBlocks).map(async (codeEl) => {
2197
2387
  const preEl = codeEl.parentElement;
2198
2388
  if (!preEl) return;
2199
2389
  const code = codeEl.textContent || "";
2200
2390
  if (!code.trim()) return;
2201
- const cached = svgCache.get(code);
2391
+ const cached = getCachedSvg(code);
2202
2392
  if (cached) {
2203
2393
  replaceWithSvg(preEl, cached, className);
2204
2394
  return;
2205
2395
  }
2206
2396
  try {
2397
+ const mermaid = await mermaidOnce();
2207
2398
  let hash = 0;
2208
2399
  for (let i = 0; i < code.length; i++) {
2209
2400
  hash = (hash << 5) - hash + code.charCodeAt(i);
@@ -2211,7 +2402,7 @@ async function renderMermaidBlocks(container, options = {}) {
2211
2402
  }
2212
2403
  const id = `mermaid-${Math.abs(hash).toString(36)}-${Date.now()}`;
2213
2404
  const { svg } = await mermaid.render(id, code);
2214
- svgCache.set(code, svg);
2405
+ setCachedSvg(code, svg);
2215
2406
  replaceWithSvg(preEl, svg, className);
2216
2407
  } catch (err) {
2217
2408
  if (onError) onError(code, err instanceof Error ? err : new Error(String(err)));
@@ -2219,9 +2410,34 @@ async function renderMermaidBlocks(container, options = {}) {
2219
2410
  });
2220
2411
  await Promise.all(renderPromises);
2221
2412
  }
2413
+ /**
2414
+ * Attributes the wrapper inherits from the `<pre>` it replaces.
2415
+ *
2416
+ * The splice is the same shape of problem `rehypeVantageMathStamps` solves for
2417
+ * KaTeX: the pipeline stamped the fence, and swapping the element out throws
2418
+ * the stamps away. A mermaid diagram inside a toned section then drew no slice
2419
+ * of the section's vertical rule, leaving a hole as tall as the diagram; a
2420
+ * collapsed section left the diagram visible under a closed heading; and a
2421
+ * `#L` anchor pointing at the fence resolved to nothing.
2422
+ *
2423
+ * Named individually rather than copied wholesale: `class` is the caller's
2424
+ * (`className`), and `id` would be duplicated onto a second element.
2425
+ */
2426
+ const CARRIED_ATTRIBUTES = [
2427
+ "data-source-line",
2428
+ "data-vantage-tone",
2429
+ "data-vantage-emphasis",
2430
+ "data-vantage-run",
2431
+ "data-vantage-collapsed",
2432
+ "data-vantage-collapse-group"
2433
+ ];
2222
2434
  function replaceWithSvg(preEl, svg, className) {
2223
2435
  const wrapper = document.createElement("div");
2224
2436
  wrapper.className = className;
2437
+ for (const name of CARRIED_ATTRIBUTES) {
2438
+ const value = preEl.getAttribute(name);
2439
+ if (value !== null) wrapper.setAttribute(name, value);
2440
+ }
2225
2441
  wrapper.innerHTML = svg;
2226
2442
  preEl.replaceWith(wrapper);
2227
2443
  }