vantage-md 0.5.5 → 0.5.7

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
@@ -1,1853 +1,2275 @@
1
- 'use strict';
2
-
3
- var react = require('react');
4
- var ReactMarkdown = require('react-markdown');
5
- var remarkGfm = require('remark-gfm');
6
- var remarkMath = require('remark-math');
7
- var rehypeRaw = require('rehype-raw');
8
- var rehypeSanitize = require('rehype-sanitize');
9
- var rehypeHighlight = require('rehype-highlight');
10
- var rehypeKatex = require('rehype-katex');
11
- var rehypeSlug = require('rehype-slug');
12
- var YAML = require('yaml');
13
- var smolToml = require('smol-toml');
14
- var jsxRuntime = require('react/jsx-runtime');
15
- var unified = require('unified');
16
- var remarkParse = require('remark-parse');
17
- var remarkRehype = require('remark-rehype');
18
- var rehypeStringify = require('rehype-stringify');
19
-
20
- function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
21
-
22
- var ReactMarkdown__default = /*#__PURE__*/_interopDefault(ReactMarkdown);
23
- var remarkGfm__default = /*#__PURE__*/_interopDefault(remarkGfm);
24
- var remarkMath__default = /*#__PURE__*/_interopDefault(remarkMath);
25
- var rehypeRaw__default = /*#__PURE__*/_interopDefault(rehypeRaw);
26
- var rehypeSanitize__default = /*#__PURE__*/_interopDefault(rehypeSanitize);
27
- var rehypeHighlight__default = /*#__PURE__*/_interopDefault(rehypeHighlight);
28
- var rehypeKatex__default = /*#__PURE__*/_interopDefault(rehypeKatex);
29
- var rehypeSlug__default = /*#__PURE__*/_interopDefault(rehypeSlug);
30
- var YAML__default = /*#__PURE__*/_interopDefault(YAML);
31
- var remarkParse__default = /*#__PURE__*/_interopDefault(remarkParse);
32
- var remarkRehype__default = /*#__PURE__*/_interopDefault(remarkRehype);
33
- var rehypeStringify__default = /*#__PURE__*/_interopDefault(rehypeStringify);
34
-
35
- // src/MarkdownViewer.tsx
36
-
37
- // src/rehypeSourceLines.ts
38
- var BLOCK_TAGS = /* @__PURE__ */ new Set([
39
- "p",
40
- "h1",
41
- "h2",
42
- "h3",
43
- "h4",
44
- "h5",
45
- "h6",
46
- "li",
47
- "blockquote",
48
- "pre",
49
- "table",
50
- "tr",
51
- "ul",
52
- "ol",
53
- "hr",
54
- "div"
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+ //#endregion
24
+ let react = require("react");
25
+ react = __toESM(react, 1);
26
+ let react_markdown = require("react-markdown");
27
+ react_markdown = __toESM(react_markdown, 1);
28
+ let remark_gfm = require("remark-gfm");
29
+ remark_gfm = __toESM(remark_gfm, 1);
30
+ let remark_math = require("remark-math");
31
+ remark_math = __toESM(remark_math, 1);
32
+ let rehype_raw = require("rehype-raw");
33
+ rehype_raw = __toESM(rehype_raw, 1);
34
+ let rehype_sanitize = require("rehype-sanitize");
35
+ rehype_sanitize = __toESM(rehype_sanitize, 1);
36
+ let rehype_highlight = require("rehype-highlight");
37
+ rehype_highlight = __toESM(rehype_highlight, 1);
38
+ let rehype_katex = require("rehype-katex");
39
+ rehype_katex = __toESM(rehype_katex, 1);
40
+ let rehype_slug = require("rehype-slug");
41
+ rehype_slug = __toESM(rehype_slug, 1);
42
+ let unist_util_visit = require("unist-util-visit");
43
+ let yaml = require("yaml");
44
+ yaml = __toESM(yaml, 1);
45
+ let smol_toml = require("smol-toml");
46
+ let react_jsx_runtime = require("react/jsx-runtime");
47
+ let unified = require("unified");
48
+ let remark_parse = require("remark-parse");
49
+ remark_parse = __toESM(remark_parse, 1);
50
+ let remark_rehype = require("remark-rehype");
51
+ remark_rehype = __toESM(remark_rehype, 1);
52
+ let rehype_stringify = require("rehype-stringify");
53
+ rehype_stringify = __toESM(rehype_stringify, 1);
54
+ //#region src/rehypeSourceLines.ts
55
+ const BLOCK_TAGS = /* @__PURE__ */ new Set([
56
+ "p",
57
+ "h1",
58
+ "h2",
59
+ "h3",
60
+ "h4",
61
+ "h5",
62
+ "h6",
63
+ "li",
64
+ "blockquote",
65
+ "pre",
66
+ "table",
67
+ "tr",
68
+ "ul",
69
+ "ol",
70
+ "hr",
71
+ "div"
55
72
  ]);
56
- function visit(node, offset) {
57
- if ("children" in node) {
58
- for (const child of node.children) {
59
- if (child.type === "element") {
60
- if (BLOCK_TAGS.has(child.tagName) && child.position?.start?.line) {
61
- child.properties = child.properties || {};
62
- child.properties["dataSourceLine"] = child.position.start.line + offset;
63
- }
64
- visit(child, offset);
65
- }
66
- }
67
- }
73
+ function visit$1(node, offset) {
74
+ if ("children" in node) {
75
+ for (const child of node.children) if (child.type === "element") {
76
+ if (BLOCK_TAGS.has(child.tagName) && child.position?.start?.line) {
77
+ child.properties = child.properties || {};
78
+ child.properties["dataSourceLine"] = child.position.start.line + offset;
79
+ }
80
+ visit$1(child, offset);
81
+ }
82
+ }
68
83
  }
69
- var rehypeSourceLines = (options) => {
70
- const offset = options?.offset ?? 0;
71
- return (tree) => {
72
- visit(tree, offset);
73
- };
84
+ const rehypeSourceLines = (options) => {
85
+ const offset = options?.offset ?? 0;
86
+ return (tree) => {
87
+ visit$1(tree, offset);
88
+ };
74
89
  };
75
- var rehypeSourceLines_default = rehypeSourceLines;
76
-
77
- // src/vantageDirectives.ts
78
- var VANTAGE_TONES = [
79
- "note",
80
- "tip",
81
- "important",
82
- "warning",
83
- "caution",
84
- "muted"
90
+ //#endregion
91
+ //#region src/rehypeVantageAlerts.ts
92
+ /**
93
+ * GFM alerts — `> [!WARNING]` — compiled into `data-vantage-alert`.
94
+ *
95
+ * `remark-gfm` does not implement alerts, so until this plugin existed a
96
+ * `> [!WARNING]` rendered as an ordinary blockquote with the literal marker
97
+ * visible as its first words. Worse than merely unstyled: `@tailwindcss/typography`
98
+ * italicises blockquotes and draws `open-quote`/`close-quote` around the first
99
+ * paragraph, so a callout came out as an italic *quotation* whose opening words
100
+ * were `"[!WARNING]`. That was the "Known gaps" entry in
101
+ * `docs/reference/inline-markup.md` and OQ-10, filed rather than fixed, while
102
+ * `styleGuide.ts` went on telling every agent to write them.
103
+ *
104
+ * The tokens are deliberately the ones the `tone` vocabulary already resolves —
105
+ * an alert *is* the six-colour light/dark treatment `tone` shipped, which is
106
+ * exactly what the gap entry said whoever fixed this should do rather than
107
+ * building a second palette. `[!WARNING]` and `<!-- vantage: block tone=warning -->`
108
+ * therefore agree by construction, and adding a theme still touches one
109
+ * custom-property block.
110
+ *
111
+ * **This runs in the shared pipeline, so all four renderers get it** — the live
112
+ * viewer, the package's exported viewer, the static export and the CLI checker's
113
+ * `renderMarkdown`. That is what makes an injected title element acceptable here
114
+ * where the collapse caret's glyph had to be drawn in CSS: the caret is injected
115
+ * by app JS that may never run, and this is not (D5).
116
+ *
117
+ * ## What it does not do
118
+ *
119
+ * It does not touch a blockquote that carries no marker, and an unrecognised
120
+ * marker (`[!HINT]`) is left exactly as it was — visible literal text, which is
121
+ * the honest rendering of something GitHub also would not style. Silently
122
+ * swallowing it would hide a typo that reads as a callout on neither renderer.
123
+ */
124
+ /**
125
+ * The five GFM alert kinds, lowercased.
126
+ *
127
+ * Deliberately *not* re-derived from `VANTAGE_TONES`: that list carries a sixth
128
+ * token, `muted`, which is ours and is not an alert word. The overlap is the
129
+ * point — the five that coincide share a palette — but the two vocabularies are
130
+ * closed by different authorities and a change to one must not silently move the
131
+ * other. A test asserts the five are a subset of the tones.
132
+ */
133
+ const VANTAGE_ALERTS = [
134
+ "note",
135
+ "tip",
136
+ "important",
137
+ "warning",
138
+ "caution"
85
139
  ];
86
- var VANTAGE_EMPHASIS = ["strong", "normal", "quiet"];
87
- var VANTAGE_BADGES = [
88
- "draft",
89
- "stale",
90
- "blocked",
91
- "done",
92
- "wip"
140
+ /** The visible label per kind. Title case, as GitHub renders it. */
141
+ const ALERT_TITLES = {
142
+ note: "Note",
143
+ tip: "Tip",
144
+ important: "Important",
145
+ warning: "Warning",
146
+ caution: "Caution"
147
+ };
148
+ /**
149
+ * The marker, anchored and requiring the rest of its line to be empty.
150
+ *
151
+ * GFM puts the marker alone on the blockquote's first line, and holding to that
152
+ * is what keeps a paragraph that merely *begins* with bracketed text from being
153
+ * eaten. The trailing newline is optional only for the degenerate blockquote
154
+ * whose entire content is the marker.
155
+ *
156
+ * Measured against the real chain rather than assumed: `remark-parse` reads
157
+ * `[!TIP]` as a shortcut link reference, and because no definition matches,
158
+ * `mdast-util-to-hast` puts it back as **one** leading text node —
159
+ * `"[!TIP]\nThe generalization: "` — not as a `[`/label/`]` triple. So a single
160
+ * anchored test on the first text node is enough, and the plugin does not have
161
+ * to reassemble the marker across siblings.
162
+ */
163
+ const MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/;
164
+ /** The first child, if it is an element. */
165
+ function firstElement(node) {
166
+ const child = node.children.find((c) => c.type === "element" || c.type === "text" && c.value.trim() !== "");
167
+ return child?.type === "element" ? child : void 0;
168
+ }
169
+ /**
170
+ * Compile `> [!KIND]` blockquotes into `data-vantage-alert="kind"`.
171
+ *
172
+ * Order in the chain matters twice, and both are stated in `pipeline.ts`:
173
+ *
174
+ * - **after `rehypeSourceLines`**, so the injected title carries no
175
+ * `data-source-line`. That is what keeps it out of `anchorBlockWithin`, which
176
+ * filters candidates to those with a finite line — otherwise a review comment
177
+ * on an alert would anchor to the word "Warning" instead of to the prose.
178
+ * - **before `rehypeSanitize`**, so nothing reaches the DOM the schema has not
179
+ * passed. `dataVantageAlert` is allowlisted there by name *and* value, like
180
+ * every other `data-vantage-*` attribute.
181
+ */
182
+ function rehypeVantageAlerts() {
183
+ return (tree) => {
184
+ (0, unist_util_visit.visit)(tree, "element", (node) => {
185
+ if (node.tagName !== "blockquote") return;
186
+ const paragraph = firstElement(node);
187
+ if (paragraph === void 0 || paragraph.tagName !== "p") return;
188
+ const lead = paragraph.children[0];
189
+ if (lead === void 0 || lead.type !== "text") return;
190
+ const match = MARKER.exec(lead.value);
191
+ if (match === null) return;
192
+ const kind = match[1].toLowerCase();
193
+ lead.value = lead.value.slice(match[0].length);
194
+ if (lead.value === "" && paragraph.children.length === 1) node.children = node.children.filter((c) => c !== paragraph);
195
+ node.properties = {
196
+ ...node.properties,
197
+ dataVantageAlert: kind
198
+ };
199
+ node.children.unshift({
200
+ type: "element",
201
+ tagName: "div",
202
+ properties: { className: ["vantage-alert-title"] },
203
+ children: [{
204
+ type: "text",
205
+ value: ALERT_TITLES[kind]
206
+ }]
207
+ });
208
+ });
209
+ };
210
+ }
211
+ //#endregion
212
+ //#region src/vantageDirectives.ts
213
+ /**
214
+ * The `tone` vocabulary: GitHub's alert words plus `muted`.
215
+ *
216
+ * Semantic, never chromatic (P2, Ledger OQ-3). A document says what a section
217
+ * *is*; the theme decides what that looks like, which is what lets one document
218
+ * render correctly in light, in dark, and in themes that do not exist yet.
219
+ */
220
+ const VANTAGE_TONES = [
221
+ "note",
222
+ "tip",
223
+ "important",
224
+ "warning",
225
+ "caution",
226
+ "muted"
227
+ ];
228
+ /** How much the block should pull the eye — separate from `tone` on purpose. */
229
+ const VANTAGE_EMPHASIS = [
230
+ "strong",
231
+ "normal",
232
+ "quiet"
93
233
  ];
94
- var VANTAGE_COLLAPSED = ["true", "false"];
95
- var VANTAGE_RUNS = ["start", "middle", "end", "only"];
96
- var VANTAGE_STYLE_TARGETS = [
97
- "p",
98
- "h1",
99
- "h2",
100
- "h3",
101
- "h4",
102
- "h5",
103
- "h6",
104
- "li",
105
- "blockquote",
106
- "pre",
107
- "table",
108
- "tr",
109
- "ul",
110
- "ol",
111
- "hr",
112
- "div"
234
+ /** A small chip beside the heading. */
235
+ const VANTAGE_BADGES = [
236
+ "draft",
237
+ "stale",
238
+ "blocked",
239
+ "done",
240
+ "wip"
113
241
  ];
114
- var VANTAGE_ANCHOR_TARGETS = [
115
- "p",
116
- "h1",
117
- "h2",
118
- "h3",
119
- "h4",
120
- "h5",
121
- "h6",
122
- "li",
123
- "blockquote",
124
- "pre",
125
- "table"
242
+ /**
243
+ * `collapsed` is a token, not a flag: `false` is the default written down.
244
+ *
245
+ * It stamps nothing on its own. Its one real effect is overriding a
246
+ * `collapsed=true` earlier in the same merged directive run — last key wins — so
247
+ * it is in the vocabulary rather than being an unknown value that drops. It
248
+ * cannot cancel an *enclosing* collapsed section: a nested heading is a hidden
249
+ * member of the outer group by design (A3), and the outer run is stamped before
250
+ * any inner directive has been resolved.
251
+ */
252
+ const VANTAGE_COLLAPSED = ["true", "false"];
253
+ /**
254
+ * Where a block sits in a stamped run, so section-wide CSS can join its members
255
+ * without an adjacent-sibling combinator.
256
+ *
257
+ * Not cosmetic. Review mode inserts comment cards as siblings *inside* a
258
+ * stamped run (`useReviewHighlights`), so `[tone] + [tone]` severs at every
259
+ * commented paragraph and bleeds across the boundary between two adjacent runs
260
+ * of different tone. An attribute survives both.
261
+ */
262
+ const VANTAGE_RUNS = [
263
+ "start",
264
+ "middle",
265
+ "end",
266
+ "only"
126
267
  ];
127
- var STYLE_KEYS = {
128
- tone: VANTAGE_TONES,
129
- emphasis: VANTAGE_EMPHASIS,
130
- badge: VANTAGE_BADGES,
131
- collapsed: VANTAGE_COLLAPSED
268
+ /**
269
+ * The tags a `section`/`block` directive may stamp.
270
+ *
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.
275
+ *
276
+ * It lives here rather than in the plugin because the CLI checker has to answer
277
+ * "will this directive stamp anything?" from an mdast tree with no hast in
278
+ * sight. A checker with its own copy of this list is a checker that calls a
279
+ * working directive an orphan, or stays silent about a dead one (D5).
280
+ */
281
+ const VANTAGE_STYLE_TARGETS = [
282
+ "p",
283
+ "h1",
284
+ "h2",
285
+ "h3",
286
+ "h4",
287
+ "h5",
288
+ "h6",
289
+ "li",
290
+ "blockquote",
291
+ "pre",
292
+ "table",
293
+ "tr",
294
+ "ul",
295
+ "ol",
296
+ "hr",
297
+ "div"
298
+ ];
299
+ /**
300
+ * The tags an `oq` directive may stamp — strictly the tags the review system
301
+ * can resolve an anchor on (`ANCHOR_TAGS` in the app's `MarkdownViewer`, and the
302
+ * block map in `useReviewHighlights`). `ul`, `ol`, `tr`, `hr` and `div` are in
303
+ * neither, so a button on one of them would build an anchor no review pass can
304
+ * find — the "mis-wired button" D6 forbids.
305
+ *
306
+ * The gap between this list and `VANTAGE_STYLE_TARGETS` is why an `oq`
307
+ * directive at column 0 above a list silently does nothing: the target is the
308
+ * `<ul>`, not the `<li>`. The checker says so.
309
+ */
310
+ const VANTAGE_ANCHOR_TARGETS = [
311
+ "p",
312
+ "h1",
313
+ "h2",
314
+ "h3",
315
+ "h4",
316
+ "h5",
317
+ "h6",
318
+ "li",
319
+ "blockquote",
320
+ "pre",
321
+ "table"
322
+ ];
323
+ VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
324
+ const STYLE_KEYS = {
325
+ tone: VANTAGE_TONES,
326
+ emphasis: VANTAGE_EMPHASIS,
327
+ badge: VANTAGE_BADGES,
328
+ collapsed: VANTAGE_COLLAPSED
132
329
  };
133
- var DIRECTIVE_VOCABULARY = {
134
- section: STYLE_KEYS,
135
- block: STYLE_KEYS,
136
- oq: { id: null, leaning: null }
330
+ /**
331
+ * Name → key → the closed value set for that key.
332
+ *
333
+ * `section` and `block` share their keys: they differ in *extent*, not in what
334
+ * they can say. `oq`'s two keys are the design's only values with no closed set
335
+ * — `id` is a token an author chose and `leaning` is a sentence (§8.3) — so
336
+ * neither can be value-allowlisted, which is recorded here as `null` rather
337
+ * than left to a caller to guess.
338
+ */
339
+ const DIRECTIVE_VOCABULARY = {
340
+ section: STYLE_KEYS,
341
+ block: STYLE_KEYS,
342
+ oq: {
343
+ id: null,
344
+ leaning: null
345
+ }
137
346
  };
138
- var WS = /[ \t\r\n]*/y;
139
- var SENTINEL_PREFIX = /^[ \t\r\n]*vantage:/;
140
- var NAME = /[a-z][a-z0-9-]*/y;
141
- var UNQUOTED = /[A-Za-z0-9_.:#-]+/y;
142
- var QUOTED = /"[^"]*"/y;
347
+ /**
348
+ * `ws` is `[ \t\r\n]` — the design's grammar leaves it undefined, and `\n` has
349
+ * to be in the set because a directive may legally wrap: a multi-line comment
350
+ * is one node whose value contains the newlines.
351
+ */
352
+ const WS = /[ \t\r\n]*/y;
353
+ const SENTINEL_PREFIX = /^[ \t\r\n]*vantage:/;
354
+ const NAME = /[a-z][a-z0-9-]*/y;
355
+ const UNQUOTED = /[A-Za-z0-9_.:#-]+/y;
356
+ /**
357
+ * A quoted value holds anything but a `"`, `--` included: measured through the
358
+ * real chain, `leaning="a--b"` reaches the tree intact, because HTML5 closes a
359
+ * comment on `-->` or `--!>` and on nothing else. There is deliberately **no**
360
+ * `--` restriction here. What a quoted value cannot hold is a terminator: a
361
+ * `-->` inside one ends the comment early and spills the tail into the document
362
+ * as literal text, which is a finding for the checker rather than a rule here —
363
+ * by the time this function runs, the truncation has already happened.
364
+ */
365
+ const QUOTED = /"[^"]*"/y;
366
+ /** The whole non-whitespace run at `offset`, capped, for a quotable message. */
143
367
  function token(comment, offset) {
144
- const rest = comment.slice(offset);
145
- const end = rest.search(/[ \t\r\n]/);
146
- const word = end === -1 ? rest : rest.slice(0, end);
147
- return word.length > 24 ? `${word.slice(0, 24)}\u2026` : word;
368
+ const rest = comment.slice(offset);
369
+ const end = rest.search(/[ \t\r\n]/);
370
+ const word = end === -1 ? rest : rest.slice(0, end);
371
+ return word.length > 24 ? `${word.slice(0, 24)}…` : word;
148
372
  }
373
+ /** The sticky match at `offset`, or `null` if the pattern does not apply. */
149
374
  function matchAt(pattern, comment, offset) {
150
- pattern.lastIndex = offset;
151
- const match = pattern.exec(comment);
152
- return match === null ? null : match[0];
375
+ pattern.lastIndex = offset;
376
+ const match = pattern.exec(comment);
377
+ return match === null ? null : match[0];
153
378
  }
379
+ /** How much whitespace sits at `offset`. `WS` matches everywhere, empty. */
154
380
  function skipWhitespace(comment, offset) {
155
- return matchAt(WS, comment, offset)?.length ?? 0;
381
+ return matchAt(WS, comment, offset)?.length ?? 0;
156
382
  }
157
383
  function malformed(reason, offset) {
158
- return { kind: "malformed", reason, offset };
384
+ return {
385
+ kind: "malformed",
386
+ reason,
387
+ offset
388
+ };
159
389
  }
390
+ /**
391
+ * Parse one comment's **inner** text — the value of a hast `comment` node, with
392
+ * `<!--` and `-->` already stripped. `null` means "no sentinel, not ours".
393
+ *
394
+ * Hand-rolled rather than one regular expression, because a repeated capture
395
+ * group keeps only its last match and the checker needs an offset per token to
396
+ * point at the character that broke.
397
+ */
160
398
  function parseVantageDirective(comment) {
161
- const sentinel = SENTINEL_PREFIX.exec(comment);
162
- if (sentinel === null) return null;
163
- let at = sentinel[0].length;
164
- at += skipWhitespace(comment, at);
165
- const nameOffset = at;
166
- const name = matchAt(NAME, comment, at);
167
- if (name === null) {
168
- return malformed("no directive name after `vantage:`", at);
169
- }
170
- at += name.length;
171
- const pairs = [];
172
- while (at < comment.length) {
173
- const gap = skipWhitespace(comment, at);
174
- at += gap;
175
- if (at >= comment.length) break;
176
- if (gap === 0) {
177
- return malformed(`\`${token(comment, at)}\` needs a space before it`, at);
178
- }
179
- const keyOffset = at;
180
- const key = matchAt(NAME, comment, at);
181
- if (key === null) {
182
- return malformed(
183
- `\`${token(comment, at)}\` is not a \`key=value\` pair`,
184
- at
185
- );
186
- }
187
- at += key.length;
188
- if (comment[at] !== "=") {
189
- return malformed(`\`${key}\` is not followed by \`=value\``, at);
190
- }
191
- at += 1;
192
- const valueOffset = at;
193
- const quoted = matchAt(QUOTED, comment, at);
194
- if (quoted !== null) {
195
- at += quoted.length;
196
- pairs.push({
197
- key,
198
- value: quoted.slice(1, -1),
199
- keyOffset,
200
- valueOffset,
201
- quoted: true
202
- });
203
- continue;
204
- }
205
- const unquoted = matchAt(UNQUOTED, comment, at);
206
- if (unquoted === null) {
207
- const found = token(comment, at);
208
- return malformed(
209
- found === "" ? `\`${key}=\` has no value` : `\`${found}\` is not a valid value for \`${key}\``,
210
- at
211
- );
212
- }
213
- at += unquoted.length;
214
- pairs.push({ key, value: unquoted, keyOffset, valueOffset, quoted: false });
215
- }
216
- return { kind: "directive", name, nameOffset, pairs };
399
+ const sentinel = SENTINEL_PREFIX.exec(comment);
400
+ if (sentinel === null) return null;
401
+ let at = sentinel[0].length;
402
+ at += skipWhitespace(comment, at);
403
+ const nameOffset = at;
404
+ const name = matchAt(NAME, comment, at);
405
+ if (name === null) return malformed("no directive name after `vantage:`", at);
406
+ at += name.length;
407
+ const pairs = [];
408
+ while (at < comment.length) {
409
+ const gap = skipWhitespace(comment, at);
410
+ at += gap;
411
+ if (at >= comment.length) break;
412
+ if (gap === 0) return malformed(`\`${token(comment, at)}\` needs a space before it`, at);
413
+ const keyOffset = at;
414
+ const key = matchAt(NAME, comment, at);
415
+ if (key === null) return malformed(`\`${token(comment, at)}\` is not a \`key=value\` pair`, at);
416
+ at += key.length;
417
+ if (comment[at] !== "=") return malformed(`\`${key}\` is not followed by \`=value\``, at);
418
+ at += 1;
419
+ const valueOffset = at;
420
+ const quoted = matchAt(QUOTED, comment, at);
421
+ if (quoted !== null) {
422
+ at += quoted.length;
423
+ pairs.push({
424
+ key,
425
+ value: quoted.slice(1, -1),
426
+ keyOffset,
427
+ valueOffset,
428
+ quoted: true
429
+ });
430
+ continue;
431
+ }
432
+ const unquoted = matchAt(UNQUOTED, comment, at);
433
+ if (unquoted === null) {
434
+ const found = token(comment, at);
435
+ return malformed(found === "" ? `\`${key}=\` has no value` : `\`${found}\` is not a valid value for \`${key}\``, at);
436
+ }
437
+ at += unquoted.length;
438
+ pairs.push({
439
+ key,
440
+ value: unquoted,
441
+ keyOffset,
442
+ valueOffset,
443
+ quoted: false
444
+ });
445
+ }
446
+ return {
447
+ kind: "directive",
448
+ name,
449
+ nameOffset,
450
+ pairs
451
+ };
217
452
  }
218
-
219
- // src/rehypeVantageDirectives.ts
220
- var STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
221
- var ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
222
- var HEADING_DEPTHS = /* @__PURE__ */ new Map([
223
- ["h1", 1],
224
- ["h2", 2],
225
- ["h3", 3],
226
- ["h4", 4],
227
- ["h5", 5],
228
- ["h6", 6]
453
+ //#endregion
454
+ //#region src/rehypeVantageDirectives.ts
455
+ /**
456
+ * What a `section`/`block` and an `oq` directive may stamp.
457
+ *
458
+ * Both lists live in `vantageDirectives.ts`, with the reasoning for each tag,
459
+ * because the CLI checker resolves the same question over mdast and must reach
460
+ * the same answer (D5).
461
+ */
462
+ const STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
463
+ const ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
464
+ const HEADING_DEPTHS = /* @__PURE__ */ new Map([
465
+ ["h1", 1],
466
+ ["h2", 2],
467
+ ["h3", 3],
468
+ ["h4", 4],
469
+ ["h5", 5],
470
+ ["h6", 6]
229
471
  ]);
230
- var RANGE_PROPERTIES = /* @__PURE__ */ new Map([
231
- ["tone", "dataVantageTone"],
232
- ["emphasis", "dataVantageEmphasis"]
233
- ]);
234
- var POINT_PROPERTIES = /* @__PURE__ */ new Map([["badge", "dataVantageBadge"]]);
235
- var RUN_PROPERTY = "dataVantageRun";
236
- var OQ_PROPERTY = "dataVantageOq";
237
- var LEANING_PROPERTY = "dataVantageLeaning";
238
- var COLLAPSED_PROPERTY = "dataVantageCollapsed";
239
- var COLLAPSE_GROUP_PROPERTY = "dataVantageCollapseGroup";
240
- var COLLAPSE_TOGGLE_PROPERTY = "dataVantageCollapseToggle";
241
- var MAX_LEANING = 500;
472
+ /**
473
+ * Key → hast property, for the keys that treat a whole run.
474
+ *
475
+ * A camelCase hast property serialises to the kebab-case attribute, so
476
+ * `dataVantageTone` is `data-vantage-tone` in every renderer.
477
+ *
478
+ * `tone` and `emphasis` describe what a section *is* and how loud it is, so
479
+ * every block in the range wears them: the tone rule is a slice of one
480
+ * continuous line down the section, and the weight applies to all of its prose.
481
+ *
482
+ * `collapsed` is not here because it is not one property on one block: it puts a
483
+ * toggle on the heading and a collapsed flag plus a group id on every block the
484
+ * heading hides, which `stampStyle` does with the three properties below.
485
+ */
486
+ const RANGE_PROPERTIES = /* @__PURE__ */ new Map([["tone", "dataVantageTone"], ["emphasis", "dataVantageEmphasis"]]);
487
+ /**
488
+ * Key → hast property, for the keys that mark one block: the directive's target.
489
+ *
490
+ * `badge` is the asymmetry in the vocabulary and the reason this second map
491
+ * exists. It is not a treatment of a run but a single chip — "a small chip after
492
+ * the heading text" (§4.3), drawn as `[data-vantage-badge]::after` — so a
493
+ * section-wide stamp paints the word once per paragraph, list, table and fence
494
+ * under the heading instead of once beside it.
495
+ *
496
+ * The chip is fixed here rather than in the stylesheet, because narrowing the
497
+ * CSS to `:is(h1, …, h6)` would silently draw nothing for the two placements
498
+ * that legitimately badge a non-heading — `block badge=…` on a paragraph, and a
499
+ * `section` that degraded onto one (A1) — and would leave an attribute stamped
500
+ * on every block that says something untrue about it.
501
+ */
502
+ const POINT_PROPERTIES = /* @__PURE__ */ new Map([["badge", "dataVantageBadge"]]);
503
+ const RUN_PROPERTY = "dataVantageRun";
504
+ const OQ_PROPERTY = "dataVantageOq";
505
+ const LEANING_PROPERTY = "dataVantageLeaning";
506
+ /**
507
+ * The three properties `collapsed=true` stamps across a section.
508
+ *
509
+ * The heading takes a *different* attribute from the blocks it hides, and that
510
+ * asymmetry is the whole design (A3): a nested `###` inside a collapsed `##` is
511
+ * both a hidden member of the outer group and the toggle for its own, so one
512
+ * shared attribute would make it permanently invisible and unreachable by
513
+ * either toggle. There is no `<details>` and no wrapper — the run stays a flat
514
+ * list of siblings, which is what keeps review comment cards, the typography
515
+ * plugin's `h2 + *` margin resets and the anchor surface working.
516
+ *
517
+ * Hiding is CSS, and that CSS is gated on two markers the toggle JS sets — the
518
+ * prose container's readiness, and an armed marker on each block whose group it
519
+ * gave a caret (`docs/reference/inline-markup.md`, "Collapse without a wrapper"). A renderer without the JS
520
+ * — the CLI checker's HTML, an external consumer of this package — shows every
521
+ * block, and so does any block that ended up with no control.
522
+ */
523
+ const COLLAPSED_PROPERTY = "dataVantageCollapsed";
524
+ const COLLAPSE_GROUP_PROPERTY = "dataVantageCollapseGroup";
525
+ const COLLAPSE_TOGGLE_PROPERTY = "dataVantageCollapseToggle";
526
+ /** A review-comment body, not prose. Bounds the attribute; 500 is generous. */
527
+ const MAX_LEANING = 500;
528
+ /**
529
+ * Nodes that may sit between a directive and its target.
530
+ *
531
+ * A whitespace-only `text` node always does — measured, with or without a blank
532
+ * line in the source. Comments do too, and an unrelated `<!-- TODO -->` must not
533
+ * break the chain: it is invisible in every renderer and deleted by the
534
+ * sanitiser, so letting it change a directive's meaning would make behaviour
535
+ * depend on something no reader can see.
536
+ */
242
537
  function isSkippable(node) {
243
- if (node.type === "comment" || node.type === "doctype") return true;
244
- if (node.type === "text") return node.value.trim() === "";
245
- return false;
538
+ if (node.type === "comment" || node.type === "doctype") return true;
539
+ if (node.type === "text") return node.value.trim() === "";
540
+ return false;
246
541
  }
247
542
  function headingDepth(node) {
248
- if (node.type !== "element") return void 0;
249
- return HEADING_DEPTHS.get(node.tagName);
543
+ if (node.type !== "element") return void 0;
544
+ return HEADING_DEPTHS.get(node.tagName);
250
545
  }
251
546
  function setProperty(element, property, value) {
252
- element.properties = element.properties ?? {};
253
- element.properties[property] = value;
547
+ element.properties = element.properties ?? {};
548
+ element.properties[property] = value;
254
549
  }
550
+ /**
551
+ * Where a member sits in a stamped run: `only` for a lone block, otherwise
552
+ * `start`, `middle`, `end`. See `VANTAGE_RUNS`.
553
+ */
255
554
  function runValue(index, length) {
256
- if (length === 1) return "only";
257
- if (index === 0) return "start";
258
- return index === length - 1 ? "end" : "middle";
555
+ if (length === 1) return "only";
556
+ if (index === 0) return "start";
557
+ return index === length - 1 ? "end" : "middle";
259
558
  }
559
+ /** The closed value set for one key, or `undefined` when the key is unknown. */
260
560
  function vocabularyOf(name, key) {
261
- return DIRECTIVE_VOCABULARY[name]?.[key];
561
+ return DIRECTIVE_VOCABULARY[name]?.[key];
262
562
  }
263
563
  function accepts(name, key, value) {
264
- const values = vocabularyOf(name, key);
265
- if (values === void 0) return false;
266
- return values === null || values.includes(value);
564
+ const values = vocabularyOf(name, key);
565
+ if (values === void 0) return false;
566
+ return values === null || values.includes(value);
267
567
  }
568
+ /**
569
+ * The nodes one style directive reaches, as indexes into its own parent's
570
+ * children — never outside that array, so a directive inside a blockquote or a
571
+ * list item cannot stamp past it.
572
+ *
573
+ * Position picks the target (the next sibling element); the name picks how far
574
+ * the stamp goes. `section` before a heading takes the heading and every
575
+ * following sibling until the first heading of the same or shallower depth;
576
+ * `section` before anything else degrades to that one block, and `block` is
577
+ * always that one block. A heading nested inside a stamped `blockquote` or
578
+ * `li` does not end the section: the walk never descends.
579
+ */
268
580
  function styleRange(children, targetIndex, name) {
269
- const range = [targetIndex];
270
- const depth = name === "section" ? headingDepth(children[targetIndex]) : void 0;
271
- if (depth === void 0) return range;
272
- for (let i = targetIndex + 1; i < children.length; i++) {
273
- const node = children[i];
274
- const nodeDepth = headingDepth(node);
275
- if (nodeDepth !== void 0 && nodeDepth <= depth) break;
276
- if (node.type === "element" && STYLE_TARGET_TAGS.has(node.tagName)) {
277
- range.push(i);
278
- }
279
- }
280
- return range;
581
+ const range = [targetIndex];
582
+ const depth = name === "section" ? headingDepth(children[targetIndex]) : void 0;
583
+ if (depth === void 0) return range;
584
+ for (let i = targetIndex + 1; i < children.length; i++) {
585
+ const node = children[i];
586
+ const nodeDepth = headingDepth(node);
587
+ if (nodeDepth !== void 0 && nodeDepth <= depth) break;
588
+ if (node.type === "element" && STYLE_TARGET_TAGS.has(node.tagName)) range.push(i);
589
+ }
590
+ return range;
281
591
  }
592
+ /**
593
+ * Whether this directive collapses its section — three ways to say no.
594
+ *
595
+ * `collapsed=false` stamps nothing: it is the default written down, and "not
596
+ * collapsed" is not a thing an attribute can usefully say. Its only effect is
597
+ * upstream of here — `stampRun` merges a run of comments last-key-wins, so a
598
+ * `false` cancels a `true` in the *same* run. It does not cancel an *enclosing*
599
+ * section: `styleRange` walks the outer heading's whole sibling span before any
600
+ * inner directive is resolved, and a nested heading being a hidden member of the
601
+ * outer group is the design (A3), not an oversight.
602
+ *
603
+ * A `block` scope is **dropped**, and so is a `section` that degraded onto
604
+ * a non-heading, because both would hide a lone paragraph with nothing left
605
+ * behind to reveal it — content that is simply gone, which is the P1/D8 failure
606
+ * the readiness gate exists to prevent. Only a heading can be a summary.
607
+ */
282
608
  function collapsesSection(name, pairs, target) {
283
- if (name !== "section") return false;
284
- if (pairs.get("collapsed") !== "true") return false;
285
- return headingDepth(target) !== void 0;
609
+ if (name !== "section") return false;
610
+ if (pairs.get("collapsed") !== "true") return false;
611
+ return headingDepth(target) !== void 0;
286
612
  }
287
613
  function stampStyle(children, targetIndex, name, pairs, state) {
288
- const target = children[targetIndex];
289
- if (!STYLE_TARGET_TAGS.has(target.tagName)) return;
290
- const rangeStamps = [];
291
- const targetStamps = [];
292
- for (const [key, value] of pairs) {
293
- if (!accepts(name, key, value)) continue;
294
- const rangeProperty = RANGE_PROPERTIES.get(key);
295
- if (rangeProperty !== void 0) {
296
- rangeStamps.push([rangeProperty, value]);
297
- continue;
298
- }
299
- const pointProperty = POINT_PROPERTIES.get(key);
300
- if (pointProperty !== void 0) targetStamps.push([pointProperty, value]);
301
- }
302
- const collapses = collapsesSection(name, pairs, target);
303
- if (rangeStamps.length === 0 && targetStamps.length === 0 && !collapses) {
304
- return;
305
- }
306
- const range = styleRange(children, targetIndex, name);
307
- const group = collapses && range.length > 1 ? String(state.nextGroup++) : void 0;
308
- for (let i = 0; i < range.length; i++) {
309
- const element = children[range[i]];
310
- for (const [property, value] of rangeStamps) {
311
- setProperty(element, property, value);
312
- }
313
- if (i === 0) {
314
- for (const [property, value] of targetStamps) {
315
- setProperty(element, property, value);
316
- }
317
- }
318
- if (rangeStamps.length > 0) {
319
- setProperty(element, RUN_PROPERTY, runValue(i, range.length));
320
- }
321
- if (group === void 0) continue;
322
- if (i === 0) {
323
- setProperty(element, COLLAPSE_TOGGLE_PROPERTY, group);
324
- } else {
325
- setProperty(element, COLLAPSED_PROPERTY, "true");
326
- setProperty(element, COLLAPSE_GROUP_PROPERTY, group);
327
- }
328
- }
614
+ const target = children[targetIndex];
615
+ if (!STYLE_TARGET_TAGS.has(target.tagName)) return;
616
+ const rangeStamps = [];
617
+ const targetStamps = [];
618
+ for (const [key, value] of pairs) {
619
+ if (!accepts(name, key, value)) continue;
620
+ const rangeProperty = RANGE_PROPERTIES.get(key);
621
+ if (rangeProperty !== void 0) {
622
+ rangeStamps.push([rangeProperty, value]);
623
+ continue;
624
+ }
625
+ const pointProperty = POINT_PROPERTIES.get(key);
626
+ if (pointProperty !== void 0) targetStamps.push([pointProperty, value]);
627
+ }
628
+ const collapses = collapsesSection(name, pairs, target);
629
+ if (rangeStamps.length === 0 && targetStamps.length === 0 && !collapses) return;
630
+ const range = styleRange(children, targetIndex, name);
631
+ const group = collapses && range.length > 1 ? String(state.nextGroup++) : void 0;
632
+ for (let i = 0; i < range.length; i++) {
633
+ const element = children[range[i]];
634
+ for (const [property, value] of rangeStamps) setProperty(element, property, value);
635
+ if (i === 0) for (const [property, value] of targetStamps) setProperty(element, property, value);
636
+ if (rangeStamps.length > 0) setProperty(element, RUN_PROPERTY, runValue(i, range.length));
637
+ if (group === void 0) continue;
638
+ if (i === 0) setProperty(element, COLLAPSE_TOGGLE_PROPERTY, group);
639
+ else {
640
+ setProperty(element, COLLAPSED_PROPERTY, "true");
641
+ setProperty(element, COLLAPSE_GROUP_PROPERTY, group);
642
+ }
643
+ }
329
644
  }
330
645
  function stampOq(target, pairs) {
331
- setProperty(target, OQ_PROPERTY, "true");
332
- const leaning = pairs.get("leaning");
333
- if (leaning === void 0) return;
334
- const text = leaning.replace(/\s+/g, " ").trim().slice(0, MAX_LEANING);
335
- if (text !== "") setProperty(target, LEANING_PROPERTY, text);
646
+ setProperty(target, OQ_PROPERTY, "true");
647
+ const leaning = pairs.get("leaning");
648
+ if (leaning === void 0) return;
649
+ const text = leaning.replace(/\s+/g, " ").trim().slice(0, MAX_LEANING);
650
+ if (text !== "") setProperty(target, LEANING_PROPERTY, text);
336
651
  }
652
+ /**
653
+ * Merge one run of directives onto one target, then stamp.
654
+ *
655
+ * Merging is defined on the tree, not on the source: every directive comment up
656
+ * to the target merges, last-key-wins, whether or not blank lines separate
657
+ * them. Measured — adjacent comments and comments separated by a blank line
658
+ * produce byte-identical trees, so a rule that told them apart would have to
659
+ * re-read line numbers to do it.
660
+ */
337
661
  function stampRun(children, targetIndex, run, state) {
338
- const target = children[targetIndex];
339
- const style = /* @__PURE__ */ new Map();
340
- const oq = /* @__PURE__ */ new Map();
341
- let styleName;
342
- let hasOq = false;
343
- for (const directive of run) {
344
- if (directive.name === "section" || directive.name === "block") {
345
- styleName = directive.name;
346
- for (const pair of directive.pairs) style.set(pair.key, pair.value);
347
- } else if (directive.name === "oq") {
348
- hasOq = true;
349
- for (const pair of directive.pairs) oq.set(pair.key, pair.value);
350
- }
351
- }
352
- if (styleName !== void 0) {
353
- stampStyle(children, targetIndex, styleName, style, state);
354
- }
355
- if (hasOq && ANCHOR_TARGET_TAGS.has(target.tagName)) {
356
- stampOq(target, oq);
357
- }
662
+ const target = children[targetIndex];
663
+ const style = /* @__PURE__ */ new Map();
664
+ const oq = /* @__PURE__ */ new Map();
665
+ let styleName;
666
+ let hasOq = false;
667
+ for (const directive of run) if (directive.name === "section" || directive.name === "block") {
668
+ styleName = directive.name;
669
+ for (const pair of directive.pairs) style.set(pair.key, pair.value);
670
+ } else if (directive.name === "oq") {
671
+ hasOq = true;
672
+ for (const pair of directive.pairs) oq.set(pair.key, pair.value);
673
+ }
674
+ if (styleName !== void 0) stampStyle(children, targetIndex, styleName, style, state);
675
+ if (hasOq && ANCHOR_TARGET_TAGS.has(target.tagName)) stampOq(target, oq);
358
676
  }
677
+ /** A directive comment, or `undefined` for anything else — malformed included. */
359
678
  function directiveOf(node) {
360
- if (node.type !== "comment") return void 0;
361
- const parsed = parseVantageDirective(node.value);
362
- return parsed !== null && parsed.kind === "directive" ? parsed : void 0;
679
+ if (node.type !== "comment") return void 0;
680
+ const parsed = parseVantageDirective(node.value);
681
+ return parsed !== null && parsed.kind === "directive" ? parsed : void 0;
363
682
  }
683
+ /**
684
+ * One left-to-right pass over a parent's children, recursing into elements.
685
+ *
686
+ * The whole tree, not just the root: `rehype-raw` leaves comment nodes inside
687
+ * `blockquote`, inside `li`, inside `td` and inline inside `p`, and the real
688
+ * Open Questions layout puts the `oq` directive inside a list item — so a
689
+ * root-only walk finds none of them.
690
+ *
691
+ * Pass order is also what resolves a nested section: an inner heading's
692
+ * directive necessarily sits at a higher child index than the outer directive
693
+ * that ranged over it, so each property is simply last-write-wins.
694
+ */
364
695
  function processChildren(parent, state) {
365
- const children = parent.children;
366
- let i = 0;
367
- while (i < children.length) {
368
- const node = children[i];
369
- if (node.type === "element") {
370
- processChildren(node, state);
371
- i++;
372
- continue;
373
- }
374
- const first = directiveOf(node);
375
- if (first === void 0) {
376
- i++;
377
- continue;
378
- }
379
- const run = [first];
380
- let j = i + 1;
381
- let targetIndex = -1;
382
- for (; j < children.length; j++) {
383
- const next = children[j];
384
- if (next.type === "element") {
385
- targetIndex = j;
386
- break;
387
- }
388
- if (!isSkippable(next)) break;
389
- const directive = directiveOf(next);
390
- if (directive !== void 0) run.push(directive);
391
- }
392
- if (targetIndex >= 0) stampRun(children, targetIndex, run, state);
393
- i = j;
394
- }
696
+ const children = parent.children;
697
+ let i = 0;
698
+ while (i < children.length) {
699
+ const node = children[i];
700
+ if (node.type === "element") {
701
+ processChildren(node, state);
702
+ i++;
703
+ continue;
704
+ }
705
+ const first = directiveOf(node);
706
+ if (first === void 0) {
707
+ i++;
708
+ continue;
709
+ }
710
+ const run = [first];
711
+ let j = i + 1;
712
+ let targetIndex = -1;
713
+ for (; j < children.length; j++) {
714
+ const next = children[j];
715
+ if (next.type === "element") {
716
+ targetIndex = j;
717
+ break;
718
+ }
719
+ if (!isSkippable(next)) break;
720
+ const directive = directiveOf(next);
721
+ if (directive !== void 0) run.push(directive);
722
+ }
723
+ if (targetIndex >= 0) stampRun(children, targetIndex, run, state);
724
+ i = j;
725
+ }
395
726
  }
396
- var rehypeVantageDirectives = () => {
397
- return (tree) => {
398
- processChildren(tree, { nextGroup: 1 });
399
- };
727
+ const rehypeVantageDirectives = () => {
728
+ return (tree) => {
729
+ processChildren(tree, { nextGroup: 1 });
730
+ };
400
731
  };
401
- var rehypeVantageDirectives_default = rehypeVantageDirectives;
402
-
403
- // src/rehypeVantageMathStamps.ts
404
- var CARRIED_KEY = "vantageDisplayMathStamps";
732
+ //#endregion
733
+ //#region src/rehypeVantageMathStamps.ts
734
+ /**
735
+ * Where the snapshot lives between the two halves.
736
+ *
737
+ * `file.data` rather than a closure or a module-level map: `buildPipeline` is
738
+ * allowed to be built once and run over many documents, and per-file state is
739
+ * the only kind that cannot leak from one of those to the next.
740
+ */
741
+ const CARRIED_KEY = "vantageDisplayMathStamps";
405
742
  function classNames(node) {
406
- const value = node.properties?.className;
407
- return Array.isArray(value) ? value.map(String) : [];
743
+ const value = node.properties?.className;
744
+ return Array.isArray(value) ? value.map(String) : [];
408
745
  }
746
+ /**
747
+ * A `<pre>` `rehype-katex` will replace — its own condition, restated.
748
+ *
749
+ * `language-math` is the only class to test: `rehype-katex` keys the
750
+ * pre-as-scope branch on it, and the sanitiser strips the `math-display` that
751
+ * `remark-math` also emits (measured — a stamped fence arrives here with
752
+ * `className: ["language-math"]` alone).
753
+ */
409
754
  function isDisplayMath(node) {
410
- if (node.type !== "element" || node.tagName !== "pre") return false;
411
- return node.children.some(
412
- (child) => child.type === "element" && child.tagName === "code" && classNames(child).includes("language-math")
413
- );
755
+ if (node.type !== "element" || node.tagName !== "pre") return false;
756
+ return node.children.some((child) => child.type === "element" && child.tagName === "code" && classNames(child).includes("language-math"));
414
757
  }
758
+ /**
759
+ * What is worth carrying: everything this pipeline stamped itself.
760
+ *
761
+ * Deliberately not the whole property bag. `className`, `style` and `id` belong
762
+ * to the element KaTeX is about to build, and copying a `<pre>`'s onto a
763
+ * `<span class="katex-display">` would fight it.
764
+ */
415
765
  function carriedProperties(properties) {
416
- const carried = {};
417
- for (const [key, value] of Object.entries(properties ?? {})) {
418
- if (key === "dataSourceLine" || key.startsWith("dataVantage")) {
419
- carried[key] = value;
420
- }
421
- }
422
- return carried;
766
+ const carried = {};
767
+ for (const [key, value] of Object.entries(properties ?? {})) if (key === "dataSourceLine" || key.startsWith("dataVantage")) carried[key] = value;
768
+ return carried;
423
769
  }
424
770
  function collect(parent, out) {
425
- const children = parent.children;
426
- for (let i = 0; i < children.length; i++) {
427
- const node = children[i];
428
- if (node.type !== "element") continue;
429
- if (isDisplayMath(node)) {
430
- const properties = carriedProperties(node.properties);
431
- if (Object.keys(properties).length > 0) {
432
- out.push({
433
- parent,
434
- anchor: i === 0 ? void 0 : children[i - 1],
435
- properties
436
- });
437
- }
438
- continue;
439
- }
440
- collect(node, out);
441
- }
771
+ const children = parent.children;
772
+ for (let i = 0; i < children.length; i++) {
773
+ const node = children[i];
774
+ if (node.type !== "element") continue;
775
+ if (isDisplayMath(node)) {
776
+ const properties = carriedProperties(node.properties);
777
+ if (Object.keys(properties).length > 0) out.push({
778
+ parent,
779
+ anchor: i === 0 ? void 0 : children[i - 1],
780
+ properties
781
+ });
782
+ continue;
783
+ }
784
+ collect(node, out);
785
+ }
442
786
  }
443
787
  function reapply(carried) {
444
- for (const { parent, anchor, properties } of carried) {
445
- const siblings = parent.children;
446
- let index = 0;
447
- if (anchor !== void 0) {
448
- const at = siblings.indexOf(anchor);
449
- if (at === -1) continue;
450
- index = at + 1;
451
- }
452
- const replacement = siblings[index];
453
- if (replacement === void 0 || replacement.type !== "element") continue;
454
- if (!classNames(replacement).some((name) => name.startsWith("katex"))) {
455
- continue;
456
- }
457
- replacement.properties ??= {};
458
- for (const [key, value] of Object.entries(properties)) {
459
- replacement.properties[key] ??= value;
460
- }
461
- }
788
+ for (const { parent, anchor, properties } of carried) {
789
+ const siblings = parent.children;
790
+ let index = 0;
791
+ if (anchor !== void 0) {
792
+ const at = siblings.indexOf(anchor);
793
+ if (at === -1) continue;
794
+ index = at + 1;
795
+ }
796
+ const replacement = siblings[index];
797
+ if (replacement === void 0 || replacement.type !== "element") continue;
798
+ if (!classNames(replacement).some((name) => name.startsWith("katex"))) continue;
799
+ replacement.properties ??= {};
800
+ for (const [key, value] of Object.entries(properties)) replacement.properties[key] ??= value;
801
+ }
462
802
  }
463
- var rehypeCaptureMathStamps = () => {
464
- return (tree, file) => {
465
- const carried = [];
466
- collect(tree, carried);
467
- file.data[CARRIED_KEY] = carried;
468
- };
803
+ /** Snapshot every stamped display-math block. Register before `rehypeKatex`. */
804
+ const rehypeCaptureMathStamps = () => {
805
+ return (tree, file) => {
806
+ const carried = [];
807
+ collect(tree, carried);
808
+ file.data[CARRIED_KEY] = carried;
809
+ };
469
810
  };
470
- var rehypeRestoreMathStamps = () => {
471
- return (_tree, file) => {
472
- const carried = file.data[CARRIED_KEY];
473
- delete file.data[CARRIED_KEY];
474
- if (Array.isArray(carried)) reapply(carried);
475
- };
811
+ /** Re-apply the snapshot. Register immediately after `rehypeKatex`. */
812
+ const rehypeRestoreMathStamps = () => {
813
+ return (_tree, file) => {
814
+ const carried = file.data[CARRIED_KEY];
815
+ delete file.data[CARRIED_KEY];
816
+ if (Array.isArray(carried)) reapply(carried);
817
+ };
476
818
  };
477
- var SAFE_STYLE_PROPERTIES = [
478
- // Box metrics. `top`/`right`/`bottom`/`left` are inert now that `position` is
479
- // banned, and they stay only because dropping them would fail the whole
480
- // attribute for a document that writes one — the all-or-nothing rule below
481
- // makes every removal a behaviour change. They buy an attacker nothing that
482
- // negative `margin` does not already buy.
483
- "height",
484
- "min-height",
485
- "max-height",
486
- "width",
487
- "min-width",
488
- "max-width",
489
- "top",
490
- "bottom",
491
- "left",
492
- "right",
493
- "margin",
494
- "margin-top",
495
- "margin-right",
496
- "margin-bottom",
497
- "margin-left",
498
- "padding",
499
- "padding-top",
500
- "padding-right",
501
- "padding-bottom",
502
- "padding-left",
503
- // Rules and boxes.
504
- "border",
505
- "border-style",
506
- "border-color",
507
- "border-width",
508
- "border-top-width",
509
- "border-right-width",
510
- "border-bottom-width",
511
- "border-left-width",
512
- "border-top-style",
513
- "border-right-style",
514
- "border-bottom-style",
515
- "border-left-style",
516
- "border-top-color",
517
- "border-right-color",
518
- "border-bottom-color",
519
- "border-left-color",
520
- "border-radius",
521
- // Typography.
522
- "color",
523
- "background-color",
524
- "font",
525
- "font-size",
526
- "font-style",
527
- "font-weight",
528
- "font-family",
529
- "font-variant",
530
- "line-height",
531
- "letter-spacing",
532
- "word-spacing",
533
- "text-align",
534
- "text-decoration",
535
- "text-indent",
536
- "white-space",
537
- "vertical-align",
538
- "list-style-type",
539
- // Flow.
540
- "display",
541
- "float",
542
- "clear",
543
- "opacity",
544
- "overflow"
545
- ];
546
- var VALUE = `[^;:()"'\\\\]*`;
547
- var DECLARATION = `(?:(?:${SAFE_STYLE_PROPERTIES.join("|")})\\s*:${VALUE})`;
548
- var SAFE_STYLE = new RegExp(
549
- `^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`,
550
- "i"
551
- );
552
- var COLLAPSE_GROUP_ID = /^[0-9]+$/;
553
- var sanitizeSchema = {
554
- ...rehypeSanitize.defaultSchema,
555
- tagNames: [
556
- ...rehypeSanitize.defaultSchema.tagNames || [],
557
- // KaTeX MathML elements
558
- "math",
559
- "semantics",
560
- "mrow",
561
- "mi",
562
- "mo",
563
- "mn",
564
- "msup",
565
- "msub",
566
- "mfrac",
567
- "mover",
568
- "munder",
569
- "msqrt",
570
- "mroot",
571
- "mtable",
572
- "mtr",
573
- "mtd",
574
- "mtext",
575
- "mspace",
576
- "annotation",
577
- // Other
578
- "figure",
579
- "figcaption",
580
- "summary",
581
- "details"
582
- ],
583
- attributes: {
584
- ...rehypeSanitize.defaultSchema.attributes,
585
- "*": [
586
- ...rehypeSanitize.defaultSchema.attributes?.["*"] || [],
587
- "className",
588
- ["style", SAFE_STYLE],
589
- "dataSourceLine",
590
- // What `rehypeVantageDirectives` compiles a `<!-- vantage: … -->` comment
591
- // into, named individually — never by a `data-vantage-*` wildcard, which
592
- // would readmit whatever a future bug emits and whatever a document
593
- // hand-writes as raw HTML.
594
- //
595
- // The value lists are the belt to the plugin's braces: the vocabulary is
596
- // closed in the plugin *and* here, imported from the one module that
597
- // defines it, so even if a refactor let an unvalidated value reach the
598
- // tree the sanitiser still refuses it.
599
- ["dataVantageTone", ...VANTAGE_TONES],
600
- ["dataVantageEmphasis", ...VANTAGE_EMPHASIS],
601
- ["dataVantageBadge", ...VANTAGE_BADGES],
602
- ["dataVantageCollapsed", ...VANTAGE_COLLAPSED],
603
- // The other half of `collapsed`: which group a hidden block belongs to,
604
- // and which group a heading toggles. Both are plugin-minted counters with
605
- // no vocabulary to allowlist, so they take a pattern instead —
606
- // `hast-util-sanitize` accepts a `RegExp` in place of a literal value.
607
- // A pattern rather than a bare name because the JS interpolates the value
608
- // into a selector: anything but digits has no business reaching it.
609
- ["dataVantageCollapseGroup", COLLAPSE_GROUP_ID],
610
- ["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
611
- ["dataVantageRun", ...VANTAGE_RUNS],
612
- ["dataVantageOq", "true"],
613
- // The design's one genuinely free-text value: the body of a review
614
- // comment, so it cannot be value-allowlisted and this entry is name-only.
615
- // Two defences remain rather than three — `hast` escapes the value on
616
- // serialisation and React sets it through the DOM property path, so it
617
- // cannot break out of the attribute — and the honest record of that is in
618
- // the design doc rather than a third layer implied here.
619
- "dataVantageLeaning"
620
- ],
621
- code: [...rehypeSanitize.defaultSchema.attributes?.code || [], "className"],
622
- span: [
623
- ...rehypeSanitize.defaultSchema.attributes?.span || [],
624
- "className",
625
- ["style", SAFE_STYLE]
626
- ],
627
- div: [
628
- ...rehypeSanitize.defaultSchema.attributes?.div || [],
629
- "className",
630
- ["style", SAFE_STYLE]
631
- ],
632
- a: [...rehypeSanitize.defaultSchema.attributes?.a || [], "id", "className"],
633
- math: ["xmlns"],
634
- annotation: ["encoding"],
635
- img: [...rehypeSanitize.defaultSchema.attributes?.img || [], "loading"],
636
- td: [...rehypeSanitize.defaultSchema.attributes?.td || [], ["style", SAFE_STYLE]],
637
- th: [...rehypeSanitize.defaultSchema.attributes?.th || [], ["style", SAFE_STYLE]]
638
- }
819
+ //#endregion
820
+ //#region src/sanitize.ts
821
+ /**
822
+ * Sanitization schema for the rendering pipeline.
823
+ * Allows GFM, KaTeX MathML, syntax highlighting classes, and
824
+ * data-source-line attributes while blocking XSS vectors.
825
+ */
826
+ const DECLARATION = `(?:(?:${[
827
+ "height",
828
+ "min-height",
829
+ "max-height",
830
+ "width",
831
+ "min-width",
832
+ "max-width",
833
+ "top",
834
+ "bottom",
835
+ "left",
836
+ "right",
837
+ "margin",
838
+ "margin-top",
839
+ "margin-right",
840
+ "margin-bottom",
841
+ "margin-left",
842
+ "padding",
843
+ "padding-top",
844
+ "padding-right",
845
+ "padding-bottom",
846
+ "padding-left",
847
+ "border",
848
+ "border-style",
849
+ "border-color",
850
+ "border-width",
851
+ "border-top-width",
852
+ "border-right-width",
853
+ "border-bottom-width",
854
+ "border-left-width",
855
+ "border-top-style",
856
+ "border-right-style",
857
+ "border-bottom-style",
858
+ "border-left-style",
859
+ "border-top-color",
860
+ "border-right-color",
861
+ "border-bottom-color",
862
+ "border-left-color",
863
+ "border-radius",
864
+ "color",
865
+ "background-color",
866
+ "font",
867
+ "font-size",
868
+ "font-style",
869
+ "font-weight",
870
+ "font-family",
871
+ "font-variant",
872
+ "line-height",
873
+ "letter-spacing",
874
+ "word-spacing",
875
+ "text-align",
876
+ "text-decoration",
877
+ "text-indent",
878
+ "white-space",
879
+ "vertical-align",
880
+ "list-style-type",
881
+ "display",
882
+ "float",
883
+ "clear",
884
+ "opacity",
885
+ "overflow"
886
+ ].join("|")})\\s*:[^;:()"'\\\\]*)`;
887
+ const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "i");
888
+ /**
889
+ * A collapse group id: one or more digits, anchored.
890
+ *
891
+ * The plugin mints these as a per-document counter, so there is no vocabulary to
892
+ * list. Keeping the shape narrow matters anyway — the toggle JS builds a
893
+ * `[data-vantage-collapse-group="…"]` selector out of the value, and a document
894
+ * that hand-wrote raw HTML is the only way a non-numeric one could ever appear.
895
+ */
896
+ const COLLAPSE_GROUP_ID = /^[0-9]+$/;
897
+ /**
898
+ * Never set `allowComments` here.
899
+ *
900
+ * `hast-util-sanitize` drops comment nodes because that boolean defaults to
901
+ * `false` — comments are not elements, so `tagNames` has nothing to do with it.
902
+ * `rehypeVantageDirectives` relies on that deletion: it consumes a
903
+ * `<!-- vantage: … -->` comment into attributes and deliberately leaves the node
904
+ * for the sanitiser. Turning the switch on readmits every directive comment —
905
+ * valid and malformed alike — into the rendered HTML, which breaks the carrier's
906
+ * whole premise. `vantageDirectives.test.ts` ("leaves no comment in the rendered
907
+ * markup") is the guard.
908
+ */
909
+ const sanitizeSchema = {
910
+ ...rehype_sanitize.defaultSchema,
911
+ tagNames: [
912
+ ...rehype_sanitize.defaultSchema.tagNames || [],
913
+ "math",
914
+ "semantics",
915
+ "mrow",
916
+ "mi",
917
+ "mo",
918
+ "mn",
919
+ "msup",
920
+ "msub",
921
+ "mfrac",
922
+ "mover",
923
+ "munder",
924
+ "msqrt",
925
+ "mroot",
926
+ "mtable",
927
+ "mtr",
928
+ "mtd",
929
+ "mtext",
930
+ "mspace",
931
+ "annotation",
932
+ "figure",
933
+ "figcaption",
934
+ "summary",
935
+ "details"
936
+ ],
937
+ attributes: {
938
+ ...rehype_sanitize.defaultSchema.attributes,
939
+ "*": [
940
+ ...rehype_sanitize.defaultSchema.attributes?.["*"] || [],
941
+ "className",
942
+ ["style", SAFE_STYLE],
943
+ "dataSourceLine",
944
+ ["dataVantageTone", ...VANTAGE_TONES],
945
+ ["dataVantageEmphasis", ...VANTAGE_EMPHASIS],
946
+ ["dataVantageBadge", ...VANTAGE_BADGES],
947
+ ["dataVantageCollapsed", ...VANTAGE_COLLAPSED],
948
+ ["dataVantageCollapseGroup", COLLAPSE_GROUP_ID],
949
+ ["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
950
+ ["dataVantageRun", ...VANTAGE_RUNS],
951
+ ["dataVantageOq", "true"],
952
+ ["dataVantageAlert", ...VANTAGE_ALERTS],
953
+ "dataVantageLeaning"
954
+ ],
955
+ code: [...rehype_sanitize.defaultSchema.attributes?.code || [], "className"],
956
+ span: [
957
+ ...rehype_sanitize.defaultSchema.attributes?.span || [],
958
+ "className",
959
+ ["style", SAFE_STYLE]
960
+ ],
961
+ div: [
962
+ ...rehype_sanitize.defaultSchema.attributes?.div || [],
963
+ "className",
964
+ ["style", SAFE_STYLE]
965
+ ],
966
+ a: [
967
+ ...rehype_sanitize.defaultSchema.attributes?.a || [],
968
+ "id",
969
+ "className"
970
+ ],
971
+ math: ["xmlns"],
972
+ annotation: ["encoding"],
973
+ img: [...rehype_sanitize.defaultSchema.attributes?.img || [], "loading"],
974
+ td: [...rehype_sanitize.defaultSchema.attributes?.td || [], ["style", SAFE_STYLE]],
975
+ th: [...rehype_sanitize.defaultSchema.attributes?.th || [], ["style", SAFE_STYLE]]
976
+ }
639
977
  };
640
-
641
- // src/pipeline.ts
978
+ //#endregion
979
+ //#region src/pipeline.ts
980
+ /**
981
+ * The mdast half of the chain. Exported on its own because there is a real
982
+ * mdast-only consumer: the CLI checker parses documents without ever running
983
+ * rehype (`packages/vantage-check/src/core/document.ts`), and it has to parse
984
+ * them exactly the way the viewer does.
985
+ */
642
986
  function buildRemarkPlugins(options = {}) {
643
- const { gfm = true, math = true } = options;
644
- const plugins = [];
645
- if (gfm) plugins.push([remarkGfm__default.default, { singleTilde: false }]);
646
- if (math) plugins.push([remarkMath__default.default, { singleDollarTextMath: false }]);
647
- return plugins;
987
+ const { gfm = true, math = true } = options;
988
+ const plugins = [];
989
+ if (gfm) plugins.push([remark_gfm.default, { singleTilde: false }]);
990
+ if (math) plugins.push([remark_math.default, { singleDollarTextMath: false }]);
991
+ return plugins;
648
992
  }
993
+ /** The hast half. Deliberately not exported: see `buildPipeline`. */
649
994
  function buildRehypePlugins(options = {}) {
650
- const {
651
- math = true,
652
- highlight = true,
653
- sourceLines = true,
654
- sanitize = true,
655
- bodyLineOffset = 0
656
- } = options;
657
- const plugins = [rehypeRaw__default.default];
658
- if (sourceLines) {
659
- plugins.push([rehypeSourceLines_default, { offset: bodyLineOffset }]);
660
- }
661
- plugins.push(rehypeVantageDirectives_default);
662
- if (sanitize) plugins.push([rehypeSanitize__default.default, sanitizeSchema]);
663
- plugins.push(rehypeSlug__default.default);
664
- if (highlight) plugins.push(rehypeHighlight__default.default);
665
- if (math) {
666
- plugins.push(rehypeCaptureMathStamps, rehypeKatex__default.default, rehypeRestoreMathStamps);
667
- }
668
- return plugins;
995
+ const { math = true, highlight = true, sourceLines = true, sanitize = true, bodyLineOffset = 0 } = options;
996
+ const plugins = [rehype_raw.default];
997
+ if (sourceLines) plugins.push([rehypeSourceLines, { offset: bodyLineOffset }]);
998
+ plugins.push(rehypeVantageAlerts);
999
+ plugins.push(rehypeVantageDirectives);
1000
+ if (sanitize) plugins.push([rehype_sanitize.default, sanitizeSchema]);
1001
+ plugins.push(rehype_slug.default);
1002
+ if (highlight) plugins.push(rehype_highlight.default);
1003
+ if (math) plugins.push(rehypeCaptureMathStamps, rehype_katex.default, rehypeRestoreMathStamps);
1004
+ return plugins;
669
1005
  }
1006
+ /**
1007
+ * Both halves from one options object.
1008
+ *
1009
+ * This is what every renderer calls. It takes one object rather than exposing
1010
+ * the two builders because `math` spans both halves — `remark-math` parses the
1011
+ * delimiters, `rehype-katex` renders the result — and two calls are two places
1012
+ * to forget the second one.
1013
+ *
1014
+ * Returns fresh arrays on every call and reads no module-level state; keep it
1015
+ * that way, so a plugin in the chain cannot become a function of how many times
1016
+ * the chain has been built.
1017
+ */
670
1018
  function buildPipeline(options = {}) {
671
- return {
672
- remarkPlugins: buildRemarkPlugins(options),
673
- rehypePlugins: buildRehypePlugins(options)
674
- };
1019
+ return {
1020
+ remarkPlugins: buildRemarkPlugins(options),
1021
+ rehypePlugins: buildRehypePlugins(options)
1022
+ };
675
1023
  }
1024
+ //#endregion
1025
+ //#region src/frontmatter.ts
1026
+ /**
1027
+ * Frontmatter parser for YAML (---) and TOML (+++) delimited content.
1028
+ * Works in both browser and server environments.
1029
+ */
1030
+ /**
1031
+ * Parse frontmatter from markdown content.
1032
+ * Supports YAML (delimited by ---) and TOML (delimited by +++).
1033
+ */
676
1034
  function parseFrontmatter(content) {
677
- if (content.startsWith("+++")) {
678
- return parseFrontmatterWithDelimiter(content, "+++", "toml");
679
- }
680
- if (content.startsWith("---")) {
681
- return parseFrontmatterWithDelimiter(content, "---", "yaml");
682
- }
683
- return withOffset(content, {
684
- frontmatter: {},
685
- body: content,
686
- format: "none"
687
- });
1035
+ if (content.startsWith("+++")) return parseFrontmatterWithDelimiter(content, "+++", "toml");
1036
+ if (content.startsWith("---")) return parseFrontmatterWithDelimiter(content, "---", "yaml");
1037
+ return withOffset(content, {
1038
+ frontmatter: {},
1039
+ body: content,
1040
+ format: "none"
1041
+ });
688
1042
  }
1043
+ /**
1044
+ * Fill in `bodyLineOffset`. `body` is always a suffix of `content`, so the
1045
+ * newlines in the prefix that was stripped are exactly the shift — which also
1046
+ * accounts for the blank line consumed after the closing delimiter.
1047
+ */
689
1048
  function withOffset(content, parsed) {
690
- const stripped = content.slice(0, content.length - parsed.body.length);
691
- let bodyLineOffset = 0;
692
- for (const ch of stripped) {
693
- if (ch === "\n") bodyLineOffset++;
694
- }
695
- return { ...parsed, bodyLineOffset };
1049
+ const stripped = content.slice(0, content.length - parsed.body.length);
1050
+ let bodyLineOffset = 0;
1051
+ for (const ch of stripped) if (ch === "\n") bodyLineOffset++;
1052
+ return {
1053
+ ...parsed,
1054
+ bodyLineOffset
1055
+ };
696
1056
  }
697
1057
  function parseFrontmatterWithDelimiter(content, delimiter, format) {
698
- const searchStart = delimiter.length;
699
- const endIndex = content.indexOf(`
700
- ${delimiter}`, searchStart);
701
- if (endIndex === -1) {
702
- return withOffset(content, {
703
- frontmatter: {},
704
- body: content,
705
- format: "none",
706
- problem: { kind: "unterminated", delimiter }
707
- });
708
- }
709
- const raw = content.slice(searchStart + 1, endIndex).trim();
710
- const bodyStart = endIndex + 1 + delimiter.length;
711
- const body = content.slice(bodyStart).replace(/^\n/, "");
712
- try {
713
- const parsed = format === "toml" ? smolToml.parse(raw) : YAML__default.default.parse(raw);
714
- return withOffset(content, {
715
- frontmatter: parsed || {},
716
- body,
717
- format,
718
- ...isMapping(parsed) ? {} : { problem: { kind: "not-a-mapping", delimiter } }
719
- });
720
- } catch (error) {
721
- return withOffset(content, {
722
- frontmatter: {},
723
- body: content,
724
- format: "none",
725
- problem: { kind: "invalid", delimiter, ...errorPosition(error) }
726
- });
727
- }
1058
+ const searchStart = delimiter.length;
1059
+ const endIndex = content.indexOf(`\n${delimiter}`, searchStart);
1060
+ if (endIndex === -1) return withOffset(content, {
1061
+ frontmatter: {},
1062
+ body: content,
1063
+ format: "none",
1064
+ problem: {
1065
+ kind: "unterminated",
1066
+ delimiter
1067
+ }
1068
+ });
1069
+ const raw = content.slice(searchStart + 1, endIndex).trim();
1070
+ const bodyStart = endIndex + 1 + delimiter.length;
1071
+ const body = content.slice(bodyStart).replace(/^\n/, "");
1072
+ try {
1073
+ const parsed = format === "toml" ? (0, smol_toml.parse)(raw) : yaml.default.parse(raw);
1074
+ return withOffset(content, {
1075
+ frontmatter: parsed || {},
1076
+ body,
1077
+ format,
1078
+ ...isMapping(parsed) ? {} : { problem: {
1079
+ kind: "not-a-mapping",
1080
+ delimiter
1081
+ } }
1082
+ });
1083
+ } catch (error) {
1084
+ return withOffset(content, {
1085
+ frontmatter: {},
1086
+ body: content,
1087
+ format: "none",
1088
+ problem: {
1089
+ kind: "invalid",
1090
+ delimiter,
1091
+ ...errorPosition(error)
1092
+ }
1093
+ });
1094
+ }
728
1095
  }
1096
+ /** Empty frontmatter is fine; a scalar or a list where a table belongs is not. */
729
1097
  function isMapping(value) {
730
- return value === null || value === void 0 || typeof value === "object" && !Array.isArray(value);
1098
+ return value === null || value === void 0 || typeof value === "object" && !Array.isArray(value);
731
1099
  }
1100
+ /**
1101
+ * Pull the parser's message and, where it gave one, the position inside the
1102
+ * frontmatter block. `yaml` reports `linePos`; `smol-toml` reports `line` and
1103
+ * `column`. Both are optional and both are read defensively — a missing
1104
+ * position costs a less precise report, a wrong assumption costs a crash.
1105
+ */
732
1106
  function errorPosition(error) {
733
- const message = error instanceof Error ? error.message : String(error);
734
- const source = error;
735
- const yamlPosition = source?.linePos?.[0];
736
- if (typeof yamlPosition?.line === "number") {
737
- return {
738
- message,
739
- line: yamlPosition.line,
740
- ...typeof yamlPosition.col === "number" ? { column: yamlPosition.col } : {}
741
- };
742
- }
743
- if (typeof source?.line === "number") {
744
- return {
745
- message,
746
- line: source.line,
747
- ...typeof source.column === "number" ? { column: source.column } : {}
748
- };
749
- }
750
- return { message };
1107
+ const message = error instanceof Error ? error.message : String(error);
1108
+ const source = error;
1109
+ const yamlPosition = source?.linePos?.[0];
1110
+ if (typeof yamlPosition?.line === "number") return {
1111
+ message,
1112
+ line: yamlPosition.line,
1113
+ ...typeof yamlPosition.col === "number" ? { column: yamlPosition.col } : {}
1114
+ };
1115
+ if (typeof source?.line === "number") return {
1116
+ message,
1117
+ line: source.line,
1118
+ ...typeof source.column === "number" ? { column: source.column } : {}
1119
+ };
1120
+ return { message };
751
1121
  }
752
-
753
- // src/mermaidCache.ts
754
- var svgCache = /* @__PURE__ */ new Map();
755
-
756
- // src/mermaidLoader.ts
757
- var mermaidInstance = null;
758
- var mermaidLoading = null;
759
- var isDark = () => typeof document !== "undefined" && document.documentElement.classList.contains("dark");
1122
+ //#endregion
1123
+ //#region src/mermaidCache.ts
1124
+ const svgCache = /* @__PURE__ */ new Map();
1125
+ //#endregion
1126
+ //#region src/mermaidLoader.ts
1127
+ let mermaidInstance = null;
1128
+ let mermaidLoading = null;
1129
+ const isDark = () => typeof document !== "undefined" && document.documentElement.classList.contains("dark");
760
1130
  async function getMermaid() {
761
- if (mermaidInstance) return mermaidInstance;
762
- if (!mermaidLoading) {
763
- mermaidLoading = import('mermaid').then((mod) => {
764
- const m = mod.default;
765
- m.initialize({
766
- startOnLoad: false,
767
- theme: isDark() ? "dark" : "default",
768
- securityLevel: "strict",
769
- suppressErrorRendering: true
770
- });
771
- mermaidInstance = m;
772
- return m;
773
- });
774
- }
775
- return mermaidLoading;
1131
+ if (mermaidInstance) return mermaidInstance;
1132
+ if (!mermaidLoading) mermaidLoading = import("mermaid").then((mod) => {
1133
+ const m = mod.default;
1134
+ m.initialize({
1135
+ startOnLoad: false,
1136
+ theme: isDark() ? "dark" : "default",
1137
+ securityLevel: "strict",
1138
+ suppressErrorRendering: true
1139
+ });
1140
+ mermaidInstance = m;
1141
+ return m;
1142
+ });
1143
+ return mermaidLoading;
776
1144
  }
777
- var AlertTriangleIcon = () => /* @__PURE__ */ jsxRuntime.jsxs(
778
- "svg",
779
- {
780
- width: "16",
781
- height: "16",
782
- viewBox: "0 0 24 24",
783
- fill: "none",
784
- stroke: "currentColor",
785
- strokeWidth: "2",
786
- strokeLinecap: "round",
787
- strokeLinejoin: "round",
788
- className: "w-4 h-4 shrink-0",
789
- children: [
790
- /* @__PURE__ */ jsxRuntime.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" }),
791
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 9v4" }),
792
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 17h.01" })
793
- ]
794
- }
795
- );
796
- var ChevronDownIcon = () => /* @__PURE__ */ jsxRuntime.jsx(
797
- "svg",
798
- {
799
- width: "12",
800
- height: "12",
801
- viewBox: "0 0 24 24",
802
- fill: "none",
803
- stroke: "currentColor",
804
- strokeWidth: "2",
805
- strokeLinecap: "round",
806
- strokeLinejoin: "round",
807
- className: "w-3 h-3",
808
- children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "m6 9 6 6 6-6" })
809
- }
810
- );
811
- var ChevronUpIcon = () => /* @__PURE__ */ jsxRuntime.jsx(
812
- "svg",
813
- {
814
- width: "12",
815
- height: "12",
816
- viewBox: "0 0 24 24",
817
- fill: "none",
818
- stroke: "currentColor",
819
- strokeWidth: "2",
820
- strokeLinecap: "round",
821
- strokeLinejoin: "round",
822
- className: "w-3 h-3",
823
- children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "m18 15-6-6-6 6" })
824
- }
825
- );
826
- var MaximizeIcon = () => /* @__PURE__ */ jsxRuntime.jsxs(
827
- "svg",
828
- {
829
- width: "16",
830
- height: "16",
831
- viewBox: "0 0 24 24",
832
- fill: "none",
833
- stroke: "currentColor",
834
- strokeWidth: "2",
835
- strokeLinecap: "round",
836
- strokeLinejoin: "round",
837
- className: "w-4 h-4 text-gray-600",
838
- children: [
839
- /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "15 3 21 3 21 9" }),
840
- /* @__PURE__ */ jsxRuntime.jsx("polyline", { points: "9 21 3 21 3 15" }),
841
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "21", x2: "14", y1: "3", y2: "10" }),
842
- /* @__PURE__ */ jsxRuntime.jsx("line", { x1: "3", x2: "10", y1: "21", y2: "14" })
843
- ]
844
- }
845
- );
846
- var CloseIcon = () => /* @__PURE__ */ jsxRuntime.jsxs(
847
- "svg",
848
- {
849
- width: "20",
850
- height: "20",
851
- viewBox: "0 0 24 24",
852
- fill: "none",
853
- stroke: "currentColor",
854
- strokeWidth: "2",
855
- strokeLinecap: "round",
856
- strokeLinejoin: "round",
857
- className: "w-5 h-5",
858
- children: [
859
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M18 6 6 18" }),
860
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "m6 6 12 12" })
861
- ]
862
- }
863
- );
864
- var PlusIcon = () => /* @__PURE__ */ jsxRuntime.jsxs(
865
- "svg",
866
- {
867
- width: "16",
868
- height: "16",
869
- viewBox: "0 0 24 24",
870
- fill: "none",
871
- stroke: "currentColor",
872
- strokeWidth: "2",
873
- strokeLinecap: "round",
874
- strokeLinejoin: "round",
875
- className: "w-4 h-4",
876
- children: [
877
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M5 12h14" }),
878
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 5v14" })
879
- ]
880
- }
881
- );
882
- var MinusIcon = () => /* @__PURE__ */ jsxRuntime.jsx(
883
- "svg",
884
- {
885
- width: "16",
886
- height: "16",
887
- viewBox: "0 0 24 24",
888
- fill: "none",
889
- stroke: "currentColor",
890
- strokeWidth: "2",
891
- strokeLinecap: "round",
892
- strokeLinejoin: "round",
893
- className: "w-4 h-4",
894
- children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M5 12h14" })
895
- }
896
- );
897
- var ResetIcon = () => /* @__PURE__ */ jsxRuntime.jsxs(
898
- "svg",
899
- {
900
- width: "16",
901
- height: "16",
902
- viewBox: "0 0 24 24",
903
- fill: "none",
904
- stroke: "currentColor",
905
- strokeWidth: "2",
906
- strokeLinecap: "round",
907
- strokeLinejoin: "round",
908
- className: "w-4 h-4",
909
- children: [
910
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }),
911
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M3 3v5h5" })
912
- ]
913
- }
914
- );
915
- var MIN_SCALE = 0.2;
916
- var MAX_SCALE = 8;
1145
+ //#endregion
1146
+ //#region src/MermaidDiagram.tsx
1147
+ const AlertTriangleIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1148
+ width: "16",
1149
+ height: "16",
1150
+ viewBox: "0 0 24 24",
1151
+ fill: "none",
1152
+ stroke: "currentColor",
1153
+ strokeWidth: "2",
1154
+ strokeLinecap: "round",
1155
+ strokeLinejoin: "round",
1156
+ className: "w-4 h-4 shrink-0",
1157
+ children: [
1158
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }),
1159
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 9v4" }),
1160
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 17h.01" })
1161
+ ]
1162
+ });
1163
+ const ChevronDownIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1164
+ width: "12",
1165
+ height: "12",
1166
+ viewBox: "0 0 24 24",
1167
+ fill: "none",
1168
+ stroke: "currentColor",
1169
+ strokeWidth: "2",
1170
+ strokeLinecap: "round",
1171
+ strokeLinejoin: "round",
1172
+ className: "w-3 h-3",
1173
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 9 6 6 6-6" })
1174
+ });
1175
+ const ChevronUpIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1176
+ width: "12",
1177
+ height: "12",
1178
+ viewBox: "0 0 24 24",
1179
+ fill: "none",
1180
+ stroke: "currentColor",
1181
+ strokeWidth: "2",
1182
+ strokeLinecap: "round",
1183
+ strokeLinejoin: "round",
1184
+ className: "w-3 h-3",
1185
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m18 15-6-6-6 6" })
1186
+ });
1187
+ const MaximizeIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1188
+ width: "16",
1189
+ height: "16",
1190
+ viewBox: "0 0 24 24",
1191
+ fill: "none",
1192
+ stroke: "currentColor",
1193
+ strokeWidth: "2",
1194
+ strokeLinecap: "round",
1195
+ strokeLinejoin: "round",
1196
+ className: "w-4 h-4 text-gray-600",
1197
+ children: [
1198
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("polyline", { points: "15 3 21 3 21 9" }),
1199
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("polyline", { points: "9 21 3 21 3 15" }),
1200
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
1201
+ x1: "21",
1202
+ x2: "14",
1203
+ y1: "3",
1204
+ y2: "10"
1205
+ }),
1206
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
1207
+ x1: "3",
1208
+ x2: "10",
1209
+ y1: "21",
1210
+ y2: "14"
1211
+ })
1212
+ ]
1213
+ });
1214
+ const CloseIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1215
+ width: "20",
1216
+ height: "20",
1217
+ viewBox: "0 0 24 24",
1218
+ fill: "none",
1219
+ stroke: "currentColor",
1220
+ strokeWidth: "2",
1221
+ strokeLinecap: "round",
1222
+ strokeLinejoin: "round",
1223
+ className: "w-5 h-5",
1224
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 6 12 12" })]
1225
+ });
1226
+ const PlusIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1227
+ width: "16",
1228
+ height: "16",
1229
+ viewBox: "0 0 24 24",
1230
+ fill: "none",
1231
+ stroke: "currentColor",
1232
+ strokeWidth: "2",
1233
+ strokeLinecap: "round",
1234
+ strokeLinejoin: "round",
1235
+ className: "w-4 h-4",
1236
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12h14" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 5v14" })]
1237
+ });
1238
+ const MinusIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1239
+ width: "16",
1240
+ height: "16",
1241
+ viewBox: "0 0 24 24",
1242
+ fill: "none",
1243
+ stroke: "currentColor",
1244
+ strokeWidth: "2",
1245
+ strokeLinecap: "round",
1246
+ strokeLinejoin: "round",
1247
+ className: "w-4 h-4",
1248
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12h14" })
1249
+ });
1250
+ const ResetIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1251
+ width: "16",
1252
+ height: "16",
1253
+ viewBox: "0 0 24 24",
1254
+ fill: "none",
1255
+ stroke: "currentColor",
1256
+ strokeWidth: "2",
1257
+ strokeLinecap: "round",
1258
+ strokeLinejoin: "round",
1259
+ className: "w-4 h-4",
1260
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 3v5h5" })]
1261
+ });
1262
+ const MIN_SCALE = .2;
1263
+ const MAX_SCALE = 8;
1264
+ /**
1265
+ * Zoom + pan viewport for the maximized diagram. Wheel/buttons zoom (anchored
1266
+ * on the cursor), drag pans, double-click resets, and it fills whatever space
1267
+ * the modal gives it.
1268
+ */
917
1269
  function ZoomableSvg({ svg }) {
918
- const [scale, setScale] = react.useState(1);
919
- const [offset, setOffset] = react.useState({ x: 0, y: 0 });
920
- const dragRef = react.useRef(null);
921
- const [isDragging, setIsDragging] = react.useState(false);
922
- const viewportRef = react.useRef(null);
923
- const reset = react.useCallback(() => {
924
- setScale(1);
925
- setOffset({ x: 0, y: 0 });
926
- }, []);
927
- const zoomBy = react.useCallback(
928
- (factor, anchor) => {
929
- setScale((prev) => {
930
- const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, prev * factor));
931
- if (next === prev) return prev;
932
- const rect = viewportRef.current?.getBoundingClientRect();
933
- if (rect) {
934
- const ax = (anchor?.x ?? rect.width / 2) - rect.width / 2;
935
- const ay = (anchor?.y ?? rect.height / 2) - rect.height / 2;
936
- setOffset((o) => ({
937
- x: ax - (ax - o.x) * next / prev,
938
- y: ay - (ay - o.y) * next / prev
939
- }));
940
- }
941
- return next;
942
- });
943
- },
944
- []
945
- );
946
- const handleWheel = react.useCallback(
947
- (e) => {
948
- e.preventDefault();
949
- const rect = viewportRef.current?.getBoundingClientRect();
950
- const anchor = rect ? { x: e.clientX - rect.left, y: e.clientY - rect.top } : void 0;
951
- zoomBy(e.deltaY < 0 ? 1.15 : 1 / 1.15, anchor);
952
- },
953
- [zoomBy]
954
- );
955
- const handlePointerDown = (e) => {
956
- e.currentTarget.setPointerCapture(e.pointerId);
957
- dragRef.current = {
958
- startX: e.clientX,
959
- startY: e.clientY,
960
- originX: offset.x,
961
- originY: offset.y
962
- };
963
- setIsDragging(true);
964
- };
965
- const handlePointerMove = (e) => {
966
- const d = dragRef.current;
967
- if (!d) return;
968
- setOffset({
969
- x: d.originX + (e.clientX - d.startX),
970
- y: d.originY + (e.clientY - d.startY)
971
- });
972
- };
973
- const endDrag = (e) => {
974
- if (dragRef.current) {
975
- try {
976
- e.currentTarget.releasePointerCapture(e.pointerId);
977
- } catch {
978
- }
979
- }
980
- dragRef.current = null;
981
- setIsDragging(false);
982
- };
983
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative w-full h-full", children: [
984
- /* @__PURE__ */ jsxRuntime.jsx(
985
- "div",
986
- {
987
- ref: viewportRef,
988
- onWheel: handleWheel,
989
- onPointerDown: handlePointerDown,
990
- onPointerMove: handlePointerMove,
991
- onPointerUp: endDrag,
992
- onPointerLeave: endDrag,
993
- onDoubleClick: reset,
994
- className: "w-full h-full overflow-hidden touch-none select-none",
995
- style: { cursor: isDragging ? "grabbing" : "grab" },
996
- children: /* @__PURE__ */ jsxRuntime.jsx(
997
- "div",
998
- {
999
- className: "mermaid-zoom flex items-center justify-center w-full h-full",
1000
- style: {
1001
- transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
1002
- transformOrigin: "center center",
1003
- transition: isDragging ? "none" : "transform 0.08s ease-out"
1004
- },
1005
- dangerouslySetInnerHTML: { __html: svg }
1006
- }
1007
- )
1008
- }
1009
- ),
1010
- /* @__PURE__ */ jsxRuntime.jsxs("div", { 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", children: [
1011
- /* @__PURE__ */ jsxRuntime.jsx(
1012
- "button",
1013
- {
1014
- onClick: () => zoomBy(1 / 1.3),
1015
- className: "p-1.5 rounded hover:bg-gray-100 text-gray-600",
1016
- "aria-label": "Zoom out",
1017
- title: "Zoom out",
1018
- children: /* @__PURE__ */ jsxRuntime.jsx(MinusIcon, {})
1019
- }
1020
- ),
1021
- /* @__PURE__ */ jsxRuntime.jsxs(
1022
- "button",
1023
- {
1024
- onClick: reset,
1025
- className: "px-2 py-1 text-xs font-medium rounded hover:bg-gray-100 text-gray-600 min-w-[3rem]",
1026
- title: "Reset zoom (or double-click)",
1027
- children: [
1028
- Math.round(scale * 100),
1029
- "%"
1030
- ]
1031
- }
1032
- ),
1033
- /* @__PURE__ */ jsxRuntime.jsx(
1034
- "button",
1035
- {
1036
- onClick: () => zoomBy(1.3),
1037
- className: "p-1.5 rounded hover:bg-gray-100 text-gray-600",
1038
- "aria-label": "Zoom in",
1039
- title: "Zoom in",
1040
- children: /* @__PURE__ */ jsxRuntime.jsx(PlusIcon, {})
1041
- }
1042
- ),
1043
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "w-px h-5 bg-gray-200 mx-0.5" }),
1044
- /* @__PURE__ */ jsxRuntime.jsx(
1045
- "button",
1046
- {
1047
- onClick: reset,
1048
- className: "p-1.5 rounded hover:bg-gray-100 text-gray-600",
1049
- "aria-label": "Reset view",
1050
- title: "Reset view",
1051
- children: /* @__PURE__ */ jsxRuntime.jsx(ResetIcon, {})
1052
- }
1053
- )
1054
- ] })
1055
- ] });
1270
+ const [scale, setScale] = (0, react.useState)(1);
1271
+ const [offset, setOffset] = (0, react.useState)({
1272
+ x: 0,
1273
+ y: 0
1274
+ });
1275
+ const dragRef = (0, react.useRef)(null);
1276
+ const [isDragging, setIsDragging] = (0, react.useState)(false);
1277
+ const viewportRef = (0, react.useRef)(null);
1278
+ const reset = (0, react.useCallback)(() => {
1279
+ setScale(1);
1280
+ setOffset({
1281
+ x: 0,
1282
+ y: 0
1283
+ });
1284
+ }, []);
1285
+ const zoomBy = (0, react.useCallback)((factor, anchor) => {
1286
+ setScale((prev) => {
1287
+ const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, prev * factor));
1288
+ if (next === prev) return prev;
1289
+ const rect = viewportRef.current?.getBoundingClientRect();
1290
+ if (rect) {
1291
+ const ax = (anchor?.x ?? rect.width / 2) - rect.width / 2;
1292
+ const ay = (anchor?.y ?? rect.height / 2) - rect.height / 2;
1293
+ setOffset((o) => ({
1294
+ x: ax - (ax - o.x) * next / prev,
1295
+ y: ay - (ay - o.y) * next / prev
1296
+ }));
1297
+ }
1298
+ return next;
1299
+ });
1300
+ }, []);
1301
+ const handleWheel = (0, react.useCallback)((e) => {
1302
+ e.preventDefault();
1303
+ const rect = viewportRef.current?.getBoundingClientRect();
1304
+ const anchor = rect ? {
1305
+ x: e.clientX - rect.left,
1306
+ y: e.clientY - rect.top
1307
+ } : void 0;
1308
+ zoomBy(e.deltaY < 0 ? 1.15 : 1 / 1.15, anchor);
1309
+ }, [zoomBy]);
1310
+ const handlePointerDown = (e) => {
1311
+ e.currentTarget.setPointerCapture(e.pointerId);
1312
+ dragRef.current = {
1313
+ startX: e.clientX,
1314
+ startY: e.clientY,
1315
+ originX: offset.x,
1316
+ originY: offset.y
1317
+ };
1318
+ setIsDragging(true);
1319
+ };
1320
+ const handlePointerMove = (e) => {
1321
+ const d = dragRef.current;
1322
+ if (!d) return;
1323
+ setOffset({
1324
+ x: d.originX + (e.clientX - d.startX),
1325
+ y: d.originY + (e.clientY - d.startY)
1326
+ });
1327
+ };
1328
+ const endDrag = (e) => {
1329
+ if (dragRef.current) try {
1330
+ e.currentTarget.releasePointerCapture(e.pointerId);
1331
+ } catch {}
1332
+ dragRef.current = null;
1333
+ setIsDragging(false);
1334
+ };
1335
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1336
+ className: "relative w-full h-full",
1337
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1338
+ ref: viewportRef,
1339
+ onWheel: handleWheel,
1340
+ onPointerDown: handlePointerDown,
1341
+ onPointerMove: handlePointerMove,
1342
+ onPointerUp: endDrag,
1343
+ onPointerLeave: endDrag,
1344
+ onDoubleClick: reset,
1345
+ className: "w-full h-full overflow-hidden touch-none select-none",
1346
+ style: { cursor: isDragging ? "grabbing" : "grab" },
1347
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1348
+ className: "mermaid-zoom flex items-center justify-center w-full h-full",
1349
+ style: {
1350
+ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
1351
+ transformOrigin: "center center",
1352
+ transition: isDragging ? "none" : "transform 0.08s ease-out"
1353
+ },
1354
+ dangerouslySetInnerHTML: { __html: svg }
1355
+ })
1356
+ }), /* @__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",
1358
+ children: [
1359
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1360
+ onClick: () => zoomBy(1 / 1.3),
1361
+ className: "p-1.5 rounded hover:bg-gray-100 text-gray-600",
1362
+ "aria-label": "Zoom out",
1363
+ title: "Zoom out",
1364
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MinusIcon, {})
1365
+ }),
1366
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1367
+ onClick: reset,
1368
+ className: "px-2 py-1 text-xs font-medium rounded hover:bg-gray-100 text-gray-600 min-w-[3rem]",
1369
+ title: "Reset zoom (or double-click)",
1370
+ children: [Math.round(scale * 100), "%"]
1371
+ }),
1372
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1373
+ onClick: () => zoomBy(1.3),
1374
+ className: "p-1.5 rounded hover:bg-gray-100 text-gray-600",
1375
+ "aria-label": "Zoom in",
1376
+ title: "Zoom in",
1377
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PlusIcon, {})
1378
+ }),
1379
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "w-px h-5 bg-gray-200 mx-0.5" }),
1380
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1381
+ onClick: reset,
1382
+ className: "p-1.5 rounded hover:bg-gray-100 text-gray-600",
1383
+ "aria-label": "Reset view",
1384
+ title: "Reset view",
1385
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ResetIcon, {})
1386
+ })
1387
+ ]
1388
+ })]
1389
+ });
1056
1390
  }
1057
1391
  function extractErrorMessage(err) {
1058
- if (err instanceof Error) {
1059
- const msg = err.message;
1060
- const parseMatch = msg.match(
1061
- /(?:Parse error|Syntax error|Error).*?(?:line \d+.*)/i
1062
- );
1063
- if (parseMatch) return parseMatch[0];
1064
- return msg.split("\n")[0].slice(0, 200);
1065
- }
1066
- if (typeof err === "string") return err.split("\n")[0].slice(0, 200);
1067
- return "Unknown error";
1392
+ if (err instanceof Error) {
1393
+ const msg = err.message;
1394
+ const parseMatch = msg.match(/(?:Parse error|Syntax error|Error).*?(?:line \d+.*)/i);
1395
+ if (parseMatch) return parseMatch[0];
1396
+ return msg.split("\n")[0].slice(0, 200);
1397
+ }
1398
+ if (typeof err === "string") return err.split("\n")[0].slice(0, 200);
1399
+ return "Unknown error";
1068
1400
  }
1069
- function DiagramModal({
1070
- isOpen,
1071
- onClose,
1072
- children
1073
- }) {
1074
- react.useEffect(() => {
1075
- if (!isOpen) return;
1076
- const handler = (e) => {
1077
- if (e.key === "Escape") onClose();
1078
- };
1079
- document.addEventListener("keydown", handler);
1080
- document.body.style.overflow = "hidden";
1081
- return () => {
1082
- document.removeEventListener("keydown", handler);
1083
- document.body.style.overflow = "unset";
1084
- };
1085
- }, [isOpen, onClose]);
1086
- if (!isOpen) return null;
1087
- return /* @__PURE__ */ jsxRuntime.jsx(
1088
- "div",
1089
- {
1090
- className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4",
1091
- onClick: onClose,
1092
- children: /* @__PURE__ */ jsxRuntime.jsxs(
1093
- "div",
1094
- {
1095
- className: "bg-white rounded-lg shadow-xl w-[96vw] h-[94vh] flex flex-col overflow-hidden",
1096
- role: "dialog",
1097
- "aria-modal": "true",
1098
- onClick: (e) => e.stopPropagation(),
1099
- children: [
1100
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between p-4 border-b shrink-0", children: [
1101
- /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "text-lg font-semibold", children: "Mermaid Diagram" }),
1102
- /* @__PURE__ */ jsxRuntime.jsx(
1103
- "button",
1104
- {
1105
- onClick: onClose,
1106
- className: "p-1 hover:bg-gray-100 rounded-full transition-colors",
1107
- "aria-label": "Close modal",
1108
- children: /* @__PURE__ */ jsxRuntime.jsx(CloseIcon, {})
1109
- }
1110
- )
1111
- ] }),
1112
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex-1 overflow-hidden bg-gray-50", children })
1113
- ]
1114
- }
1115
- )
1116
- }
1117
- );
1401
+ /** Simple modal for expanding diagrams */
1402
+ function DiagramModal({ isOpen, onClose, children }) {
1403
+ (0, react.useEffect)(() => {
1404
+ if (!isOpen) return;
1405
+ const handler = (e) => {
1406
+ if (e.key === "Escape") onClose();
1407
+ };
1408
+ document.addEventListener("keydown", handler);
1409
+ document.body.style.overflow = "hidden";
1410
+ return () => {
1411
+ document.removeEventListener("keydown", handler);
1412
+ document.body.style.overflow = "unset";
1413
+ };
1414
+ }, [isOpen, onClose]);
1415
+ if (!isOpen) return null;
1416
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1417
+ className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4",
1418
+ onClick: onClose,
1419
+ 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",
1421
+ role: "dialog",
1422
+ "aria-modal": "true",
1423
+ onClick: (e) => e.stopPropagation(),
1424
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1425
+ className: "flex items-center justify-between p-4 border-b shrink-0",
1426
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1427
+ className: "text-lg font-semibold",
1428
+ children: "Mermaid Diagram"
1429
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1430
+ onClick: onClose,
1431
+ className: "p-1 hover:bg-gray-100 rounded-full transition-colors",
1432
+ "aria-label": "Close modal",
1433
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CloseIcon, {})
1434
+ })]
1435
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1436
+ className: "flex-1 overflow-hidden bg-gray-50",
1437
+ children
1438
+ })]
1439
+ })
1440
+ });
1118
1441
  }
1119
- var MermaidDiagramInner = ({ code }) => {
1120
- const hasCached = svgCache.has(code);
1121
- const [svg, setSvg] = react.useState(() => svgCache.get(code) || "");
1122
- const [errorMessage, setErrorMessage] = react.useState(null);
1123
- const [showSource, setShowSource] = react.useState(false);
1124
- const [isModalOpen, setIsModalOpen] = react.useState(false);
1125
- const [isLoading, setIsLoading] = react.useState(!hasCached);
1126
- const [minHeight, setMinHeight] = react.useState("auto");
1127
- const containerRef = react.useRef(null);
1128
- const lastHeightRef = react.useRef(null);
1129
- const stableId = react.useMemo(() => {
1130
- let hash = 0;
1131
- for (let i = 0; i < code.length; i++) {
1132
- const char = code.charCodeAt(i);
1133
- hash = (hash << 5) - hash + char;
1134
- hash = hash & hash;
1135
- }
1136
- return `mermaid-${Math.abs(hash).toString(36)}`;
1137
- }, [code]);
1138
- react.useEffect(() => {
1139
- if (hasCached) return;
1140
- let mounted = true;
1141
- const renderDiagram = async () => {
1142
- try {
1143
- if (containerRef.current) {
1144
- const height = containerRef.current.offsetHeight;
1145
- lastHeightRef.current = height;
1146
- setMinHeight(`${height}px`);
1147
- }
1148
- const m = await getMermaid();
1149
- const id = `${stableId}-${Date.now()}`;
1150
- const { svg: renderedSvg } = await m.render(id, code);
1151
- if (mounted) {
1152
- svgCache.set(code, renderedSvg);
1153
- setSvg(renderedSvg);
1154
- setErrorMessage(null);
1155
- setIsLoading(false);
1156
- }
1157
- } catch (err) {
1158
- console.error("Mermaid render error:", err);
1159
- if (mounted) {
1160
- setErrorMessage(extractErrorMessage(err));
1161
- setIsLoading(false);
1162
- }
1163
- }
1164
- };
1165
- renderDiagram();
1166
- return () => {
1167
- mounted = false;
1168
- };
1169
- }, [code, stableId, hasCached]);
1170
- if (errorMessage) {
1171
- return /* @__PURE__ */ jsxRuntime.jsxs(
1172
- "div",
1173
- {
1174
- "data-testid": "mermaid-container",
1175
- className: "my-4 rounded-md border border-yellow-300/40 bg-yellow-50/50 dark:border-yellow-700/40 dark:bg-yellow-950/20 overflow-hidden",
1176
- children: [
1177
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 px-4 py-2.5 text-sm text-yellow-800 dark:text-yellow-200", children: [
1178
- /* @__PURE__ */ jsxRuntime.jsx(AlertTriangleIcon, {}),
1179
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-medium", children: "Diagram syntax error" }),
1180
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-yellow-700/70 dark:text-yellow-300/60", children: [
1181
- "\u2014 ",
1182
- errorMessage
1183
- ] })
1184
- ] }),
1185
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "border-t border-yellow-300/30 dark:border-yellow-700/30", children: [
1186
- /* @__PURE__ */ jsxRuntime.jsxs(
1187
- "button",
1188
- {
1189
- onClick: () => setShowSource(!showSource),
1190
- className: "flex items-center gap-1.5 px-4 py-1.5 text-xs text-yellow-700/60 dark:text-yellow-400/50 hover:text-yellow-800 dark:hover:text-yellow-300 transition-colors w-full",
1191
- children: [
1192
- showSource ? /* @__PURE__ */ jsxRuntime.jsx(ChevronUpIcon, {}) : /* @__PURE__ */ jsxRuntime.jsx(ChevronDownIcon, {}),
1193
- showSource ? "Hide source" : "Show source"
1194
- ]
1195
- }
1196
- ),
1197
- showSource && /* @__PURE__ */ jsxRuntime.jsx("pre", { className: "px-4 pb-3 text-xs font-mono text-yellow-800/70 dark:text-yellow-200/60 overflow-auto whitespace-pre-wrap", children: code })
1198
- ] })
1199
- ]
1200
- }
1201
- );
1202
- }
1203
- return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1204
- /* @__PURE__ */ jsxRuntime.jsxs(
1205
- "div",
1206
- {
1207
- ref: containerRef,
1208
- "data-testid": "mermaid-container",
1209
- className: "relative group inline-block max-w-full",
1210
- style: { minHeight: isLoading ? minHeight : "auto" },
1211
- children: [
1212
- /* @__PURE__ */ jsxRuntime.jsx(
1213
- "div",
1214
- {
1215
- className: `mermaid flex justify-center my-4 overflow-x-auto transition-opacity duration-150 ${isLoading ? "opacity-50" : "opacity-100"}`,
1216
- dangerouslySetInnerHTML: { __html: svg }
1217
- }
1218
- ),
1219
- svg && /* @__PURE__ */ jsxRuntime.jsx(
1220
- "button",
1221
- {
1222
- onClick: () => setIsModalOpen(true),
1223
- 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",
1224
- "aria-label": "Maximize diagram",
1225
- children: /* @__PURE__ */ jsxRuntime.jsx(MaximizeIcon, {})
1226
- }
1227
- )
1228
- ]
1229
- }
1230
- ),
1231
- /* @__PURE__ */ jsxRuntime.jsx(DiagramModal, { isOpen: isModalOpen, onClose: () => setIsModalOpen(false), children: /* @__PURE__ */ jsxRuntime.jsx(ZoomableSvg, { svg }) })
1232
- ] });
1442
+ 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);
1446
+ const [showSource, setShowSource] = (0, react.useState)(false);
1447
+ const [isModalOpen, setIsModalOpen] = (0, react.useState)(false);
1448
+ const [isLoading, setIsLoading] = (0, react.useState)(!hasCached);
1449
+ const [minHeight, setMinHeight] = (0, react.useState)("auto");
1450
+ const containerRef = (0, react.useRef)(null);
1451
+ const lastHeightRef = (0, react.useRef)(null);
1452
+ const stableId = (0, react.useMemo)(() => {
1453
+ let hash = 0;
1454
+ for (let i = 0; i < code.length; i++) {
1455
+ const char = code.charCodeAt(i);
1456
+ hash = (hash << 5) - hash + char;
1457
+ hash = hash & hash;
1458
+ }
1459
+ return `mermaid-${Math.abs(hash).toString(36)}`;
1460
+ }, [code]);
1461
+ (0, react.useEffect)(() => {
1462
+ if (hasCached) return;
1463
+ let mounted = true;
1464
+ const renderDiagram = async () => {
1465
+ try {
1466
+ if (containerRef.current) {
1467
+ const height = containerRef.current.offsetHeight;
1468
+ lastHeightRef.current = height;
1469
+ setMinHeight(`${height}px`);
1470
+ }
1471
+ const m = await getMermaid();
1472
+ const id = `${stableId}-${Date.now()}`;
1473
+ const { svg: renderedSvg } = await m.render(id, code);
1474
+ if (mounted) {
1475
+ svgCache.set(code, renderedSvg);
1476
+ setSvg(renderedSvg);
1477
+ setErrorMessage(null);
1478
+ setIsLoading(false);
1479
+ }
1480
+ } catch (err) {
1481
+ console.error("Mermaid render error:", err);
1482
+ if (mounted) {
1483
+ setErrorMessage(extractErrorMessage(err));
1484
+ setIsLoading(false);
1485
+ }
1486
+ }
1487
+ };
1488
+ renderDiagram();
1489
+ return () => {
1490
+ mounted = false;
1491
+ };
1492
+ }, [
1493
+ code,
1494
+ stableId,
1495
+ hasCached
1496
+ ]);
1497
+ if (errorMessage) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1498
+ "data-testid": "mermaid-container",
1499
+ className: "my-4 rounded-md border border-yellow-300/40 bg-yellow-50/50 dark:border-yellow-700/40 dark:bg-yellow-950/20 overflow-hidden",
1500
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1501
+ className: "flex items-center gap-2 px-4 py-2.5 text-sm text-yellow-800 dark:text-yellow-200",
1502
+ children: [
1503
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AlertTriangleIcon, {}),
1504
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1505
+ className: "font-medium",
1506
+ children: "Diagram syntax error"
1507
+ }),
1508
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1509
+ className: "text-yellow-700/70 dark:text-yellow-300/60",
1510
+ children: ["— ", errorMessage]
1511
+ })
1512
+ ]
1513
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1514
+ className: "border-t border-yellow-300/30 dark:border-yellow-700/30",
1515
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1516
+ onClick: () => setShowSource(!showSource),
1517
+ className: "flex items-center gap-1.5 px-4 py-1.5 text-xs text-yellow-700/60 dark:text-yellow-400/50 hover:text-yellow-800 dark:hover:text-yellow-300 transition-colors w-full",
1518
+ children: [showSource ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronUpIcon, {}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChevronDownIcon, {}), showSource ? "Hide source" : "Show source"]
1519
+ }), showSource && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
1520
+ className: "px-4 pb-3 text-xs font-mono text-yellow-800/70 dark:text-yellow-200/60 overflow-auto whitespace-pre-wrap",
1521
+ children: code
1522
+ })]
1523
+ })]
1524
+ });
1525
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1526
+ ref: containerRef,
1527
+ "data-testid": "mermaid-container",
1528
+ className: "relative group inline-block max-w-full",
1529
+ style: { minHeight: isLoading ? minHeight : "auto" },
1530
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1531
+ className: `mermaid flex justify-center my-4 overflow-x-auto transition-opacity duration-150 ${isLoading ? "opacity-50" : "opacity-100"}`,
1532
+ dangerouslySetInnerHTML: { __html: svg }
1533
+ }), svg && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1534
+ 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",
1536
+ "aria-label": "Maximize diagram",
1537
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MaximizeIcon, {})
1538
+ })]
1539
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DiagramModal, {
1540
+ isOpen: isModalOpen,
1541
+ onClose: () => setIsModalOpen(false),
1542
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ZoomableSvg, { svg })
1543
+ })] });
1233
1544
  };
1234
- var MermaidDiagram = react.memo(
1235
- MermaidDiagramInner,
1236
- (prevProps, nextProps) => prevProps.code === nextProps.code
1237
- );
1238
-
1239
- // src/vantageFrontmatter.ts
1240
- var DOC_STATUSES = [
1241
- "draft",
1242
- "in-review",
1243
- "accepted",
1244
- "deprecated"
1545
+ const MermaidDiagram = (0, react.memo)(MermaidDiagramInner, (prevProps, nextProps) => prevProps.code === nextProps.code);
1546
+ //#endregion
1547
+ //#region src/vantageFrontmatter.ts
1548
+ /**
1549
+ * The document lifecycle vocabulary. Closed; extending it is a code change.
1550
+ *
1551
+ * This is the repo's own existing set, not a new one — `styleGuide.ts` tells
1552
+ * every agent to write `status: in-review # draft | in-review | accepted |
1553
+ * deprecated`, and every document under `docs/` follows it. It is deliberately
1554
+ * *not* the `badge` set (`draft stale blocked done wip`): `badge` is
1555
+ * section-scoped workflow state, `status` is document lifecycle state, and
1556
+ * `in-review` — the value the design doc's own only example renders — is not a
1557
+ * badge word at all. Only `draft` is a member of both, and a token set is per key.
1558
+ */
1559
+ const DOC_STATUSES = [
1560
+ "draft",
1561
+ "in-review",
1562
+ "accepted",
1563
+ "deprecated"
1245
1564
  ];
1246
- var VANTAGE_FRONTMATTER_KEYS = ["status-chip"];
1247
- var DOC_STATUS_TONES = {
1248
- draft: "muted",
1249
- "in-review": "warning",
1250
- accepted: "tip",
1251
- deprecated: "caution"
1565
+ /** Every key this build knows under `vantage:`. Closed. */
1566
+ const VANTAGE_FRONTMATTER_KEYS = ["status-chip"];
1567
+ /**
1568
+ * Which tone each status borrows its colours from.
1569
+ *
1570
+ * The chip has no palette of its own: it reuses the tone chips
1571
+ * (`.vantage-chip--<tone>` in `styles/directives.css`), which is also what makes
1572
+ * a `draft` chip and a `badge=draft` chip the same visual object. A map rather
1573
+ * than a computed class name, so the whole status→tone relation is one readable
1574
+ * table and a test can assert it covers the vocabulary.
1575
+ */
1576
+ const DOC_STATUS_TONES = {
1577
+ draft: "muted",
1578
+ "in-review": "warning",
1579
+ accepted: "tip",
1580
+ deprecated: "caution"
1252
1581
  };
1253
- var STATUS_CHIP_VALUES = [
1254
- ...DOC_STATUSES,
1255
- "true",
1256
- "false"
1582
+ /** The legal `status-chip` values, in the order a message should list them. */
1583
+ const STATUS_CHIP_VALUES = [
1584
+ ...DOC_STATUSES,
1585
+ "true",
1586
+ "false"
1257
1587
  ];
1588
+ /** Narrowing helper the chip and the checker both use. */
1258
1589
  function isDocStatus(value) {
1259
- return typeof value === "string" && DOC_STATUSES.includes(value);
1590
+ return typeof value === "string" && DOC_STATUSES.includes(value);
1260
1591
  }
1261
1592
  function isTable(value) {
1262
- return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
1593
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
1263
1594
  }
1595
+ /**
1596
+ * Read the `vantage:` key out of parsed frontmatter.
1597
+ *
1598
+ * Pure: no module state, no mutation of the input, no logging. The same object
1599
+ * in twice gives equal results out.
1600
+ */
1264
1601
  function readVantageFrontmatter(frontmatter) {
1265
- const issues = [];
1266
- if (!Object.hasOwn(frontmatter, "vantage")) return { issues };
1267
- const value = frontmatter["vantage"];
1268
- if (!isTable(value)) {
1269
- issues.push({ kind: "not-a-table", value });
1270
- return { issues };
1271
- }
1272
- let statusChip;
1273
- for (const key of Object.keys(value)) {
1274
- if (!VANTAGE_FRONTMATTER_KEYS.includes(key)) {
1275
- issues.push({ kind: "unknown-key", key });
1276
- continue;
1277
- }
1278
- if (key === "status-chip") {
1279
- statusChip = readStatusChip(frontmatter, value[key], issues);
1280
- }
1281
- }
1282
- return { ...statusChip === void 0 ? {} : { statusChip }, issues };
1602
+ const issues = [];
1603
+ if (!Object.hasOwn(frontmatter, "vantage")) return { issues };
1604
+ const value = frontmatter["vantage"];
1605
+ if (!isTable(value)) {
1606
+ issues.push({
1607
+ kind: "not-a-table",
1608
+ value
1609
+ });
1610
+ return { issues };
1611
+ }
1612
+ let statusChip;
1613
+ for (const key of Object.keys(value)) {
1614
+ if (!VANTAGE_FRONTMATTER_KEYS.includes(key)) {
1615
+ issues.push({
1616
+ kind: "unknown-key",
1617
+ key
1618
+ });
1619
+ continue;
1620
+ }
1621
+ if (key === "status-chip") statusChip = readStatusChip(frontmatter, value[key], issues);
1622
+ }
1623
+ return {
1624
+ ...statusChip === void 0 ? {} : { statusChip },
1625
+ issues
1626
+ };
1283
1627
  }
1628
+ /**
1629
+ * `status-chip` takes two shapes, and the boolean one is the recommended shape.
1630
+ *
1631
+ * `true` **inherits** the document's own top-level `status:`, so the chip cannot
1632
+ * disagree with it — which is the entire point of §5.3 ("makes `status: draft`
1633
+ * visible rather than only buried in a metadata card"; the row stays, the chip
1634
+ * promotes the value rather than moving it). A literal token is kept
1635
+ * because the design doc's first draft of that example used one, and the
1636
+ * disagreement it makes possible is turned into a checker finding rather than
1637
+ * banned.
1638
+ *
1639
+ * Discrimination is on `typeof`, never truthiness: `true` is a YAML boolean and
1640
+ * `2026-08-31` is a `Date`, and both would sail through a truthy test.
1641
+ */
1284
1642
  function readStatusChip(frontmatter, raw, issues) {
1285
- const status = frontmatter["status"];
1286
- if (raw === false) return void 0;
1287
- if (raw === true) {
1288
- if (isDocStatus(status)) return status;
1289
- issues.push({ kind: "status-chip-orphan", status });
1290
- return void 0;
1291
- }
1292
- if (isDocStatus(raw)) {
1293
- if (isDocStatus(status) && status !== raw) {
1294
- issues.push({ kind: "status-chip-disagrees", chip: raw, status });
1295
- }
1296
- return raw;
1297
- }
1298
- issues.push({
1299
- kind: "bad-value",
1300
- key: "status-chip",
1301
- value: raw,
1302
- legal: STATUS_CHIP_VALUES
1303
- });
1304
- return void 0;
1643
+ const status = frontmatter["status"];
1644
+ if (raw === false) return void 0;
1645
+ if (raw === true) {
1646
+ if (isDocStatus(status)) return status;
1647
+ issues.push({
1648
+ kind: "status-chip-orphan",
1649
+ status
1650
+ });
1651
+ return;
1652
+ }
1653
+ if (isDocStatus(raw)) {
1654
+ if (isDocStatus(status) && status !== raw) issues.push({
1655
+ kind: "status-chip-disagrees",
1656
+ chip: raw,
1657
+ status
1658
+ });
1659
+ return raw;
1660
+ }
1661
+ issues.push({
1662
+ kind: "bad-value",
1663
+ key: "status-chip",
1664
+ value: raw,
1665
+ legal: STATUS_CHIP_VALUES
1666
+ });
1305
1667
  }
1306
- var DocumentStatusChipInner = ({
1307
- status
1308
- }) => /* @__PURE__ */ jsxRuntime.jsx(
1309
- "span",
1310
- {
1311
- className: `vantage-chip vantage-chip--${DOC_STATUS_TONES[status]}`,
1312
- "data-vantage-status": status,
1313
- title: `Document status: ${status}`,
1314
- children: status
1315
- }
1316
- );
1317
- var DocumentStatusChip = react.memo(DocumentStatusChipInner);
1318
- var FileTextIcon = () => /* @__PURE__ */ jsxRuntime.jsxs(
1319
- "svg",
1320
- {
1321
- width: "14",
1322
- height: "14",
1323
- viewBox: "0 0 24 24",
1324
- fill: "none",
1325
- stroke: "currentColor",
1326
- strokeWidth: "2",
1327
- strokeLinecap: "round",
1328
- strokeLinejoin: "round",
1329
- className: "text-slate-400",
1330
- children: [
1331
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" }),
1332
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M14 2v4a2 2 0 0 0 2 2h4" }),
1333
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M10 9H8" }),
1334
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M16 13H8" }),
1335
- /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M16 17H8" })
1336
- ]
1337
- }
1338
- );
1668
+ //#endregion
1669
+ //#region src/DocumentStatusChip.tsx
1670
+ /**
1671
+ * The document's lifecycle status, as a chip.
1672
+ *
1673
+ * Non-interactive on purpose. D4 says a control that cannot work must not
1674
+ * render, and the cheapest way to satisfy that in *every* rendering — the app,
1675
+ * the package's exported viewer, an exported static site — is not to be a
1676
+ * control at all. So there is no `onClick`, no `<button>` and no
1677
+ * `isStaticMode()` gate: gating it would delete the chip from every exported
1678
+ * site, which is the one place D5 costs nothing.
1679
+ *
1680
+ * The text is the token verbatim, lowercase. No display map (`"In review"`) —
1681
+ * that is a second vocabulary that drifts from the first, and the visual
1682
+ * flourish belongs in CSS, where `text-transform` cannot change what the
1683
+ * document's text content is.
1684
+ *
1685
+ * Styling comes entirely from `styles/directives.css` — `.vantage-chip` for the
1686
+ * geometry, shared by selector list with the `badge=` pseudo-element, and
1687
+ * `.vantage-chip--<tone>` for the colours. No Tailwind utility appears here, so
1688
+ * the chip survives in a consumer that does not run Tailwind and no class name
1689
+ * has to be discovered by a content scan.
1690
+ */
1691
+ const DocumentStatusChipInner = ({ status }) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1692
+ className: `vantage-chip vantage-chip--${DOC_STATUS_TONES[status]}`,
1693
+ "data-vantage-status": status,
1694
+ title: `Document status: ${status}`,
1695
+ children: status
1696
+ });
1697
+ const DocumentStatusChip = (0, react.memo)(DocumentStatusChipInner);
1698
+ //#endregion
1699
+ //#region src/FrontmatterDisplay.tsx
1700
+ const FileTextIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1701
+ width: "14",
1702
+ height: "14",
1703
+ viewBox: "0 0 24 24",
1704
+ fill: "none",
1705
+ stroke: "currentColor",
1706
+ strokeWidth: "2",
1707
+ strokeLinecap: "round",
1708
+ strokeLinejoin: "round",
1709
+ className: "text-slate-400",
1710
+ children: [
1711
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" }),
1712
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M14 2v4a2 2 0 0 0 2 2h4" }),
1713
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M10 9H8" }),
1714
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M16 13H8" }),
1715
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M16 17H8" })
1716
+ ]
1717
+ });
1339
1718
  function isStringArray(value) {
1340
- return Array.isArray(value) && value.every((item) => typeof item === "string");
1719
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
1341
1720
  }
1342
1721
  function isPlainObject(value) {
1343
- return typeof value === "object" && value !== null && !Array.isArray(value);
1722
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1344
1723
  }
1345
1724
  function formatValue(value) {
1346
- if (value instanceof Date) {
1347
- return value.toISOString().split("T")[0];
1348
- }
1349
- if (Array.isArray(value)) {
1350
- return value.map(String).join(", ");
1351
- }
1352
- if (typeof value === "boolean") {
1353
- return value ? "true" : "false";
1354
- }
1355
- if (isPlainObject(value)) {
1356
- return JSON.stringify(value, null, 2);
1357
- }
1358
- return String(value);
1725
+ if (value instanceof Date) return value.toISOString().split("T")[0];
1726
+ if (Array.isArray(value)) return value.map(String).join(", ");
1727
+ if (typeof value === "boolean") return value ? "true" : "false";
1728
+ if (isPlainObject(value)) return JSON.stringify(value, null, 2);
1729
+ return String(value);
1359
1730
  }
1360
1731
  function TagList({ items }) {
1361
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-1.5", children: items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(
1362
- "span",
1363
- {
1364
- className: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
1365
- children: item
1366
- },
1367
- item
1368
- )) });
1732
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1733
+ className: "flex flex-wrap gap-1.5",
1734
+ children: items.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1735
+ className: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
1736
+ children: item
1737
+ }, item))
1738
+ });
1369
1739
  }
1370
1740
  function ValueCell({ value }) {
1371
- if (isStringArray(value) && value.length > 0) {
1372
- return /* @__PURE__ */ jsxRuntime.jsx(TagList, { items: value });
1373
- }
1374
- if (isPlainObject(value)) {
1375
- return /* @__PURE__ */ jsxRuntime.jsx("pre", { className: "bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-md px-3 py-2 text-xs font-mono overflow-x-auto text-slate-600 dark:text-slate-300", children: formatValue(value) });
1376
- }
1377
- return /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-medium", children: formatValue(value) });
1741
+ if (isStringArray(value) && value.length > 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TagList, { items: value });
1742
+ if (isPlainObject(value)) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
1743
+ className: "bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-md px-3 py-2 text-xs font-mono overflow-x-auto text-slate-600 dark:text-slate-300",
1744
+ children: formatValue(value)
1745
+ });
1746
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1747
+ className: "font-medium",
1748
+ children: formatValue(value)
1749
+ });
1378
1750
  }
1379
1751
  function flattenEntries(entries) {
1380
- const result = [];
1381
- for (const [key, value] of entries) {
1382
- if (key === "vantage") continue;
1383
- if (key === "taxonomies" && isPlainObject(value)) {
1384
- for (const [taxKey, taxVal] of Object.entries(value)) {
1385
- result.push([taxKey, taxVal]);
1386
- }
1387
- } else if (key === "extra" && isPlainObject(value)) {
1388
- for (const [extraKey, extraVal] of Object.entries(value)) {
1389
- result.push([extraKey, extraVal]);
1390
- }
1391
- } else {
1392
- result.push([key, value]);
1393
- }
1394
- }
1395
- return result;
1752
+ const result = [];
1753
+ for (const [key, value] of entries) {
1754
+ if (key === "vantage") continue;
1755
+ if (key === "taxonomies" && isPlainObject(value)) for (const [taxKey, taxVal] of Object.entries(value)) result.push([taxKey, taxVal]);
1756
+ else if (key === "extra" && isPlainObject(value)) for (const [extraKey, extraVal] of Object.entries(value)) result.push([extraKey, extraVal]);
1757
+ else result.push([key, value]);
1758
+ }
1759
+ return result;
1396
1760
  }
1397
- var FrontmatterDisplayInner = ({
1398
- frontmatter
1399
- }) => {
1400
- const entries = flattenEntries(Object.entries(frontmatter));
1401
- const { statusChip } = readVantageFrontmatter(frontmatter);
1402
- if (entries.length === 0 && statusChip === void 0) return null;
1403
- return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1404
- statusChip !== void 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "vantage-chrome", children: /* @__PURE__ */ jsxRuntime.jsx(DocumentStatusChip, { status: statusChip }) }),
1405
- entries.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mb-8 rounded-lg overflow-hidden bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-800 dark:to-slate-800/80 border border-slate-200 dark:border-slate-700 shadow-sm", children: [
1406
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-4 py-2.5 bg-white/60 dark:bg-slate-900/40 border-b border-slate-200 dark:border-slate-700 flex items-center gap-2", children: [
1407
- /* @__PURE__ */ jsxRuntime.jsx(FileTextIcon, {}),
1408
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider", children: "Metadata" })
1409
- ] }),
1410
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "p-4", children: /* @__PURE__ */ jsxRuntime.jsx("table", { className: "w-full text-sm", children: /* @__PURE__ */ jsxRuntime.jsx("tbody", { children: entries.map(([key, value]) => /* @__PURE__ */ jsxRuntime.jsxs(
1411
- "tr",
1412
- {
1413
- className: "border-b border-slate-200/60 dark:border-slate-700/60 last:border-0",
1414
- children: [
1415
- /* @__PURE__ */ jsxRuntime.jsx("td", { className: "py-2 pr-4 font-medium text-slate-500 dark:text-slate-400 whitespace-nowrap align-top w-1/4 min-w-[100px]", children: key }),
1416
- /* @__PURE__ */ jsxRuntime.jsx("td", { className: "py-2 text-slate-800 dark:text-slate-200 align-top", children: /* @__PURE__ */ jsxRuntime.jsx(ValueCell, { value }) })
1417
- ]
1418
- },
1419
- key
1420
- )) }) }) })
1421
- ] })
1422
- ] });
1761
+ const FrontmatterDisplayInner = ({ frontmatter }) => {
1762
+ const entries = flattenEntries(Object.entries(frontmatter));
1763
+ const { statusChip } = readVantageFrontmatter(frontmatter);
1764
+ if (entries.length === 0 && statusChip === void 0) return null;
1765
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [statusChip !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1766
+ className: "vantage-chrome",
1767
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DocumentStatusChip, { status: statusChip })
1768
+ }), entries.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1769
+ className: "mb-8 rounded-lg overflow-hidden bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-800 dark:to-slate-800/80 border border-slate-200 dark:border-slate-700 shadow-sm",
1770
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1771
+ className: "px-4 py-2.5 bg-white/60 dark:bg-slate-900/40 border-b border-slate-200 dark:border-slate-700 flex items-center gap-2",
1772
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FileTextIcon, {}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1773
+ className: "text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider",
1774
+ children: "Metadata"
1775
+ })]
1776
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1777
+ className: "p-4",
1778
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("table", {
1779
+ className: "w-full text-sm",
1780
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: entries.map(([key, value]) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", {
1781
+ className: "border-b border-slate-200/60 dark:border-slate-700/60 last:border-0",
1782
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1783
+ className: "py-2 pr-4 font-medium text-slate-500 dark:text-slate-400 whitespace-nowrap align-top w-1/4 min-w-[100px]",
1784
+ children: key
1785
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
1786
+ className: "py-2 text-slate-800 dark:text-slate-200 align-top",
1787
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ValueCell, { value })
1788
+ })]
1789
+ }, key)) })
1790
+ })
1791
+ })]
1792
+ })] });
1423
1793
  };
1424
- var FrontmatterDisplay = react.memo(FrontmatterDisplayInner);
1425
-
1426
- // src/lineAnchor.ts
1794
+ const FrontmatterDisplay = (0, react.memo)(FrontmatterDisplayInner);
1795
+ //#endregion
1796
+ //#region src/lineAnchor.ts
1797
+ /**
1798
+ * Parsing for GitHub-style line anchors, with no DOM in sight.
1799
+ *
1800
+ * Split out from scrollToLineAnchor.ts so that non-browser consumers — the
1801
+ * `vantage-check` CLI, which validates `#L42` links against the file on disk —
1802
+ * can share the *same* syntax the viewer honours instead of reimplementing it
1803
+ * and drifting.
1804
+ */
1805
+ /**
1806
+ * Parse a GitHub-style line anchor hash.
1807
+ * Supports: #L42, #L42-L50, #L42-50
1808
+ * Returns null if the hash is not a line anchor.
1809
+ */
1427
1810
  function parseLineAnchor(hash) {
1428
- if (!hash) return null;
1429
- const frag = hash.startsWith("#") ? hash.slice(1) : hash;
1430
- const match = frag.match(/^L(\d+)(?:-L?(\d+))?$/);
1431
- if (!match) return null;
1432
- const start = parseInt(match[1], 10);
1433
- const end = match[2] ? parseInt(match[2], 10) : start;
1434
- return { start: Math.min(start, end), end: Math.max(start, end) };
1811
+ if (!hash) return null;
1812
+ const match = (hash.startsWith("#") ? hash.slice(1) : hash).match(/^L(\d+)(?:-L?(\d+))?$/);
1813
+ if (!match) return null;
1814
+ const start = parseInt(match[1], 10);
1815
+ const end = match[2] ? parseInt(match[2], 10) : start;
1816
+ return {
1817
+ start: Math.min(start, end),
1818
+ end: Math.max(start, end)
1819
+ };
1435
1820
  }
1436
-
1437
- // src/scrollToLineAnchor.ts
1438
- var HIGHLIGHT_CLASS = "line-anchor-highlight";
1821
+ //#endregion
1822
+ //#region src/scrollToLineAnchor.ts
1823
+ /**
1824
+ * Framework-agnostic line anchor utilities.
1825
+ * Scroll to and highlight the elements a GitHub-style line anchor
1826
+ * (#L42, #L42-L50) names. The parsing half lives in lineAnchor.ts, which has
1827
+ * no DOM dependency.
1828
+ */
1829
+ const HIGHLIGHT_CLASS$1 = "line-anchor-highlight";
1830
+ /**
1831
+ * Clear all line anchor highlights from a container.
1832
+ */
1439
1833
  function clearLineAnchorHighlights(container) {
1440
- container.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((node) => {
1441
- node.classList.remove(HIGHLIGHT_CLASS);
1442
- });
1834
+ container.querySelectorAll(`.${HIGHLIGHT_CLASS$1}`).forEach((node) => {
1835
+ node.classList.remove(HIGHLIGHT_CLASS$1);
1836
+ });
1443
1837
  }
1838
+ /**
1839
+ * Scroll to and highlight line-anchored elements in a container.
1840
+ *
1841
+ * @param container - The DOM element containing rendered markdown
1842
+ * @param hash - The URL hash (e.g. "#L42" or "#L42-L50")
1843
+ * @returns A cleanup function that removes the highlights
1844
+ */
1444
1845
  function scrollToLineAnchor(container, hash) {
1445
- clearLineAnchorHighlights(container);
1446
- const range = parseLineAnchor(hash);
1447
- if (!range) return null;
1448
- const blocks = container.querySelectorAll("[data-source-line]");
1449
- let firstMatch = null;
1450
- for (const block of blocks) {
1451
- const line = parseInt(block.dataset.sourceLine || "0", 10);
1452
- if (line >= range.start && line <= range.end) {
1453
- block.classList.add(HIGHLIGHT_CLASS);
1454
- if (!firstMatch) firstMatch = block;
1455
- }
1456
- }
1457
- if (!firstMatch) {
1458
- let closest = null;
1459
- let closestLine = 0;
1460
- for (const block of blocks) {
1461
- const line = parseInt(
1462
- block.dataset.sourceLine || "0",
1463
- 10
1464
- );
1465
- if (line <= range.start && line > closestLine) {
1466
- closestLine = line;
1467
- closest = block;
1468
- }
1469
- }
1470
- if (closest) {
1471
- closest.classList.add(HIGHLIGHT_CLASS);
1472
- firstMatch = closest;
1473
- }
1474
- }
1475
- if (firstMatch) {
1476
- requestAnimationFrame(() => {
1477
- const scrollParent = findScrollParent(container);
1478
- if (scrollParent) {
1479
- const offset = firstMatch.getBoundingClientRect().top - scrollParent.getBoundingClientRect().top + scrollParent.scrollTop;
1480
- scrollParent.scrollTo({ top: offset - 32, behavior: "smooth" });
1481
- } else {
1482
- firstMatch.scrollIntoView({ behavior: "smooth", block: "start" });
1483
- }
1484
- });
1485
- }
1486
- return () => clearLineAnchorHighlights(container);
1846
+ clearLineAnchorHighlights(container);
1847
+ const range = parseLineAnchor(hash);
1848
+ if (!range) return null;
1849
+ const blocks = container.querySelectorAll("[data-source-line]");
1850
+ let firstMatch = null;
1851
+ for (const block of blocks) {
1852
+ const line = parseInt(block.dataset.sourceLine || "0", 10);
1853
+ if (line >= range.start && line <= range.end) {
1854
+ block.classList.add(HIGHLIGHT_CLASS$1);
1855
+ if (!firstMatch) firstMatch = block;
1856
+ }
1857
+ }
1858
+ if (!firstMatch) {
1859
+ let closest = null;
1860
+ let closestLine = 0;
1861
+ for (const block of blocks) {
1862
+ const line = parseInt(block.dataset.sourceLine || "0", 10);
1863
+ if (line <= range.start && line > closestLine) {
1864
+ closestLine = line;
1865
+ closest = block;
1866
+ }
1867
+ }
1868
+ if (closest) {
1869
+ closest.classList.add(HIGHLIGHT_CLASS$1);
1870
+ firstMatch = closest;
1871
+ }
1872
+ }
1873
+ if (firstMatch) requestAnimationFrame(() => {
1874
+ const scrollParent = findScrollParent(container);
1875
+ if (scrollParent) {
1876
+ const offset = firstMatch.getBoundingClientRect().top - scrollParent.getBoundingClientRect().top + scrollParent.scrollTop;
1877
+ scrollParent.scrollTo({
1878
+ top: offset - 32,
1879
+ behavior: "smooth"
1880
+ });
1881
+ } else firstMatch.scrollIntoView({
1882
+ behavior: "smooth",
1883
+ block: "start"
1884
+ });
1885
+ });
1886
+ return () => clearLineAnchorHighlights(container);
1487
1887
  }
1488
1888
  function findScrollParent(el) {
1489
- let node = el;
1490
- while (node) {
1491
- const overflow = getComputedStyle(node).overflowY;
1492
- if (overflow === "auto" || overflow === "scroll") return node;
1493
- node = node.parentElement;
1494
- }
1495
- return null;
1889
+ let node = el;
1890
+ while (node) {
1891
+ const overflow = getComputedStyle(node).overflowY;
1892
+ if (overflow === "auto" || overflow === "scroll") return node;
1893
+ node = node.parentElement;
1894
+ }
1895
+ return null;
1496
1896
  }
1497
-
1498
- // src/useLineAnchor.ts
1499
- var HIGHLIGHT_CLASS2 = "line-anchor-highlight";
1897
+ //#endregion
1898
+ //#region src/useLineAnchor.ts
1899
+ const HIGHLIGHT_CLASS = "line-anchor-highlight";
1900
+ /**
1901
+ * React hook that handles GitHub-style line anchors (#L42, #L42-L50).
1902
+ *
1903
+ * - Parses the URL hash for line references
1904
+ * - Finds block elements with matching `data-source-line` attributes
1905
+ * - Scrolls to and highlights them
1906
+ * - Dismisses on Escape or click on the highlight
1907
+ *
1908
+ * @param containerRef - Ref to the DOM element containing rendered markdown
1909
+ * @param hash - The current URL hash (e.g. "#L42", from location.hash or window.location.hash)
1910
+ */
1500
1911
  function useLineAnchor(containerRef, hash) {
1501
- const clearHighlights = react.useCallback(() => {
1502
- const el = containerRef.current;
1503
- if (!el) return;
1504
- clearLineAnchorHighlights(el);
1505
- }, [containerRef]);
1506
- react.useEffect(() => {
1507
- const el = containerRef.current;
1508
- if (!el) return;
1509
- clearHighlights();
1510
- const range = parseLineAnchor(hash);
1511
- if (!range) return;
1512
- const blocks = el.querySelectorAll("[data-source-line]");
1513
- let firstMatch = null;
1514
- for (const block of blocks) {
1515
- const line = parseInt(
1516
- block.dataset.sourceLine || "0",
1517
- 10
1518
- );
1519
- if (line >= range.start && line <= range.end) {
1520
- block.classList.add(HIGHLIGHT_CLASS2);
1521
- if (!firstMatch) firstMatch = block;
1522
- }
1523
- }
1524
- if (!firstMatch) {
1525
- let closest = null;
1526
- let closestLine = 0;
1527
- for (const block of blocks) {
1528
- const line = parseInt(
1529
- block.dataset.sourceLine || "0",
1530
- 10
1531
- );
1532
- if (line <= range.start && line > closestLine) {
1533
- closestLine = line;
1534
- closest = block;
1535
- }
1536
- }
1537
- if (closest) {
1538
- closest.classList.add(HIGHLIGHT_CLASS2);
1539
- firstMatch = closest;
1540
- }
1541
- }
1542
- if (firstMatch) {
1543
- let scrollParent = el;
1544
- while (scrollParent) {
1545
- const overflow = getComputedStyle(scrollParent).overflowY;
1546
- if (overflow === "auto" || overflow === "scroll") break;
1547
- scrollParent = scrollParent.parentElement;
1548
- }
1549
- if (scrollParent) {
1550
- const target = firstMatch;
1551
- requestAnimationFrame(() => {
1552
- const offset = target.getBoundingClientRect().top - scrollParent.getBoundingClientRect().top + scrollParent.scrollTop;
1553
- scrollParent.scrollTo({ top: offset - 32, behavior: "smooth" });
1554
- });
1555
- }
1556
- }
1557
- }, [hash, containerRef, clearHighlights]);
1558
- react.useEffect(() => {
1559
- const handler = (e) => {
1560
- if (e.key === "Escape") {
1561
- clearHighlights();
1562
- if (window.location.hash) {
1563
- history.replaceState(null, "", window.location.pathname);
1564
- }
1565
- }
1566
- };
1567
- document.addEventListener("keydown", handler);
1568
- return () => document.removeEventListener("keydown", handler);
1569
- }, [clearHighlights]);
1570
- react.useEffect(() => {
1571
- const el = containerRef.current;
1572
- if (!el) return;
1573
- const handler = (e) => {
1574
- const target = e.target;
1575
- if (target.closest(`.${HIGHLIGHT_CLASS2}`)) {
1576
- clearHighlights();
1577
- if (window.location.hash) {
1578
- history.replaceState(null, "", window.location.pathname);
1579
- }
1580
- }
1581
- };
1582
- el.addEventListener("click", handler);
1583
- return () => el.removeEventListener("click", handler);
1584
- }, [containerRef, clearHighlights]);
1585
- return { clearHighlights };
1912
+ const clearHighlights = (0, react.useCallback)(() => {
1913
+ const el = containerRef.current;
1914
+ if (!el) return;
1915
+ clearLineAnchorHighlights(el);
1916
+ }, [containerRef]);
1917
+ (0, react.useEffect)(() => {
1918
+ const el = containerRef.current;
1919
+ if (!el) return;
1920
+ clearHighlights();
1921
+ const range = parseLineAnchor(hash);
1922
+ if (!range) return;
1923
+ const blocks = el.querySelectorAll("[data-source-line]");
1924
+ let firstMatch = null;
1925
+ for (const block of blocks) {
1926
+ const line = parseInt(block.dataset.sourceLine || "0", 10);
1927
+ if (line >= range.start && line <= range.end) {
1928
+ block.classList.add(HIGHLIGHT_CLASS);
1929
+ if (!firstMatch) firstMatch = block;
1930
+ }
1931
+ }
1932
+ if (!firstMatch) {
1933
+ let closest = null;
1934
+ let closestLine = 0;
1935
+ for (const block of blocks) {
1936
+ const line = parseInt(block.dataset.sourceLine || "0", 10);
1937
+ if (line <= range.start && line > closestLine) {
1938
+ closestLine = line;
1939
+ closest = block;
1940
+ }
1941
+ }
1942
+ if (closest) {
1943
+ closest.classList.add(HIGHLIGHT_CLASS);
1944
+ firstMatch = closest;
1945
+ }
1946
+ }
1947
+ if (firstMatch) {
1948
+ let scrollParent = el;
1949
+ while (scrollParent) {
1950
+ const overflow = getComputedStyle(scrollParent).overflowY;
1951
+ if (overflow === "auto" || overflow === "scroll") break;
1952
+ scrollParent = scrollParent.parentElement;
1953
+ }
1954
+ if (scrollParent) {
1955
+ const target = firstMatch;
1956
+ requestAnimationFrame(() => {
1957
+ const offset = target.getBoundingClientRect().top - scrollParent.getBoundingClientRect().top + scrollParent.scrollTop;
1958
+ scrollParent.scrollTo({
1959
+ top: offset - 32,
1960
+ behavior: "smooth"
1961
+ });
1962
+ });
1963
+ }
1964
+ }
1965
+ }, [
1966
+ hash,
1967
+ containerRef,
1968
+ clearHighlights
1969
+ ]);
1970
+ (0, react.useEffect)(() => {
1971
+ const handler = (e) => {
1972
+ if (e.key === "Escape") {
1973
+ clearHighlights();
1974
+ if (window.location.hash) history.replaceState(null, "", window.location.pathname);
1975
+ }
1976
+ };
1977
+ document.addEventListener("keydown", handler);
1978
+ return () => document.removeEventListener("keydown", handler);
1979
+ }, [clearHighlights]);
1980
+ (0, react.useEffect)(() => {
1981
+ const el = containerRef.current;
1982
+ if (!el) return;
1983
+ const handler = (e) => {
1984
+ if (e.target.closest(`.${HIGHLIGHT_CLASS}`)) {
1985
+ clearHighlights();
1986
+ if (window.location.hash) history.replaceState(null, "", window.location.pathname);
1987
+ }
1988
+ };
1989
+ el.addEventListener("click", handler);
1990
+ return () => el.removeEventListener("click", handler);
1991
+ }, [containerRef, clearHighlights]);
1992
+ return { clearHighlights };
1586
1993
  }
1587
- var MarkdownViewerInner = ({
1588
- content,
1589
- currentPath = "",
1590
- hash = "",
1591
- baseUrl = "/",
1592
- imageApiBase,
1593
- className,
1594
- onNavigate
1595
- }) => {
1596
- const containerRef = react.useRef(null);
1597
- useLineAnchor(containerRef, hash);
1598
- const { frontmatter, body, bodyLineOffset } = react.useMemo(() => {
1599
- return parseFrontmatter(content);
1600
- }, [content]);
1601
- const { remarkPlugins, rehypePlugins } = react.useMemo(
1602
- () => buildPipeline({ bodyLineOffset }),
1603
- [bodyLineOffset]
1604
- );
1605
- const handleLinkClick = react.useCallback(
1606
- (e, href) => {
1607
- if (href.startsWith("#")) {
1608
- e.preventDefault();
1609
- const id = href.slice(1);
1610
- const el = document.getElementById(id);
1611
- if (el) {
1612
- const scrollContainer = el.closest("[data-content-scroll]") || el.closest(".overflow-y-auto");
1613
- if (scrollContainer) {
1614
- const offset = el.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top + scrollContainer.scrollTop;
1615
- scrollContainer.scrollTo({ top: offset - 16 });
1616
- } else {
1617
- el.scrollIntoView();
1618
- }
1619
- }
1620
- return;
1621
- }
1622
- if (href.startsWith("http") || href.startsWith("mailto:")) return;
1623
- if (onNavigate) {
1624
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
1625
- e.preventDefault();
1626
- const [pathPart, hashPart] = href.split("#");
1627
- const parts = currentPath.split("/");
1628
- parts.pop();
1629
- const dir = parts.join("/");
1630
- const cleanHref = pathPart.replace(/^\.\//, "");
1631
- const resolvedPath = dir ? `${dir}/${cleanHref}` : cleanHref;
1632
- onNavigate(resolvedPath + (hashPart ? `#${hashPart}` : ""));
1633
- }
1634
- },
1635
- [currentPath, onNavigate]
1636
- );
1637
- const resolveHref = react.useCallback(
1638
- (href) => {
1639
- if (!href) return "";
1640
- if (href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("#") || href.startsWith("/")) {
1641
- return href;
1642
- }
1643
- const [pathPart, hashPart] = href.split("#");
1644
- const parts = currentPath.split("/");
1645
- parts.pop();
1646
- const dir = parts.join("/");
1647
- const cleanHref = pathPart.replace(/^\.\//, "");
1648
- const resolvedPath = dir ? `${dir}/${cleanHref}` : cleanHref;
1649
- return `${baseUrl}${resolvedPath}${hashPart ? `#${hashPart}` : ""}`;
1650
- },
1651
- [currentPath, baseUrl]
1652
- );
1653
- const transformImageUri = react.useCallback(
1654
- (uri, key) => {
1655
- if (key === "href") return uri;
1656
- if (uri.startsWith("http") || uri.startsWith("data:")) return uri;
1657
- const parts = currentPath.split("/");
1658
- parts.pop();
1659
- const dir = parts.join("/");
1660
- const resolvedPath = dir ? `${dir}/${uri}` : uri;
1661
- if (imageApiBase) {
1662
- return `${imageApiBase}/content?path=${encodeURIComponent(resolvedPath)}`;
1663
- }
1664
- return `${baseUrl}${resolvedPath}`;
1665
- },
1666
- [currentPath, baseUrl, imageApiBase]
1667
- );
1668
- const markdownComponents = react.useMemo(
1669
- () => ({
1670
- a({
1671
- href,
1672
- children,
1673
- ...props
1674
- }) {
1675
- const resolvedHref = resolveHref(href);
1676
- return /* @__PURE__ */ jsxRuntime.jsx(
1677
- "a",
1678
- {
1679
- href: resolvedHref,
1680
- onClick: (e) => href && handleLinkClick(e, href),
1681
- ...props,
1682
- children
1683
- }
1684
- );
1685
- },
1686
- code(props) {
1687
- const { children, className: codeClassName, ...rest } = props;
1688
- const match = /language-(\w+)/.exec(codeClassName || "");
1689
- if (match && match[1] === "mermaid") {
1690
- return /* @__PURE__ */ jsxRuntime.jsx(MermaidDiagram, { code: String(children).replace(/\n$/, "") });
1691
- }
1692
- return /* @__PURE__ */ jsxRuntime.jsx("code", { className: codeClassName, ...rest, children });
1693
- }
1694
- }),
1695
- [handleLinkClick, resolveHref]
1696
- );
1697
- const proseClasses = [
1698
- "prose prose-slate dark:prose-invert max-w-none",
1699
- "prose-headings:font-semibold prose-headings:tracking-tight prose-headings:text-slate-900 dark:prose-headings:text-slate-100",
1700
- "prose-h1:text-[2em] prose-h1:mb-3 prose-h1:pb-[0.3em] prose-h1:border-b prose-h1:border-slate-200 dark:prose-h1:border-slate-700",
1701
- "prose-h2:text-[1.5em] prose-h2:mt-6 prose-h2:mb-3 prose-h2:pb-[0.3em] prose-h2:border-b prose-h2:border-slate-200 dark:prose-h2:border-slate-700",
1702
- "prose-h3:text-[1.25em] prose-h3:mt-6 prose-h3:mb-2",
1703
- "prose-h4:text-[1em] prose-h4:mt-6 prose-h4:mb-2",
1704
- "prose-p:text-slate-700 dark:prose-p:text-slate-300 prose-p:leading-[1.5] prose-p:my-[16px]",
1705
- "prose-ul:my-[16px] prose-ul:list-disc prose-li:my-0.5 prose-li:marker:text-slate-900 dark:prose-li:marker:text-slate-300",
1706
- "prose-ol:my-[16px] prose-li:marker:text-slate-900 dark:prose-li:marker:text-slate-300",
1707
- "prose-pre:bg-slate-50 dark:prose-pre:bg-slate-800 prose-pre:border prose-pre:border-slate-200 dark:prose-pre:border-slate-700 prose-pre:p-4 prose-pre:rounded-md prose-pre:text-[85%] prose-pre:leading-[1.45]",
1708
- "prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-a:no-underline hover:prose-a:underline",
1709
- "prose-img:rounded-lg prose-img:my-4",
1710
- "prose-blockquote:border-l-[0.25em] prose-blockquote:border-slate-300 dark:prose-blockquote:border-slate-600 prose-blockquote:pl-4 prose-blockquote:text-slate-600 dark:prose-blockquote:text-slate-400 prose-blockquote:italic",
1711
- "prose-code:before:content-none prose-code:after:content-none",
1712
- "prose-code:bg-slate-100 dark:prose-code:bg-slate-800 prose-code:px-[0.4em] prose-code:py-[0.2em] prose-code:rounded-md prose-code:text-slate-800 dark:prose-code:text-slate-200 prose-code:font-mono prose-code:text-[85%] prose-code:font-normal prose-code:border prose-code:border-slate-200/50 dark:prose-code:border-slate-700/50",
1713
- "prose-table:text-sm",
1714
- "prose-th:px-3 prose-th:py-1.5 prose-th:border prose-th:border-slate-200 dark:prose-th:border-slate-700",
1715
- "prose-td:px-3 prose-td:py-1.5 prose-td:border prose-td:border-slate-200 dark:prose-td:border-slate-700"
1716
- ].join(" ");
1717
- return /* @__PURE__ */ jsxRuntime.jsxs(
1718
- "div",
1719
- {
1720
- ref: containerRef,
1721
- className: className ? `${proseClasses} ${className}` : proseClasses,
1722
- children: [
1723
- /* @__PURE__ */ jsxRuntime.jsx(FrontmatterDisplay, { frontmatter }),
1724
- /* @__PURE__ */ jsxRuntime.jsx(
1725
- ReactMarkdown__default.default,
1726
- {
1727
- remarkPlugins,
1728
- rehypePlugins,
1729
- urlTransform: transformImageUri,
1730
- components: markdownComponents,
1731
- children: body
1732
- }
1733
- )
1734
- ]
1735
- }
1736
- );
1994
+ //#endregion
1995
+ //#region src/MarkdownViewer.tsx
1996
+ /**
1997
+ * React markdown viewer component.
1998
+ *
1999
+ * Renders markdown with the full Vantage pipeline: GFM, math, syntax
2000
+ * highlighting, mermaid diagrams, line anchors, and frontmatter display.
2001
+ *
2002
+ * This is a standalone component with no dependency on Vantage's stores,
2003
+ * router, or review system.
2004
+ */
2005
+ const MarkdownViewerInner = ({ content, currentPath = "", hash = "", baseUrl = "/", imageApiBase, className, onNavigate }) => {
2006
+ const containerRef = (0, react.useRef)(null);
2007
+ useLineAnchor(containerRef, hash);
2008
+ const { frontmatter, body, bodyLineOffset } = (0, react.useMemo)(() => {
2009
+ return parseFrontmatter(content);
2010
+ }, [content]);
2011
+ const { remarkPlugins, rehypePlugins } = (0, react.useMemo)(() => buildPipeline({ bodyLineOffset }), [bodyLineOffset]);
2012
+ const handleLinkClick = (0, react.useCallback)((e, href) => {
2013
+ if (href.startsWith("#")) {
2014
+ e.preventDefault();
2015
+ const id = href.slice(1);
2016
+ const el = document.getElementById(id);
2017
+ if (el) {
2018
+ const scrollContainer = el.closest("[data-content-scroll]") || el.closest(".overflow-y-auto");
2019
+ if (scrollContainer) {
2020
+ const offset = el.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top + scrollContainer.scrollTop;
2021
+ scrollContainer.scrollTo({ top: offset - 16 });
2022
+ } else el.scrollIntoView();
2023
+ }
2024
+ return;
2025
+ }
2026
+ if (href.startsWith("http") || href.startsWith("mailto:")) return;
2027
+ if (onNavigate) {
2028
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
2029
+ e.preventDefault();
2030
+ const [pathPart, hashPart] = href.split("#");
2031
+ const parts = currentPath.split("/");
2032
+ parts.pop();
2033
+ const dir = parts.join("/");
2034
+ const cleanHref = pathPart.replace(/^\.\//, "");
2035
+ onNavigate((dir ? `${dir}/${cleanHref}` : cleanHref) + (hashPart ? `#${hashPart}` : ""));
2036
+ }
2037
+ }, [currentPath, onNavigate]);
2038
+ const resolveHref = (0, react.useCallback)((href) => {
2039
+ if (!href) return "";
2040
+ if (href.startsWith("http") || href.startsWith("mailto:") || href.startsWith("#") || href.startsWith("/")) return href;
2041
+ const [pathPart, hashPart] = href.split("#");
2042
+ const parts = currentPath.split("/");
2043
+ parts.pop();
2044
+ const dir = parts.join("/");
2045
+ const cleanHref = pathPart.replace(/^\.\//, "");
2046
+ return `${baseUrl}${dir ? `${dir}/${cleanHref}` : cleanHref}${hashPart ? `#${hashPart}` : ""}`;
2047
+ }, [currentPath, baseUrl]);
2048
+ const transformImageUri = (0, react.useCallback)((uri, key) => {
2049
+ if (key === "href") return uri;
2050
+ if (uri.startsWith("http") || uri.startsWith("data:")) return uri;
2051
+ const parts = currentPath.split("/");
2052
+ parts.pop();
2053
+ const dir = parts.join("/");
2054
+ const resolvedPath = dir ? `${dir}/${uri}` : uri;
2055
+ if (imageApiBase) return `${imageApiBase}/content?path=${encodeURIComponent(resolvedPath)}`;
2056
+ return `${baseUrl}${resolvedPath}`;
2057
+ }, [
2058
+ currentPath,
2059
+ baseUrl,
2060
+ imageApiBase
2061
+ ]);
2062
+ const markdownComponents = (0, react.useMemo)(() => ({
2063
+ a({ href, children, ...props }) {
2064
+ const resolvedHref = resolveHref(href);
2065
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2066
+ href: resolvedHref,
2067
+ onClick: (e) => href && handleLinkClick(e, href),
2068
+ ...props,
2069
+ children
2070
+ });
2071
+ },
2072
+ code(props) {
2073
+ const { children, className: codeClassName, ...rest } = props;
2074
+ const match = /language-(\w+)/.exec(codeClassName || "");
2075
+ if (match && match[1] === "mermaid") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MermaidDiagram, { code: String(children).replace(/\n$/, "") });
2076
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
2077
+ className: codeClassName,
2078
+ ...rest,
2079
+ children
2080
+ });
2081
+ }
2082
+ }), [handleLinkClick, resolveHref]);
2083
+ const proseClasses = [
2084
+ "prose prose-slate dark:prose-invert max-w-none",
2085
+ "prose-headings:font-semibold prose-headings:tracking-tight prose-headings:text-slate-900 dark:prose-headings:text-slate-100",
2086
+ "prose-h1:text-[2em] prose-h1:mb-3 prose-h1:pb-[0.3em] prose-h1:border-b prose-h1:border-slate-200 dark:prose-h1:border-slate-700",
2087
+ "prose-h2:text-[1.5em] prose-h2:mt-6 prose-h2:mb-3 prose-h2:pb-[0.3em] prose-h2:border-b prose-h2:border-slate-200 dark:prose-h2:border-slate-700",
2088
+ "prose-h3:text-[1.25em] prose-h3:mt-6 prose-h3:mb-2",
2089
+ "prose-h4:text-[1em] prose-h4:mt-6 prose-h4:mb-2",
2090
+ "prose-p:text-slate-700 dark:prose-p:text-slate-300 prose-p:leading-[1.5] prose-p:my-[16px]",
2091
+ "prose-ul:my-[16px] prose-ul:list-disc prose-li:my-0.5 prose-li:marker:text-slate-900 dark:prose-li:marker:text-slate-300",
2092
+ "prose-ol:my-[16px] prose-li:marker:text-slate-900 dark:prose-li:marker:text-slate-300",
2093
+ "prose-pre:bg-slate-50 dark:prose-pre:bg-slate-800 prose-pre:border prose-pre:border-slate-200 dark:prose-pre:border-slate-700 prose-pre:p-4 prose-pre:rounded-md prose-pre:text-[85%] prose-pre:leading-[1.45]",
2094
+ "prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-a:no-underline hover:prose-a:underline",
2095
+ "prose-img:rounded-lg prose-img:my-4",
2096
+ "prose-blockquote:border-l-[0.25em] prose-blockquote:border-slate-300 dark:prose-blockquote:border-slate-600 prose-blockquote:pl-4 prose-blockquote:text-slate-600 dark:prose-blockquote:text-slate-400 prose-blockquote:italic",
2097
+ "prose-code:before:content-none prose-code:after:content-none",
2098
+ "prose-code:bg-slate-100 dark:prose-code:bg-slate-800 prose-code:px-[0.4em] prose-code:py-[0.2em] prose-code:rounded-md prose-code:text-slate-800 dark:prose-code:text-slate-200 prose-code:font-mono prose-code:text-[85%] prose-code:font-normal prose-code:border prose-code:border-slate-200/50 dark:prose-code:border-slate-700/50",
2099
+ "prose-table:text-sm",
2100
+ "prose-th:px-3 prose-th:py-1.5 prose-th:border prose-th:border-slate-200 dark:prose-th:border-slate-700",
2101
+ "prose-td:px-3 prose-td:py-1.5 prose-td:border prose-td:border-slate-200 dark:prose-td:border-slate-700"
2102
+ ].join(" ");
2103
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2104
+ ref: containerRef,
2105
+ className: className ? `${proseClasses} ${className}` : proseClasses,
2106
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FrontmatterDisplay, { frontmatter }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_markdown.default, {
2107
+ remarkPlugins,
2108
+ rehypePlugins,
2109
+ urlTransform: transformImageUri,
2110
+ components: markdownComponents,
2111
+ children: body
2112
+ })]
2113
+ });
1737
2114
  };
1738
- var MarkdownViewer = react.memo(
1739
- MarkdownViewerInner,
1740
- (prevProps, nextProps) => prevProps.content === nextProps.content && prevProps.currentPath === nextProps.currentPath && prevProps.hash === nextProps.hash && prevProps.className === nextProps.className
1741
- );
2115
+ const MarkdownViewer = (0, react.memo)(MarkdownViewerInner, (prevProps, nextProps) => prevProps.content === nextProps.content && prevProps.currentPath === nextProps.currentPath && prevProps.hash === nextProps.hash && prevProps.className === nextProps.className);
2116
+ //#endregion
2117
+ //#region src/renderMarkdown.ts
2118
+ /**
2119
+ * Framework-agnostic markdown -> HTML rendering pipeline.
2120
+ * Uses the same remark/rehype chain as the Vantage viewer.
2121
+ */
2122
+ /**
2123
+ * Render a markdown string to HTML using the full Vantage pipeline.
2124
+ *
2125
+ * Features (all enabled by default):
2126
+ * - GitHub Flavored Markdown (tables, strikethrough, task lists)
2127
+ * - KaTeX math rendering, inline and block ($$...$$ only; single $ is not a delimiter)
2128
+ * - Syntax highlighting via highlight.js
2129
+ * - `data-source-line` attributes for line anchors
2130
+ * - XSS sanitization
2131
+ * - Heading slugs/anchors
2132
+ * - YAML/TOML frontmatter parsing
2133
+ *
2134
+ * Mermaid diagrams are NOT rendered server-side (they require a browser).
2135
+ * Mermaid code blocks are preserved as `<pre><code class="language-mermaid">`.
2136
+ * Use the React `<MarkdownViewer>` component for client-side mermaid rendering.
2137
+ */
1742
2138
  async function renderMarkdown(content, options = {}) {
1743
- const {
1744
- gfm = true,
1745
- math = true,
1746
- highlight = true,
1747
- sourceLines = true,
1748
- sanitize = true,
1749
- frontmatter: parseFm = true
1750
- } = options;
1751
- let parsed;
1752
- if (parseFm) {
1753
- parsed = parseFrontmatter(content);
1754
- } else {
1755
- parsed = {
1756
- frontmatter: {},
1757
- body: content,
1758
- format: "none",
1759
- bodyLineOffset: 0
1760
- };
1761
- }
1762
- const { remarkPlugins, rehypePlugins } = buildPipeline({
1763
- gfm,
1764
- math,
1765
- highlight,
1766
- sourceLines,
1767
- sanitize,
1768
- bodyLineOffset: parsed.bodyLineOffset
1769
- });
1770
- const processor = unified.unified().use(remarkParse__default.default).use(remarkPlugins).use(remarkRehype__default.default, { allowDangerousHtml: true }).use(rehypePlugins).use(rehypeStringify__default.default);
1771
- const result = await processor.process(parsed.body);
1772
- return {
1773
- html: String(result),
1774
- frontmatter: parsed.frontmatter,
1775
- body: parsed.body
1776
- };
2139
+ const { gfm = true, math = true, highlight = true, sourceLines = true, sanitize = true, frontmatter: parseFm = true } = options;
2140
+ let parsed;
2141
+ if (parseFm) parsed = parseFrontmatter(content);
2142
+ else parsed = {
2143
+ frontmatter: {},
2144
+ body: content,
2145
+ format: "none",
2146
+ bodyLineOffset: 0
2147
+ };
2148
+ const { remarkPlugins, rehypePlugins } = buildPipeline({
2149
+ gfm,
2150
+ math,
2151
+ highlight,
2152
+ sourceLines,
2153
+ sanitize,
2154
+ bodyLineOffset: parsed.bodyLineOffset
2155
+ });
2156
+ const result = await (0, unified.unified)().use(remark_parse.default).use(remarkPlugins).use(remark_rehype.default, { allowDangerousHtml: true }).use(rehypePlugins).use(rehype_stringify.default).process(parsed.body);
2157
+ return {
2158
+ html: String(result),
2159
+ frontmatter: parsed.frontmatter,
2160
+ body: parsed.body
2161
+ };
1777
2162
  }
1778
-
1779
- // src/renderMermaidBlocks.ts
2163
+ //#endregion
2164
+ //#region src/renderMermaidBlocks.ts
2165
+ /**
2166
+ * Client-side utility to find and render mermaid code blocks in a container.
2167
+ *
2168
+ * After calling `renderMarkdown()`, mermaid blocks come through as
2169
+ * `<pre><code class="language-mermaid">...</code></pre>`. This function
2170
+ * finds those blocks and replaces them with rendered SVG diagrams.
2171
+ *
2172
+ * Framework-agnostic — works in any browser environment.
2173
+ */
2174
+ /**
2175
+ * Find all `<pre><code class="language-mermaid">` blocks in a container
2176
+ * and replace them with rendered SVG diagrams.
2177
+ *
2178
+ * @param container - DOM element containing rendered markdown HTML
2179
+ * @param options - Optional configuration
2180
+ * @returns Promise that resolves when all diagrams are rendered
2181
+ *
2182
+ * @example
2183
+ * ```ts
2184
+ * import { renderMarkdown, renderMermaidBlocks } from "vantage-md";
2185
+ *
2186
+ * const { html } = await renderMarkdown(content);
2187
+ * container.innerHTML = html;
2188
+ * await renderMermaidBlocks(container);
2189
+ * ```
2190
+ */
1780
2191
  async function renderMermaidBlocks(container, options = {}) {
1781
- const { className = "mermaid", onError } = options;
1782
- const codeBlocks = container.querySelectorAll(
1783
- 'pre > code.language-mermaid, pre > code[class*="language-mermaid"]'
1784
- );
1785
- if (codeBlocks.length === 0) return;
1786
- const mermaid = await getMermaid();
1787
- const renderPromises = Array.from(codeBlocks).map(async (codeEl) => {
1788
- const preEl = codeEl.parentElement;
1789
- if (!preEl) return;
1790
- const code = codeEl.textContent || "";
1791
- if (!code.trim()) return;
1792
- const cached = svgCache.get(code);
1793
- if (cached) {
1794
- replaceWithSvg(preEl, cached, className);
1795
- return;
1796
- }
1797
- try {
1798
- let hash = 0;
1799
- for (let i = 0; i < code.length; i++) {
1800
- hash = (hash << 5) - hash + code.charCodeAt(i);
1801
- hash = hash & hash;
1802
- }
1803
- const id = `mermaid-${Math.abs(hash).toString(36)}-${Date.now()}`;
1804
- const { svg } = await mermaid.render(id, code);
1805
- svgCache.set(code, svg);
1806
- replaceWithSvg(preEl, svg, className);
1807
- } catch (err) {
1808
- if (onError) {
1809
- onError(code, err instanceof Error ? err : new Error(String(err)));
1810
- }
1811
- }
1812
- });
1813
- await Promise.all(renderPromises);
2192
+ const { className = "mermaid", onError } = options;
2193
+ const codeBlocks = container.querySelectorAll("pre > code.language-mermaid, pre > code[class*=\"language-mermaid\"]");
2194
+ if (codeBlocks.length === 0) return;
2195
+ const mermaid = await getMermaid();
2196
+ const renderPromises = Array.from(codeBlocks).map(async (codeEl) => {
2197
+ const preEl = codeEl.parentElement;
2198
+ if (!preEl) return;
2199
+ const code = codeEl.textContent || "";
2200
+ if (!code.trim()) return;
2201
+ const cached = svgCache.get(code);
2202
+ if (cached) {
2203
+ replaceWithSvg(preEl, cached, className);
2204
+ return;
2205
+ }
2206
+ try {
2207
+ let hash = 0;
2208
+ for (let i = 0; i < code.length; i++) {
2209
+ hash = (hash << 5) - hash + code.charCodeAt(i);
2210
+ hash = hash & hash;
2211
+ }
2212
+ const id = `mermaid-${Math.abs(hash).toString(36)}-${Date.now()}`;
2213
+ const { svg } = await mermaid.render(id, code);
2214
+ svgCache.set(code, svg);
2215
+ replaceWithSvg(preEl, svg, className);
2216
+ } catch (err) {
2217
+ if (onError) onError(code, err instanceof Error ? err : new Error(String(err)));
2218
+ }
2219
+ });
2220
+ await Promise.all(renderPromises);
1814
2221
  }
1815
2222
  function replaceWithSvg(preEl, svg, className) {
1816
- const wrapper = document.createElement("div");
1817
- wrapper.className = className;
1818
- wrapper.innerHTML = svg;
1819
- preEl.replaceWith(wrapper);
2223
+ const wrapper = document.createElement("div");
2224
+ wrapper.className = className;
2225
+ wrapper.innerHTML = svg;
2226
+ preEl.replaceWith(wrapper);
1820
2227
  }
1821
-
1822
- // src/resolveLinks.ts
2228
+ //#endregion
2229
+ //#region src/resolveLinks.ts
2230
+ /**
2231
+ * Rewrite relative links in rendered HTML.
2232
+ *
2233
+ * Processes all `href="..."` attributes, skipping:
2234
+ * - Absolute URLs (http://, https://, mailto:, etc.)
2235
+ * - Anchor-only links (#section)
2236
+ * - Already-absolute paths (/path/to/file)
2237
+ *
2238
+ * @example
2239
+ * ```ts
2240
+ * import { renderMarkdown, resolveLinks } from "vantage-md";
2241
+ *
2242
+ * const { html } = await renderMarkdown(content);
2243
+ *
2244
+ * // Simple: prepend a base path
2245
+ * const resolved = resolveLinks(html, { basePath: "/docs/", currentPath: "guides/setup.md" });
2246
+ *
2247
+ * // Custom: full control over link rewriting
2248
+ * const resolved = resolveLinks(html, {
2249
+ * currentPath: "guides/setup.md",
2250
+ * rewriter: (href, currentPath) => `/kb/${currentPath}/../${href}`,
2251
+ * });
2252
+ * ```
2253
+ */
1823
2254
  function resolveLinks(html, options = {}) {
1824
- const { basePath = "/", rewriter, currentPath = "" } = options;
1825
- const parts = currentPath.split("/");
1826
- parts.pop();
1827
- const currentDir = parts.join("/");
1828
- return html.replace(
1829
- /href="([^"]*?)"/g,
1830
- (_match, href) => {
1831
- if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("mailto:") || href.startsWith("data:") || href.startsWith("#") || href.startsWith("/")) {
1832
- return `href="${href}"`;
1833
- }
1834
- if (rewriter) {
1835
- const result = rewriter(href, currentPath);
1836
- if (result !== null) {
1837
- return `href="${result}"`;
1838
- }
1839
- return `href="${href}"`;
1840
- }
1841
- const [pathPart, hashPart] = href.split("#");
1842
- const cleanHref = pathPart.replace(/^\.\//, "");
1843
- const resolvedPath = currentDir ? `${currentDir}/${cleanHref}` : cleanHref;
1844
- const base = basePath.endsWith("/") ? basePath : `${basePath}/`;
1845
- const finalHref = `${base}${resolvedPath}${hashPart ? `#${hashPart}` : ""}`;
1846
- return `href="${finalHref}"`;
1847
- }
1848
- );
2255
+ const { basePath = "/", rewriter, currentPath = "" } = options;
2256
+ const parts = currentPath.split("/");
2257
+ parts.pop();
2258
+ const currentDir = parts.join("/");
2259
+ return html.replace(/href="([^"]*?)"/g, (_match, href) => {
2260
+ if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("mailto:") || href.startsWith("data:") || href.startsWith("#") || href.startsWith("/")) return `href="${href}"`;
2261
+ if (rewriter) {
2262
+ const result = rewriter(href, currentPath);
2263
+ if (result !== null) return `href="${result}"`;
2264
+ return `href="${href}"`;
2265
+ }
2266
+ const [pathPart, hashPart] = href.split("#");
2267
+ const cleanHref = pathPart.replace(/^\.\//, "");
2268
+ const resolvedPath = currentDir ? `${currentDir}/${cleanHref}` : cleanHref;
2269
+ return `href="${`${basePath.endsWith("/") ? basePath : `${basePath}/`}${resolvedPath}${hashPart ? `#${hashPart}` : ""}`}"`;
2270
+ });
1849
2271
  }
1850
-
2272
+ //#endregion
1851
2273
  exports.DOC_STATUSES = DOC_STATUSES;
1852
2274
  exports.DOC_STATUS_TONES = DOC_STATUS_TONES;
1853
2275
  exports.DocumentStatusChip = DocumentStatusChip;
@@ -1862,12 +2284,12 @@ exports.isDocStatus = isDocStatus;
1862
2284
  exports.parseFrontmatter = parseFrontmatter;
1863
2285
  exports.parseLineAnchor = parseLineAnchor;
1864
2286
  exports.readVantageFrontmatter = readVantageFrontmatter;
1865
- exports.rehypeSourceLines = rehypeSourceLines_default;
2287
+ exports.rehypeSourceLines = rehypeSourceLines;
1866
2288
  exports.renderMarkdown = renderMarkdown;
1867
2289
  exports.renderMermaidBlocks = renderMermaidBlocks;
1868
2290
  exports.resolveLinks = resolveLinks;
1869
2291
  exports.sanitizeSchema = sanitizeSchema;
1870
2292
  exports.scrollToLineAnchor = scrollToLineAnchor;
1871
2293
  exports.useLineAnchor = useLineAnchor;
1872
- //# sourceMappingURL=react.cjs.map
2294
+
1873
2295
  //# sourceMappingURL=react.cjs.map