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/index.cjs CHANGED
@@ -46,6 +46,21 @@ let unist_util_visit = require("unist-util-visit");
46
46
  let yaml = require("yaml");
47
47
  yaml = __toESM(yaml, 1);
48
48
  let smol_toml = require("smol-toml");
49
+ //#region src/rehypeVantageAnchors.ts
50
+ /** What `rehypeVantageDirectives` stamps, in hast property form. */
51
+ const OQ_ID_PROPERTY$1 = "dataVantageOqId";
52
+ function rehypeVantageAnchors() {
53
+ return (tree) => {
54
+ (0, unist_util_visit.visit)(tree, "element", (node) => {
55
+ const carried = node.properties?.[OQ_ID_PROPERTY$1];
56
+ if (typeof carried !== "string" || carried === "") return;
57
+ delete node.properties[OQ_ID_PROPERTY$1];
58
+ if (typeof node.properties.id === "string" && node.properties.id !== "") return;
59
+ node.properties.id = carried;
60
+ });
61
+ };
62
+ }
63
+ //#endregion
49
64
  //#region src/rehypeSourceLines.ts
50
65
  /**
51
66
  * Tags that get a `data-source-line`.
@@ -317,12 +332,17 @@ const VANTAGE_RUNS = [
317
332
  "only"
318
333
  ];
319
334
  /**
320
- * The tags a `section`/`block` directive may stamp.
335
+ * The tags a `section`/`block` directive may **target**.
336
+ *
337
+ * Deliberately `rehypeSourceLines`'s `BLOCK_TAGS`: a directive's target should
338
+ * also be a block with a `data-source-line`, so the styling surface and the
339
+ * anchor surface coincide. It also keeps an inline directive from stamping the
340
+ * `<em>` that happens to follow it inside a paragraph.
321
341
  *
322
- * Deliberately `rehypeSourceLines`'s `BLOCK_TAGS`: a stamped block should also
323
- * be a block with a `data-source-line`, so the styling surface and the anchor
324
- * surface coincide. It also keeps an inline directive from stamping the `<em>`
325
- * that happens to follow it inside a paragraph.
342
+ * It does **not** bound a `section`'s range. Every element in the span is
343
+ * stamped, on the tag list or not, because a member only has to be a box in the
344
+ * flow for the section's vertical rule to cross it see `styleRange` in
345
+ * `rehypeVantageDirectives.ts` for the hole that restricting the range left.
326
346
  *
327
347
  * It lives here rather than in the plugin because the CLI checker has to answer
328
348
  * "will this directive stamp anything?" from an mdast tree with no hast in
@@ -389,6 +409,23 @@ const VANTAGE_ANCHOR_TARGETS = [
389
409
  * and said nothing, which is the D5 break this module exists to prevent.
390
410
  */
391
411
  const VANTAGE_OQ_HOST_TARGETS = VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
412
+ /**
413
+ * The shape of an `oq` directive's `id`: `OQ-` then an optional short uppercase
414
+ * prefix then digits. `OQ-9`, `OQ-TP6` and `OQ-A03` are ids; `OQ-foo`, `OQ-tp6`
415
+ * and a bare `OQ6` are not.
416
+ *
417
+ * The prefix is what keeps ids distinct once one document references another's
418
+ * questions — `trust-paths.md`'s `OQ-4` and a design sketch's `OQ-4` are
419
+ * different questions, and a bare number cannot say which one a cross-document
420
+ * reference means. It is optional because most documents never leave their own
421
+ * file, and requiring it everywhere would fire on every single-doc sketch.
422
+ *
423
+ * Three consumers read it from here and none of them may re-spell it: the
424
+ * plugin that stamps the anchor, the sanitiser that allowlists the value, and
425
+ * the checker's `vantage/oq-id-format`. A fourth copy is how the checker starts
426
+ * calling a working anchor malformed.
427
+ */
428
+ const VANTAGE_OQ_ID = /^OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+$/;
392
429
  const STYLE_KEYS = {
393
430
  tone: VANTAGE_TONES,
394
431
  emphasis: VANTAGE_EMPHASIS,
@@ -531,11 +568,13 @@ function parseVantageDirective(comment) {
531
568
  //#endregion
532
569
  //#region src/rehypeVantageDirectives.ts
533
570
  /**
534
- * What a `section`/`block` and an `oq` directive may stamp.
571
+ * What a `section`/`block` and an `oq` directive may **target**.
535
572
  *
536
573
  * Both lists live in `vantageDirectives.ts`, with the reasoning for each tag,
537
574
  * because the CLI checker resolves the same question over mdast and must reach
538
575
  * the same answer (D5).
576
+ *
577
+ * Neither list bounds a `section`'s range: see `styleRange`.
539
578
  */
540
579
  const STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
541
580
  const ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
@@ -582,6 +621,18 @@ const RUN_PROPERTY = "dataVantageRun";
582
621
  const OQ_PROPERTY = "dataVantageOq";
583
622
  const LEANING_PROPERTY = "dataVantageLeaning";
584
623
  /**
624
+ * The id, carried as a `data-` attribute rather than written straight to `id`.
625
+ *
626
+ * This plugin runs *before* `rehypeSanitize` — it has to, it reads comments and
627
+ * the sanitiser deletes them — and the sanitiser's default schema clobbers `id`
628
+ * with the prefix `user-content-`. A bare `id` set here would reach the page as
629
+ * `user-content-OQ-4`, every `#OQ-4` link in every document would land nowhere,
630
+ * and nothing would error. `rehypeVantageAnchors` promotes this to a real `id`
631
+ * on the other side of the sanitiser, which is the same reason `rehypeSlug` is
632
+ * registered there (`pipeline.ts`).
633
+ */
634
+ const OQ_ID_PROPERTY = "dataVantageOqId";
635
+ /**
585
636
  * The three properties `collapsed=true` stamps across a section.
586
637
  *
587
638
  * The heading takes a *different* attribute from the blocks it hides, and that
@@ -654,6 +705,22 @@ function accepts(name, key, value) {
654
705
  * `section` before anything else degrades to that one block, and `block` is
655
706
  * always that one block. A heading nested inside a stamped `blockquote` or
656
707
  * `li` does not end the section: the walk never descends.
708
+ *
709
+ * **Every element in the span, not only a `VANTAGE_STYLE_TARGETS` one.** That
710
+ * list gates the *target* and nothing else. Restricting the range to it as well
711
+ * used to leave a raw-HTML `<figure>`, `<dl>` or `<details>` unstamped between
712
+ * two stamped paragraphs — and the section's one continuous vertical rule is
713
+ * drawn per member, so an unstamped member is a hole the height of the block
714
+ * plus its margins. Measured over the real stylesheet: 44px for a one-line
715
+ * `<figure>`, against the 40px a neighbour can bleed upward, and arbitrarily
716
+ * large for anything taller. `collapsed=true` had the same shape of bug the
717
+ * other way round — it hid the paragraphs and left the figure on the page.
718
+ *
719
+ * The two lists answering different questions is the point, not an oversight:
720
+ * a *target* must be a block a review anchor can name, because a directive
721
+ * pointing at something unanchorable is a directive with no addressable effect.
722
+ * A *member* only has to be a box in the flow, because all it does is carry the
723
+ * run's tone across itself.
657
724
  */
658
725
  function styleRange(children, targetIndex, name) {
659
726
  const range = [targetIndex];
@@ -663,7 +730,7 @@ function styleRange(children, targetIndex, name) {
663
730
  const node = children[i];
664
731
  const nodeDepth = headingDepth(node);
665
732
  if (nodeDepth !== void 0 && nodeDepth <= depth) break;
666
- if (node.type === "element" && STYLE_TARGET_TAGS.has(node.tagName)) range.push(i);
733
+ if (node.type === "element") range.push(i);
667
734
  }
668
735
  return range;
669
736
  }
@@ -722,6 +789,8 @@ function stampStyle(children, targetIndex, name, pairs, state) {
722
789
  }
723
790
  function stampOq(target, pairs) {
724
791
  setProperty(target, OQ_PROPERTY, "true");
792
+ const id = pairs.get("id");
793
+ if (id !== void 0 && id !== "") setProperty(target, OQ_ID_PROPERTY, id);
725
794
  const leaning = pairs.get("leaning");
726
795
  if (leaning === void 0) return;
727
796
  const text = leaning.replace(/\s+/g, " ").trim().slice(0, MAX_LEANING);
@@ -1027,6 +1096,7 @@ const sanitizeSchema = {
1027
1096
  ["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
1028
1097
  ["dataVantageRun", ...VANTAGE_RUNS],
1029
1098
  ["dataVantageOq", "true"],
1099
+ ["dataVantageOqId", VANTAGE_OQ_ID],
1030
1100
  ["dataVantageAlert", ...VANTAGE_ALERTS],
1031
1101
  "dataVantageLeaning"
1032
1102
  ],
@@ -1076,6 +1146,7 @@ function buildRehypePlugins(options = {}) {
1076
1146
  plugins.push(rehypeVantageAlerts);
1077
1147
  plugins.push(rehypeVantageDirectives);
1078
1148
  if (sanitize) plugins.push([rehype_sanitize.default, sanitizeSchema]);
1149
+ plugins.push(rehypeVantageAnchors);
1079
1150
  plugins.push(rehype_slug.default);
1080
1151
  if (highlight) plugins.push(rehype_highlight.default);
1081
1152
  if (math) plugins.push(rehypeCaptureMathStamps, rehype_katex.default, rehypeRestoreMathStamps);
@@ -1469,27 +1540,92 @@ function readStatusChip(frontmatter, raw, issues) {
1469
1540
  });
1470
1541
  }
1471
1542
  //#endregion
1543
+ //#region src/mermaidTheme.ts
1544
+ /** Whether the document is asking for the dark palette right now. */
1545
+ function currentMermaidTheme() {
1546
+ return typeof document !== "undefined" && document.documentElement.classList.contains("dark") ? "dark" : "default";
1547
+ }
1548
+ /**
1549
+ * Theme variables per theme. Mermaid derives most of its palette from these, so
1550
+ * the set is deliberately small: the surfaces, the ink, and the lines.
1551
+ */
1552
+ const THEME_VARIABLES = {
1553
+ dark: {
1554
+ background: "#1d293d",
1555
+ mainBkg: "#314158",
1556
+ nodeBorder: "#90a1b9",
1557
+ nodeTextColor: "#f1f5f9",
1558
+ lineColor: "#90a1b9",
1559
+ textColor: "#e2e8f0",
1560
+ edgeLabelBackground: "#1d293d"
1561
+ },
1562
+ default: {
1563
+ background: "#f8fafc",
1564
+ mainBkg: "#f1f5f9",
1565
+ nodeBorder: "#62748e",
1566
+ nodeTextColor: "#0f172b",
1567
+ lineColor: "#62748e",
1568
+ textColor: "#1d293d",
1569
+ edgeLabelBackground: "#f8fafc"
1570
+ }
1571
+ };
1572
+ function mermaidThemeVariables(theme) {
1573
+ return THEME_VARIABLES[theme];
1574
+ }
1575
+ //#endregion
1472
1576
  //#region src/mermaidCache.ts
1473
1577
  const svgCache = /* @__PURE__ */ new Map();
1578
+ const cacheKey = (code, theme) => `${theme}${code}`;
1579
+ /** The SVG for this fence in the theme the page is currently asking for. */
1580
+ function getCachedSvg(code, theme = currentMermaidTheme()) {
1581
+ return svgCache.get(cacheKey(code, theme));
1582
+ }
1583
+ function setCachedSvg(code, svg, theme = currentMermaidTheme()) {
1584
+ svgCache.set(cacheKey(code, theme), svg);
1585
+ }
1474
1586
  //#endregion
1475
1587
  //#region src/mermaidLoader.ts
1476
1588
  let mermaidInstance = null;
1477
1589
  let mermaidLoading = null;
1478
- const isDark = () => typeof document !== "undefined" && document.documentElement.classList.contains("dark");
1590
+ /** The theme the loaded instance was last configured for, `null` until loaded. */
1591
+ let configuredTheme = null;
1592
+ function configure(m, theme) {
1593
+ m.initialize({
1594
+ startOnLoad: false,
1595
+ theme,
1596
+ themeVariables: mermaidThemeVariables(theme),
1597
+ securityLevel: "strict",
1598
+ suppressErrorRendering: true
1599
+ });
1600
+ configuredTheme = theme;
1601
+ }
1602
+ /**
1603
+ * The mermaid module, configured for the theme the page is asking for *now*.
1604
+ *
1605
+ * Re-configuring on a theme change is the point. `initialize` used to run once,
1606
+ * on first import, so every diagram rendered after a light/dark switch still
1607
+ * came out in the palette the session started in — a white slab of a flowchart
1608
+ * on the dark page, or a black one on the light page. `initialize` merges into
1609
+ * mermaid's global config, so calling it again is how the next `render` picks
1610
+ * the new palette up; the cache is keyed by theme so the old SVGs are not
1611
+ * served instead (`mermaidCache.ts`).
1612
+ */
1479
1613
  async function getMermaid() {
1480
- if (mermaidInstance) return mermaidInstance;
1614
+ const theme = currentMermaidTheme();
1615
+ if (mermaidInstance) {
1616
+ if (configuredTheme !== theme) configure(mermaidInstance, theme);
1617
+ return mermaidInstance;
1618
+ }
1481
1619
  if (!mermaidLoading) mermaidLoading = import("mermaid").then((mod) => {
1482
1620
  const m = mod.default;
1483
- m.initialize({
1484
- startOnLoad: false,
1485
- theme: isDark() ? "dark" : "default",
1486
- securityLevel: "strict",
1487
- suppressErrorRendering: true
1488
- });
1621
+ configure(m, currentMermaidTheme());
1489
1622
  mermaidInstance = m;
1490
1623
  return m;
1491
1624
  });
1492
- return mermaidLoading;
1625
+ const loaded = await mermaidLoading;
1626
+ const wanted = currentMermaidTheme();
1627
+ if (configuredTheme !== wanted) configure(loaded, wanted);
1628
+ return loaded;
1493
1629
  }
1494
1630
  //#endregion
1495
1631
  //#region src/renderMermaidBlocks.ts
@@ -1523,18 +1659,20 @@ async function renderMermaidBlocks(container, options = {}) {
1523
1659
  const { className = "mermaid", onError } = options;
1524
1660
  const codeBlocks = container.querySelectorAll("pre > code.language-mermaid, pre > code[class*=\"language-mermaid\"]");
1525
1661
  if (codeBlocks.length === 0) return;
1526
- const mermaid = await getMermaid();
1662
+ let loading;
1663
+ const mermaidOnce = () => loading ??= getMermaid();
1527
1664
  const renderPromises = Array.from(codeBlocks).map(async (codeEl) => {
1528
1665
  const preEl = codeEl.parentElement;
1529
1666
  if (!preEl) return;
1530
1667
  const code = codeEl.textContent || "";
1531
1668
  if (!code.trim()) return;
1532
- const cached = svgCache.get(code);
1669
+ const cached = getCachedSvg(code);
1533
1670
  if (cached) {
1534
1671
  replaceWithSvg(preEl, cached, className);
1535
1672
  return;
1536
1673
  }
1537
1674
  try {
1675
+ const mermaid = await mermaidOnce();
1538
1676
  let hash = 0;
1539
1677
  for (let i = 0; i < code.length; i++) {
1540
1678
  hash = (hash << 5) - hash + code.charCodeAt(i);
@@ -1542,7 +1680,7 @@ async function renderMermaidBlocks(container, options = {}) {
1542
1680
  }
1543
1681
  const id = `mermaid-${Math.abs(hash).toString(36)}-${Date.now()}`;
1544
1682
  const { svg } = await mermaid.render(id, code);
1545
- svgCache.set(code, svg);
1683
+ setCachedSvg(code, svg);
1546
1684
  replaceWithSvg(preEl, svg, className);
1547
1685
  } catch (err) {
1548
1686
  if (onError) onError(code, err instanceof Error ? err : new Error(String(err)));
@@ -1550,9 +1688,34 @@ async function renderMermaidBlocks(container, options = {}) {
1550
1688
  });
1551
1689
  await Promise.all(renderPromises);
1552
1690
  }
1691
+ /**
1692
+ * Attributes the wrapper inherits from the `<pre>` it replaces.
1693
+ *
1694
+ * The splice is the same shape of problem `rehypeVantageMathStamps` solves for
1695
+ * KaTeX: the pipeline stamped the fence, and swapping the element out throws
1696
+ * the stamps away. A mermaid diagram inside a toned section then drew no slice
1697
+ * of the section's vertical rule, leaving a hole as tall as the diagram; a
1698
+ * collapsed section left the diagram visible under a closed heading; and a
1699
+ * `#L` anchor pointing at the fence resolved to nothing.
1700
+ *
1701
+ * Named individually rather than copied wholesale: `class` is the caller's
1702
+ * (`className`), and `id` would be duplicated onto a second element.
1703
+ */
1704
+ const CARRIED_ATTRIBUTES = [
1705
+ "data-source-line",
1706
+ "data-vantage-tone",
1707
+ "data-vantage-emphasis",
1708
+ "data-vantage-run",
1709
+ "data-vantage-collapsed",
1710
+ "data-vantage-collapse-group"
1711
+ ];
1553
1712
  function replaceWithSvg(preEl, svg, className) {
1554
1713
  const wrapper = document.createElement("div");
1555
1714
  wrapper.className = className;
1715
+ for (const name of CARRIED_ATTRIBUTES) {
1716
+ const value = preEl.getAttribute(name);
1717
+ if (value !== null) wrapper.setAttribute(name, value);
1718
+ }
1556
1719
  wrapper.innerHTML = svg;
1557
1720
  preEl.replaceWith(wrapper);
1558
1721
  }
@@ -1719,6 +1882,8 @@ The steps below predate the rewrite.
1719
1882
  - **Anything outside those sets is silently ignored** — nothing breaks, and nothing styles either. Run \`vantage-check\` on the document: the \`vantage/*\` rules are the only thing that will ever tell you a directive did nothing.
1720
1883
  - **Always close the comment with \`-->\`.** Never \`--!>\`, and never leave it open: Markdown reads every line below an unclosed \`<!--\` as part of the comment, and the whole rest of the document vanishes from the page. For the same reason \`-->\` cannot appear *inside* a value — it ends the comment early and spills the remainder into the page as literal text.
1721
1884
  - **In a list, indent the directive inside the item**, with blank lines around it (below). At the start of a line between two items it ends the list and starts a second one, which changes the numbering and the spacing in every renderer — the one thing a directive must never do.
1885
+ - **An open question's id is \`OQ-\` then an optional short uppercase prefix then digits** — \`OQ-9\`, \`OQ-TP6\`, \`OQ-A03\`. The prefix is what keeps ids distinct once one document references another's questions, so use one in both whenever they cross-reference. \`vantage-check\` reports anything outside that shape as \`vantage/oq-id-format\`, and the same id twice in one document as \`vantage/oq-id-duplicate\` — both are silent otherwise, because the id becomes the block's anchor and a refused or duplicated one simply goes nowhere.
1886
+ - **A reference is a link, or it is a lie.** An \`OQ-\` id, a \`\u00a7N\` section number and a filename all read like pointers, and written as bare prose none of them can be followed or checked — which is exactly why a stale one is never caught. Link the question to its anchor (\`[OQ-4](#OQ-4)\`, or the Decision Ledger once it is compacted), the section to its heading, the filename to the file. \`vantage-check\` reports all three (\`ref/*\`) as errors, and checks that the link points at the thing the reference names rather than merely at something. Writing a specimen rather than a reference? Put it in a fenced block, which the rules never read.
1722
1887
  - **Every open question (\u{1F4AC}) with a stated leaning gets an \`oq\` directive.** The convention's prose — the emoji, the \`OQ-N\` id, the \`_Leaning:_\` line, the fill-in \`**Answer:**\` — produces no button on its own. Writing the convention and stopping there is the most common way this feature goes missing: the questions look complete, review mode is on, and there is nothing to click. **\`vantage-check\` reports it as an error** (\`vantage/oq-missing\`), because a question awaiting a ruling that the reviewer cannot file is not a style preference. Mark it \u{1F512} if it is blocked on something upstream and cannot be answered yet, or \u2705 once it is decided; either state needs no directive.
1723
1888
  - **A \`leaning\` restates the leaning; it is never "yes".** The one-click button in review mode files that text as a review comment, and the comment is all the agent reading it has — nobody remembers which button was clicked. \`leaning="Yes"\` beside a two-branch question is a support ticket.
1724
1889
 
@@ -1764,6 +1929,7 @@ exports.parseVantageDirective = parseVantageDirective;
1764
1929
  exports.readVantageFrontmatter = readVantageFrontmatter;
1765
1930
  exports.rehypeSourceLines = rehypeSourceLines;
1766
1931
  exports.rehypeVantageAlerts = rehypeVantageAlerts;
1932
+ exports.rehypeVantageAnchors = rehypeVantageAnchors;
1767
1933
  exports.rehypeVantageDirectives = rehypeVantageDirectives;
1768
1934
  exports.renderMarkdown = renderMarkdown;
1769
1935
  exports.renderMermaidBlocks = renderMermaidBlocks;