vantage-md 0.5.8 → 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,6 +51,21 @@ 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
55
70
  /**
56
71
  * Tags that get a `data-source-line`.
@@ -277,12 +292,17 @@ const VANTAGE_RUNS = [
277
292
  "only"
278
293
  ];
279
294
  /**
280
- * The tags a `section`/`block` directive may stamp.
295
+ * The tags a `section`/`block` directive may **target**.
281
296
  *
282
- * Deliberately `rehypeSourceLines`'s `BLOCK_TAGS`: a stamped block should also
283
- * be a block with a `data-source-line`, so the styling surface and the anchor
284
- * surface coincide. It also keeps an inline directive from stamping the `<em>`
285
- * 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.
286
306
  *
287
307
  * It lives here rather than in the plugin because the CLI checker has to answer
288
308
  * "will this directive stamp anything?" from an mdast tree with no hast in
@@ -332,6 +352,23 @@ const VANTAGE_ANCHOR_TARGETS = [
332
352
  "table"
333
353
  ];
334
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]+$/;
335
372
  const STYLE_KEYS = {
336
373
  tone: VANTAGE_TONES,
337
374
  emphasis: VANTAGE_EMPHASIS,
@@ -464,11 +501,13 @@ function parseVantageDirective(comment) {
464
501
  //#endregion
465
502
  //#region src/rehypeVantageDirectives.ts
466
503
  /**
467
- * What a `section`/`block` and an `oq` directive may stamp.
504
+ * What a `section`/`block` and an `oq` directive may **target**.
468
505
  *
469
506
  * Both lists live in `vantageDirectives.ts`, with the reasoning for each tag,
470
507
  * because the CLI checker resolves the same question over mdast and must reach
471
508
  * the same answer (D5).
509
+ *
510
+ * Neither list bounds a `section`'s range: see `styleRange`.
472
511
  */
473
512
  const STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
474
513
  const ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
@@ -515,6 +554,18 @@ const RUN_PROPERTY = "dataVantageRun";
515
554
  const OQ_PROPERTY = "dataVantageOq";
516
555
  const LEANING_PROPERTY = "dataVantageLeaning";
517
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
+ /**
518
569
  * The three properties `collapsed=true` stamps across a section.
519
570
  *
520
571
  * The heading takes a *different* attribute from the blocks it hides, and that
@@ -587,6 +638,22 @@ function accepts(name, key, value) {
587
638
  * `section` before anything else degrades to that one block, and `block` is
588
639
  * always that one block. A heading nested inside a stamped `blockquote` or
589
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.
590
657
  */
591
658
  function styleRange(children, targetIndex, name) {
592
659
  const range = [targetIndex];
@@ -596,7 +663,7 @@ function styleRange(children, targetIndex, name) {
596
663
  const node = children[i];
597
664
  const nodeDepth = headingDepth(node);
598
665
  if (nodeDepth !== void 0 && nodeDepth <= depth) break;
599
- if (node.type === "element" && STYLE_TARGET_TAGS.has(node.tagName)) range.push(i);
666
+ if (node.type === "element") range.push(i);
600
667
  }
601
668
  return range;
602
669
  }
@@ -655,6 +722,8 @@ function stampStyle(children, targetIndex, name, pairs, state) {
655
722
  }
656
723
  function stampOq(target, pairs) {
657
724
  setProperty(target, OQ_PROPERTY, "true");
725
+ const id = pairs.get("id");
726
+ if (id !== void 0 && id !== "") setProperty(target, OQ_ID_PROPERTY, id);
658
727
  const leaning = pairs.get("leaning");
659
728
  if (leaning === void 0) return;
660
729
  const text = leaning.replace(/\s+/g, " ").trim().slice(0, MAX_LEANING);
@@ -960,6 +1029,7 @@ const sanitizeSchema = {
960
1029
  ["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
961
1030
  ["dataVantageRun", ...VANTAGE_RUNS],
962
1031
  ["dataVantageOq", "true"],
1032
+ ["dataVantageOqId", VANTAGE_OQ_ID],
963
1033
  ["dataVantageAlert", ...VANTAGE_ALERTS],
964
1034
  "dataVantageLeaning"
965
1035
  ],
@@ -1009,6 +1079,7 @@ function buildRehypePlugins(options = {}) {
1009
1079
  plugins.push(rehypeVantageAlerts);
1010
1080
  plugins.push(rehypeVantageDirectives);
1011
1081
  if (sanitize) plugins.push([rehype_sanitize.default, sanitizeSchema]);
1082
+ plugins.push(rehypeVantageAnchors);
1012
1083
  plugins.push(rehype_slug.default);
1013
1084
  if (highlight) plugins.push(rehype_highlight.default);
1014
1085
  if (math) plugins.push(rehypeCaptureMathStamps, rehype_katex.default, rehypeRestoreMathStamps);
@@ -1131,27 +1202,92 @@ function errorPosition(error) {
1131
1202
  return { message };
1132
1203
  }
1133
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
1134
1238
  //#region src/mermaidCache.ts
1135
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
+ }
1136
1248
  //#endregion
1137
1249
  //#region src/mermaidLoader.ts
1138
1250
  let mermaidInstance = null;
1139
1251
  let mermaidLoading = null;
1140
- 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
+ */
1141
1275
  async function getMermaid() {
1142
- if (mermaidInstance) return mermaidInstance;
1276
+ const theme = currentMermaidTheme();
1277
+ if (mermaidInstance) {
1278
+ if (configuredTheme !== theme) configure(mermaidInstance, theme);
1279
+ return mermaidInstance;
1280
+ }
1143
1281
  if (!mermaidLoading) mermaidLoading = import("mermaid").then((mod) => {
1144
1282
  const m = mod.default;
1145
- m.initialize({
1146
- startOnLoad: false,
1147
- theme: isDark() ? "dark" : "default",
1148
- securityLevel: "strict",
1149
- suppressErrorRendering: true
1150
- });
1283
+ configure(m, currentMermaidTheme());
1151
1284
  mermaidInstance = m;
1152
1285
  return m;
1153
1286
  });
1154
- return mermaidLoading;
1287
+ const loaded = await mermaidLoading;
1288
+ const wanted = currentMermaidTheme();
1289
+ if (configuredTheme !== wanted) configure(loaded, wanted);
1290
+ return loaded;
1155
1291
  }
1156
1292
  //#endregion
1157
1293
  //#region src/MermaidDiagram.tsx
@@ -1204,7 +1340,7 @@ const MaximizeIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1204
1340
  strokeWidth: "2",
1205
1341
  strokeLinecap: "round",
1206
1342
  strokeLinejoin: "round",
1207
- className: "w-4 h-4 text-gray-600",
1343
+ className: "w-4 h-4 text-gray-600 dark:text-slate-200",
1208
1344
  children: [
1209
1345
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("polyline", { points: "15 3 21 3 21 9" }),
1210
1346
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("polyline", { points: "9 21 3 21 3 15" }),
@@ -1365,32 +1501,32 @@ function ZoomableSvg({ svg }) {
1365
1501
  dangerouslySetInnerHTML: { __html: svg }
1366
1502
  })
1367
1503
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1368
- 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",
1369
1505
  children: [
1370
1506
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1371
1507
  onClick: () => zoomBy(1 / 1.3),
1372
- 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",
1373
1509
  "aria-label": "Zoom out",
1374
1510
  title: "Zoom out",
1375
1511
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MinusIcon, {})
1376
1512
  }),
1377
1513
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1378
1514
  onClick: reset,
1379
- 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]",
1380
1516
  title: "Reset zoom (or double-click)",
1381
1517
  children: [Math.round(scale * 100), "%"]
1382
1518
  }),
1383
1519
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1384
1520
  onClick: () => zoomBy(1.3),
1385
- 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",
1386
1522
  "aria-label": "Zoom in",
1387
1523
  title: "Zoom in",
1388
1524
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlusIcon, {})
1389
1525
  }),
1390
- /* @__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" }),
1391
1527
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1392
1528
  onClick: reset,
1393
- 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",
1394
1530
  "aria-label": "Reset view",
1395
1531
  title: "Reset view",
1396
1532
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ResetIcon, {})
@@ -1428,38 +1564,74 @@ function DiagramModal({ isOpen, onClose, children }) {
1428
1564
  className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4",
1429
1565
  onClick: onClose,
1430
1566
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1431
- 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",
1432
1568
  role: "dialog",
1433
1569
  "aria-modal": "true",
1434
1570
  onClick: (e) => e.stopPropagation(),
1435
1571
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1436
- 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",
1437
1573
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1438
1574
  className: "text-lg font-semibold",
1439
1575
  children: "Mermaid Diagram"
1440
1576
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1441
1577
  onClick: onClose,
1442
- 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",
1443
1579
  "aria-label": "Close modal",
1444
1580
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CloseIcon, {})
1445
1581
  })]
1446
1582
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1447
- className: "flex-1 overflow-hidden bg-gray-50",
1583
+ className: "flex-1 overflow-hidden bg-gray-50 dark:bg-slate-800",
1448
1584
  children
1449
1585
  })]
1450
1586
  })
1451
1587
  });
1452
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
+ }
1453
1617
  const MermaidDiagramInner = ({ code }) => {
1454
- const hasCached = svgCache.has(code);
1455
- const [svg, setSvg] = (0, react.useState)(() => svgCache.get(code) || "");
1456
- const [errorMessage, setErrorMessage] = (0, react.useState)(null);
1618
+ const theme = useMermaidTheme();
1457
1619
  const [showSource, setShowSource] = (0, react.useState)(false);
1458
1620
  const [isModalOpen, setIsModalOpen] = (0, react.useState)(false);
1459
- const [isLoading, setIsLoading] = (0, react.useState)(!hasCached);
1460
1621
  const [minHeight, setMinHeight] = (0, react.useState)("auto");
1461
1622
  const containerRef = (0, react.useRef)(null);
1462
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);
1463
1635
  const stableId = (0, react.useMemo)(() => {
1464
1636
  let hash = 0;
1465
1637
  for (let i = 0; i < code.length; i++) {
@@ -1469,9 +1641,14 @@ const MermaidDiagramInner = ({ code }) => {
1469
1641
  }
1470
1642
  return `mermaid-${Math.abs(hash).toString(36)}`;
1471
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;
1472
1648
  (0, react.useEffect)(() => {
1473
- if (hasCached) return;
1649
+ if (getCachedSvg(code, theme) !== void 0) return;
1474
1650
  let mounted = true;
1651
+ const key = `${theme} ${stableId}`;
1475
1652
  const renderDiagram = async () => {
1476
1653
  try {
1477
1654
  if (containerRef.current) {
@@ -1483,17 +1660,18 @@ const MermaidDiagramInner = ({ code }) => {
1483
1660
  const id = `${stableId}-${Date.now()}`;
1484
1661
  const { svg: renderedSvg } = await m.render(id, code);
1485
1662
  if (mounted) {
1486
- svgCache.set(code, renderedSvg);
1487
- setSvg(renderedSvg);
1488
- setErrorMessage(null);
1489
- setIsLoading(false);
1663
+ setCachedSvg(code, renderedSvg, theme);
1664
+ setRendered({
1665
+ key,
1666
+ svg: renderedSvg
1667
+ });
1490
1668
  }
1491
1669
  } catch (err) {
1492
1670
  console.error("Mermaid render error:", err);
1493
- if (mounted) {
1494
- setErrorMessage(extractErrorMessage(err));
1495
- setIsLoading(false);
1496
- }
1671
+ if (mounted) setFailure({
1672
+ key,
1673
+ message: extractErrorMessage(err)
1674
+ });
1497
1675
  }
1498
1676
  };
1499
1677
  renderDiagram();
@@ -1503,7 +1681,7 @@ const MermaidDiagramInner = ({ code }) => {
1503
1681
  }, [
1504
1682
  code,
1505
1683
  stableId,
1506
- hasCached
1684
+ theme
1507
1685
  ]);
1508
1686
  if (errorMessage) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1509
1687
  "data-testid": "mermaid-container",
@@ -1543,7 +1721,7 @@ const MermaidDiagramInner = ({ code }) => {
1543
1721
  dangerouslySetInnerHTML: { __html: svg }
1544
1722
  }), svg && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1545
1723
  onClick: () => setIsModalOpen(true),
1546
- 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",
1547
1725
  "aria-label": "Maximize diagram",
1548
1726
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaximizeIcon, {})
1549
1727
  })]
@@ -2203,18 +2381,20 @@ async function renderMermaidBlocks(container, options = {}) {
2203
2381
  const { className = "mermaid", onError } = options;
2204
2382
  const codeBlocks = container.querySelectorAll("pre > code.language-mermaid, pre > code[class*=\"language-mermaid\"]");
2205
2383
  if (codeBlocks.length === 0) return;
2206
- const mermaid = await getMermaid();
2384
+ let loading;
2385
+ const mermaidOnce = () => loading ??= getMermaid();
2207
2386
  const renderPromises = Array.from(codeBlocks).map(async (codeEl) => {
2208
2387
  const preEl = codeEl.parentElement;
2209
2388
  if (!preEl) return;
2210
2389
  const code = codeEl.textContent || "";
2211
2390
  if (!code.trim()) return;
2212
- const cached = svgCache.get(code);
2391
+ const cached = getCachedSvg(code);
2213
2392
  if (cached) {
2214
2393
  replaceWithSvg(preEl, cached, className);
2215
2394
  return;
2216
2395
  }
2217
2396
  try {
2397
+ const mermaid = await mermaidOnce();
2218
2398
  let hash = 0;
2219
2399
  for (let i = 0; i < code.length; i++) {
2220
2400
  hash = (hash << 5) - hash + code.charCodeAt(i);
@@ -2222,7 +2402,7 @@ async function renderMermaidBlocks(container, options = {}) {
2222
2402
  }
2223
2403
  const id = `mermaid-${Math.abs(hash).toString(36)}-${Date.now()}`;
2224
2404
  const { svg } = await mermaid.render(id, code);
2225
- svgCache.set(code, svg);
2405
+ setCachedSvg(code, svg);
2226
2406
  replaceWithSvg(preEl, svg, className);
2227
2407
  } catch (err) {
2228
2408
  if (onError) onError(code, err instanceof Error ? err : new Error(String(err)));
@@ -2230,9 +2410,34 @@ async function renderMermaidBlocks(container, options = {}) {
2230
2410
  });
2231
2411
  await Promise.all(renderPromises);
2232
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
+ ];
2233
2434
  function replaceWithSvg(preEl, svg, className) {
2234
2435
  const wrapper = document.createElement("div");
2235
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
+ }
2236
2441
  wrapper.innerHTML = svg;
2237
2442
  preEl.replaceWith(wrapper);
2238
2443
  }