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/index.js CHANGED
@@ -1,1025 +1,1582 @@
1
- import { unified } from 'unified';
2
- import remarkParse from 'remark-parse';
3
- import remarkRehype from 'remark-rehype';
4
- import rehypeStringify from 'rehype-stringify';
5
- import remarkGfm from 'remark-gfm';
6
- import remarkMath from 'remark-math';
7
- import rehypeRaw from 'rehype-raw';
8
- import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
9
- import rehypeHighlight from 'rehype-highlight';
10
- import rehypeKatex from 'rehype-katex';
11
- import rehypeSlug from 'rehype-slug';
12
- import YAML from 'yaml';
13
- import { parse } from 'smol-toml';
14
-
15
- // src/renderMarkdown.ts
16
-
17
- // src/rehypeSourceLines.ts
18
- var BLOCK_TAGS = /* @__PURE__ */ new Set([
19
- "p",
20
- "h1",
21
- "h2",
22
- "h3",
23
- "h4",
24
- "h5",
25
- "h6",
26
- "li",
27
- "blockquote",
28
- "pre",
29
- "table",
30
- "tr",
31
- "ul",
32
- "ol",
33
- "hr",
34
- "div"
1
+ import { unified } from "unified";
2
+ import remarkParse from "remark-parse";
3
+ import remarkRehype from "remark-rehype";
4
+ import rehypeStringify from "rehype-stringify";
5
+ import remarkGfm from "remark-gfm";
6
+ import remarkMath from "remark-math";
7
+ import rehypeRaw from "rehype-raw";
8
+ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
9
+ import rehypeHighlight from "rehype-highlight";
10
+ import rehypeKatex from "rehype-katex";
11
+ import rehypeSlug from "rehype-slug";
12
+ import { visit } from "unist-util-visit";
13
+ import YAML from "yaml";
14
+ import { parse } from "smol-toml";
15
+ //#region src/rehypeSourceLines.ts
16
+ const BLOCK_TAGS = /* @__PURE__ */ new Set([
17
+ "p",
18
+ "h1",
19
+ "h2",
20
+ "h3",
21
+ "h4",
22
+ "h5",
23
+ "h6",
24
+ "li",
25
+ "blockquote",
26
+ "pre",
27
+ "table",
28
+ "tr",
29
+ "ul",
30
+ "ol",
31
+ "hr",
32
+ "div"
35
33
  ]);
36
- function visit(node, offset) {
37
- if ("children" in node) {
38
- for (const child of node.children) {
39
- if (child.type === "element") {
40
- if (BLOCK_TAGS.has(child.tagName) && child.position?.start?.line) {
41
- child.properties = child.properties || {};
42
- child.properties["dataSourceLine"] = child.position.start.line + offset;
43
- }
44
- visit(child, offset);
45
- }
46
- }
47
- }
34
+ function visit$1(node, offset) {
35
+ if ("children" in node) {
36
+ for (const child of node.children) if (child.type === "element") {
37
+ if (BLOCK_TAGS.has(child.tagName) && child.position?.start?.line) {
38
+ child.properties = child.properties || {};
39
+ child.properties["dataSourceLine"] = child.position.start.line + offset;
40
+ }
41
+ visit$1(child, offset);
42
+ }
43
+ }
48
44
  }
49
- var rehypeSourceLines = (options) => {
50
- const offset = options?.offset ?? 0;
51
- return (tree) => {
52
- visit(tree, offset);
53
- };
45
+ const rehypeSourceLines = (options) => {
46
+ const offset = options?.offset ?? 0;
47
+ return (tree) => {
48
+ visit$1(tree, offset);
49
+ };
54
50
  };
55
- var rehypeSourceLines_default = rehypeSourceLines;
56
-
57
- // src/vantageDirectives.ts
58
- var VANTAGE_SENTINEL = "vantage:";
59
- var DIRECTIVE_NAMES = ["section", "block", "oq"];
60
- var VANTAGE_TONES = [
61
- "note",
62
- "tip",
63
- "important",
64
- "warning",
65
- "caution",
66
- "muted"
51
+ //#endregion
52
+ //#region src/rehypeVantageAlerts.ts
53
+ /**
54
+ * GFM alerts — `> [!WARNING]` — compiled into `data-vantage-alert`.
55
+ *
56
+ * `remark-gfm` does not implement alerts, so until this plugin existed a
57
+ * `> [!WARNING]` rendered as an ordinary blockquote with the literal marker
58
+ * visible as its first words. Worse than merely unstyled: `@tailwindcss/typography`
59
+ * italicises blockquotes and draws `open-quote`/`close-quote` around the first
60
+ * paragraph, so a callout came out as an italic *quotation* whose opening words
61
+ * were `"[!WARNING]`. That was the "Known gaps" entry in
62
+ * `docs/reference/inline-markup.md` and OQ-10, filed rather than fixed, while
63
+ * `styleGuide.ts` went on telling every agent to write them.
64
+ *
65
+ * The tokens are deliberately the ones the `tone` vocabulary already resolves —
66
+ * an alert *is* the six-colour light/dark treatment `tone` shipped, which is
67
+ * exactly what the gap entry said whoever fixed this should do rather than
68
+ * building a second palette. `[!WARNING]` and `<!-- vantage: block tone=warning -->`
69
+ * therefore agree by construction, and adding a theme still touches one
70
+ * custom-property block.
71
+ *
72
+ * **This runs in the shared pipeline, so all four renderers get it** — the live
73
+ * viewer, the package's exported viewer, the static export and the CLI checker's
74
+ * `renderMarkdown`. That is what makes an injected title element acceptable here
75
+ * where the collapse caret's glyph had to be drawn in CSS: the caret is injected
76
+ * by app JS that may never run, and this is not (D5).
77
+ *
78
+ * ## What it does not do
79
+ *
80
+ * It does not touch a blockquote that carries no marker, and an unrecognised
81
+ * marker (`[!HINT]`) is left exactly as it was — visible literal text, which is
82
+ * the honest rendering of something GitHub also would not style. Silently
83
+ * swallowing it would hide a typo that reads as a callout on neither renderer.
84
+ */
85
+ /**
86
+ * The five GFM alert kinds, lowercased.
87
+ *
88
+ * Deliberately *not* re-derived from `VANTAGE_TONES`: that list carries a sixth
89
+ * token, `muted`, which is ours and is not an alert word. The overlap is the
90
+ * point — the five that coincide share a palette — but the two vocabularies are
91
+ * closed by different authorities and a change to one must not silently move the
92
+ * other. A test asserts the five are a subset of the tones.
93
+ */
94
+ const VANTAGE_ALERTS = [
95
+ "note",
96
+ "tip",
97
+ "important",
98
+ "warning",
99
+ "caution"
100
+ ];
101
+ /** The visible label per kind. Title case, as GitHub renders it. */
102
+ const ALERT_TITLES = {
103
+ note: "Note",
104
+ tip: "Tip",
105
+ important: "Important",
106
+ warning: "Warning",
107
+ caution: "Caution"
108
+ };
109
+ /**
110
+ * The marker, anchored and requiring the rest of its line to be empty.
111
+ *
112
+ * GFM puts the marker alone on the blockquote's first line, and holding to that
113
+ * is what keeps a paragraph that merely *begins* with bracketed text from being
114
+ * eaten. The trailing newline is optional only for the degenerate blockquote
115
+ * whose entire content is the marker.
116
+ *
117
+ * Measured against the real chain rather than assumed: `remark-parse` reads
118
+ * `[!TIP]` as a shortcut link reference, and because no definition matches,
119
+ * `mdast-util-to-hast` puts it back as **one** leading text node —
120
+ * `"[!TIP]\nThe generalization: "` — not as a `[`/label/`]` triple. So a single
121
+ * anchored test on the first text node is enough, and the plugin does not have
122
+ * to reassemble the marker across siblings.
123
+ */
124
+ const MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/;
125
+ /** The first child, if it is an element. */
126
+ function firstElement(node) {
127
+ const child = node.children.find((c) => c.type === "element" || c.type === "text" && c.value.trim() !== "");
128
+ return child?.type === "element" ? child : void 0;
129
+ }
130
+ /**
131
+ * Compile `> [!KIND]` blockquotes into `data-vantage-alert="kind"`.
132
+ *
133
+ * Order in the chain matters twice, and both are stated in `pipeline.ts`:
134
+ *
135
+ * - **after `rehypeSourceLines`**, so the injected title carries no
136
+ * `data-source-line`. That is what keeps it out of `anchorBlockWithin`, which
137
+ * filters candidates to those with a finite line — otherwise a review comment
138
+ * on an alert would anchor to the word "Warning" instead of to the prose.
139
+ * - **before `rehypeSanitize`**, so nothing reaches the DOM the schema has not
140
+ * passed. `dataVantageAlert` is allowlisted there by name *and* value, like
141
+ * every other `data-vantage-*` attribute.
142
+ */
143
+ function rehypeVantageAlerts() {
144
+ return (tree) => {
145
+ visit(tree, "element", (node) => {
146
+ if (node.tagName !== "blockquote") return;
147
+ const paragraph = firstElement(node);
148
+ if (paragraph === void 0 || paragraph.tagName !== "p") return;
149
+ const lead = paragraph.children[0];
150
+ if (lead === void 0 || lead.type !== "text") return;
151
+ const match = MARKER.exec(lead.value);
152
+ if (match === null) return;
153
+ const kind = match[1].toLowerCase();
154
+ lead.value = lead.value.slice(match[0].length);
155
+ if (lead.value === "" && paragraph.children.length === 1) node.children = node.children.filter((c) => c !== paragraph);
156
+ node.properties = {
157
+ ...node.properties,
158
+ dataVantageAlert: kind
159
+ };
160
+ node.children.unshift({
161
+ type: "element",
162
+ tagName: "div",
163
+ properties: { className: ["vantage-alert-title"] },
164
+ children: [{
165
+ type: "text",
166
+ value: ALERT_TITLES[kind]
167
+ }]
168
+ });
169
+ });
170
+ };
171
+ }
172
+ //#endregion
173
+ //#region src/vantageDirectives.ts
174
+ /**
175
+ * The directive grammar and the closed vocabulary — one parser, no renderer.
176
+ *
177
+ * A Vantage directive is an ordinary HTML comment carrying a `vantage:`
178
+ * sentinel: `<!-- vantage: section tone=warning -->`. GitHub drops it, every
179
+ * other Markdown renderer drops it, and Vantage compiles it into
180
+ * `data-vantage-*` attributes on the block that follows
181
+ * (`rehypeVantageDirectives`). See `docs/reference/inline-markup.md`, "The carrier and the grammar".
182
+ *
183
+ * This module is deliberately **zero-dependency — not even a type import**, and
184
+ * it knows nothing about hast. Two callers need it and only one of them has a
185
+ * tree: the rehype plugin stamps attributes, and the `vantage-check` CLI
186
+ * validates directives with no rendering at all, importing this file by
187
+ * relative path. A checker with its own copy of the grammar is a checker that
188
+ * disagrees with the renderer, which is the failure D5 names.
189
+ *
190
+ * Everything here is a pure function of a string. Nothing throws, nothing logs
191
+ * (P3): a comment that is not a directive is `null`, and a comment that carries
192
+ * the sentinel but does not parse is `malformed` with a reason only the checker
193
+ * reads.
194
+ */
195
+ /**
196
+ * The mandatory sentinel — the full word, never a terser `v:`.
197
+ *
198
+ * It is what keeps an ordinary `<!-- TODO: rewrite this -->` from being parsed
199
+ * as markup, and it makes the common case a prefix test rather than a grammar
200
+ * attempt (Ledger OQ-1).
201
+ */
202
+ const VANTAGE_SENTINEL = "vantage:";
203
+ /**
204
+ * The closed name set. An unknown name drops the **whole** directive: there is
205
+ * no target semantics without a name. An unknown key or value drops only that
206
+ * pair (D2 is per-key).
207
+ *
208
+ * Position picks the target; the name picks the extent. `section` before a
209
+ * heading reaches the heading's whole section, `block` reaches one block, and
210
+ * `oq` marks one answerable question. The name cannot disagree with position —
211
+ * it only says how far the stamp reaches — so §4.2's refusal of a `scope=` key
212
+ * stands.
213
+ */
214
+ const DIRECTIVE_NAMES = [
215
+ "section",
216
+ "block",
217
+ "oq"
218
+ ];
219
+ /**
220
+ * The `tone` vocabulary: GitHub's alert words plus `muted`.
221
+ *
222
+ * Semantic, never chromatic (P2, Ledger OQ-3). A document says what a section
223
+ * *is*; the theme decides what that looks like, which is what lets one document
224
+ * render correctly in light, in dark, and in themes that do not exist yet.
225
+ */
226
+ const VANTAGE_TONES = [
227
+ "note",
228
+ "tip",
229
+ "important",
230
+ "warning",
231
+ "caution",
232
+ "muted"
233
+ ];
234
+ /** How much the block should pull the eye — separate from `tone` on purpose. */
235
+ const VANTAGE_EMPHASIS = [
236
+ "strong",
237
+ "normal",
238
+ "quiet"
67
239
  ];
68
- var VANTAGE_EMPHASIS = ["strong", "normal", "quiet"];
69
- var VANTAGE_BADGES = [
70
- "draft",
71
- "stale",
72
- "blocked",
73
- "done",
74
- "wip"
240
+ /** A small chip beside the heading. */
241
+ const VANTAGE_BADGES = [
242
+ "draft",
243
+ "stale",
244
+ "blocked",
245
+ "done",
246
+ "wip"
75
247
  ];
76
- var VANTAGE_COLLAPSED = ["true", "false"];
77
- var VANTAGE_RUNS = ["start", "middle", "end", "only"];
78
- var VANTAGE_STYLE_TARGETS = [
79
- "p",
80
- "h1",
81
- "h2",
82
- "h3",
83
- "h4",
84
- "h5",
85
- "h6",
86
- "li",
87
- "blockquote",
88
- "pre",
89
- "table",
90
- "tr",
91
- "ul",
92
- "ol",
93
- "hr",
94
- "div"
248
+ /**
249
+ * `collapsed` is a token, not a flag: `false` is the default written down.
250
+ *
251
+ * It stamps nothing on its own. Its one real effect is overriding a
252
+ * `collapsed=true` earlier in the same merged directive run — last key wins — so
253
+ * it is in the vocabulary rather than being an unknown value that drops. It
254
+ * cannot cancel an *enclosing* collapsed section: a nested heading is a hidden
255
+ * member of the outer group by design (A3), and the outer run is stamped before
256
+ * any inner directive has been resolved.
257
+ */
258
+ const VANTAGE_COLLAPSED = ["true", "false"];
259
+ /**
260
+ * Where a block sits in a stamped run, so section-wide CSS can join its members
261
+ * without an adjacent-sibling combinator.
262
+ *
263
+ * Not cosmetic. Review mode inserts comment cards as siblings *inside* a
264
+ * stamped run (`useReviewHighlights`), so `[tone] + [tone]` severs at every
265
+ * commented paragraph and bleeds across the boundary between two adjacent runs
266
+ * of different tone. An attribute survives both.
267
+ */
268
+ const VANTAGE_RUNS = [
269
+ "start",
270
+ "middle",
271
+ "end",
272
+ "only"
95
273
  ];
96
- var VANTAGE_ANCHOR_TARGETS = [
97
- "p",
98
- "h1",
99
- "h2",
100
- "h3",
101
- "h4",
102
- "h5",
103
- "h6",
104
- "li",
105
- "blockquote",
106
- "pre",
107
- "table"
274
+ /**
275
+ * The tags a `section`/`block` directive may stamp.
276
+ *
277
+ * Deliberately `rehypeSourceLines`'s `BLOCK_TAGS`: a stamped block should also
278
+ * be a block with a `data-source-line`, so the styling surface and the anchor
279
+ * surface coincide. It also keeps an inline directive from stamping the `<em>`
280
+ * that happens to follow it inside a paragraph.
281
+ *
282
+ * It lives here rather than in the plugin because the CLI checker has to answer
283
+ * "will this directive stamp anything?" from an mdast tree with no hast in
284
+ * sight. A checker with its own copy of this list is a checker that calls a
285
+ * working directive an orphan, or stays silent about a dead one (D5).
286
+ */
287
+ const VANTAGE_STYLE_TARGETS = [
288
+ "p",
289
+ "h1",
290
+ "h2",
291
+ "h3",
292
+ "h4",
293
+ "h5",
294
+ "h6",
295
+ "li",
296
+ "blockquote",
297
+ "pre",
298
+ "table",
299
+ "tr",
300
+ "ul",
301
+ "ol",
302
+ "hr",
303
+ "div"
108
304
  ];
109
- var VANTAGE_OQ_HOST_TARGETS = VANTAGE_ANCHOR_TARGETS.filter(
110
- (tag) => tag !== "pre" && tag !== "table"
111
- );
112
- var STYLE_KEYS = {
113
- tone: VANTAGE_TONES,
114
- emphasis: VANTAGE_EMPHASIS,
115
- badge: VANTAGE_BADGES,
116
- collapsed: VANTAGE_COLLAPSED
305
+ /**
306
+ * The tags an `oq` directive may stamp — strictly the tags the review system
307
+ * can resolve an anchor on (`ANCHOR_TAGS` in the app's `MarkdownViewer`, and the
308
+ * block map in `useReviewHighlights`). `ul`, `ol`, `tr`, `hr` and `div` are in
309
+ * neither, so a button on one of them would build an anchor no review pass can
310
+ * find — the "mis-wired button" D6 forbids.
311
+ *
312
+ * The gap between this list and `VANTAGE_STYLE_TARGETS` is why an `oq`
313
+ * directive at column 0 above a list silently does nothing: the target is the
314
+ * `<ul>`, not the `<li>`. The checker says so.
315
+ */
316
+ const VANTAGE_ANCHOR_TARGETS = [
317
+ "p",
318
+ "h1",
319
+ "h2",
320
+ "h3",
321
+ "h4",
322
+ "h5",
323
+ "h6",
324
+ "li",
325
+ "blockquote",
326
+ "pre",
327
+ "table"
328
+ ];
329
+ /**
330
+ * The tags a `<!-- vantage: oq … -->` directive actually yields a *button* on —
331
+ * `VANTAGE_ANCHOR_TARGETS` minus `pre` and `table`, written as an explicit
332
+ * subtraction so the narrowing stays visible.
333
+ *
334
+ * Anchorable and button-hosting are different questions, and this is the second
335
+ * one. A comment *can* be anchored on a `<pre>` or a `<table>` — both are in
336
+ * `ANCHOR_TAGS` — but neither can hold the affordance: inside a `<pre>` the
337
+ * button renders as part of the code, and a `<button>` child of `<table>` is not
338
+ * valid HTML at all, so the parser hoists it out.
339
+ *
340
+ * Both consumers read it from here: `OQ_HOST_TAGS` in the app's
341
+ * `useOpenQuestionButtons`, and the `oq` branch of the checker's
342
+ * `vantage/orphan`. They were two hand-written lists that disagreed — the
343
+ * checker called an `oq` above a fence fine while the app rendered no button
344
+ * and said nothing, which is the D5 break this module exists to prevent.
345
+ */
346
+ const VANTAGE_OQ_HOST_TARGETS = VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
347
+ const STYLE_KEYS = {
348
+ tone: VANTAGE_TONES,
349
+ emphasis: VANTAGE_EMPHASIS,
350
+ badge: VANTAGE_BADGES,
351
+ collapsed: VANTAGE_COLLAPSED
117
352
  };
118
- var DIRECTIVE_VOCABULARY = {
119
- section: STYLE_KEYS,
120
- block: STYLE_KEYS,
121
- oq: { id: null, leaning: null }
353
+ /**
354
+ * Name → key → the closed value set for that key.
355
+ *
356
+ * `section` and `block` share their keys: they differ in *extent*, not in what
357
+ * they can say. `oq`'s two keys are the design's only values with no closed set
358
+ * — `id` is a token an author chose and `leaning` is a sentence (§8.3) — so
359
+ * neither can be value-allowlisted, which is recorded here as `null` rather
360
+ * than left to a caller to guess.
361
+ */
362
+ const DIRECTIVE_VOCABULARY = {
363
+ section: STYLE_KEYS,
364
+ block: STYLE_KEYS,
365
+ oq: {
366
+ id: null,
367
+ leaning: null
368
+ }
122
369
  };
123
- var WS = /[ \t\r\n]*/y;
124
- var SENTINEL_PREFIX = /^[ \t\r\n]*vantage:/;
125
- var NAME = /[a-z][a-z0-9-]*/y;
126
- var UNQUOTED = /[A-Za-z0-9_.:#-]+/y;
127
- var QUOTED = /"[^"]*"/y;
370
+ /**
371
+ * `ws` is `[ \t\r\n]` — the design's grammar leaves it undefined, and `\n` has
372
+ * to be in the set because a directive may legally wrap: a multi-line comment
373
+ * is one node whose value contains the newlines.
374
+ */
375
+ const WS = /[ \t\r\n]*/y;
376
+ const SENTINEL_PREFIX = /^[ \t\r\n]*vantage:/;
377
+ const NAME = /[a-z][a-z0-9-]*/y;
378
+ const UNQUOTED = /[A-Za-z0-9_.:#-]+/y;
379
+ /**
380
+ * A quoted value holds anything but a `"`, `--` included: measured through the
381
+ * real chain, `leaning="a--b"` reaches the tree intact, because HTML5 closes a
382
+ * comment on `-->` or `--!>` and on nothing else. There is deliberately **no**
383
+ * `--` restriction here. What a quoted value cannot hold is a terminator: a
384
+ * `-->` inside one ends the comment early and spills the tail into the document
385
+ * as literal text, which is a finding for the checker rather than a rule here —
386
+ * by the time this function runs, the truncation has already happened.
387
+ */
388
+ const QUOTED = /"[^"]*"/y;
389
+ /**
390
+ * The cheap prefix test. Runs first on every comment in every document, so an
391
+ * ordinary editorial comment never reaches the tokenizer.
392
+ *
393
+ * Note `<!--- vantage: x -->` is *not* a directive: its inner text begins with
394
+ * the extra `-`, and the sentinel must be the first thing in the comment.
395
+ */
128
396
  function hasVantageSentinel(comment) {
129
- return SENTINEL_PREFIX.test(comment);
397
+ return SENTINEL_PREFIX.test(comment);
130
398
  }
399
+ /** The whole non-whitespace run at `offset`, capped, for a quotable message. */
131
400
  function token(comment, offset) {
132
- const rest = comment.slice(offset);
133
- const end = rest.search(/[ \t\r\n]/);
134
- const word = end === -1 ? rest : rest.slice(0, end);
135
- return word.length > 24 ? `${word.slice(0, 24)}\u2026` : word;
401
+ const rest = comment.slice(offset);
402
+ const end = rest.search(/[ \t\r\n]/);
403
+ const word = end === -1 ? rest : rest.slice(0, end);
404
+ return word.length > 24 ? `${word.slice(0, 24)}…` : word;
136
405
  }
406
+ /** The sticky match at `offset`, or `null` if the pattern does not apply. */
137
407
  function matchAt(pattern, comment, offset) {
138
- pattern.lastIndex = offset;
139
- const match = pattern.exec(comment);
140
- return match === null ? null : match[0];
408
+ pattern.lastIndex = offset;
409
+ const match = pattern.exec(comment);
410
+ return match === null ? null : match[0];
141
411
  }
412
+ /** How much whitespace sits at `offset`. `WS` matches everywhere, empty. */
142
413
  function skipWhitespace(comment, offset) {
143
- return matchAt(WS, comment, offset)?.length ?? 0;
414
+ return matchAt(WS, comment, offset)?.length ?? 0;
144
415
  }
145
416
  function malformed(reason, offset) {
146
- return { kind: "malformed", reason, offset };
417
+ return {
418
+ kind: "malformed",
419
+ reason,
420
+ offset
421
+ };
147
422
  }
423
+ /**
424
+ * Parse one comment's **inner** text — the value of a hast `comment` node, with
425
+ * `<!--` and `-->` already stripped. `null` means "no sentinel, not ours".
426
+ *
427
+ * Hand-rolled rather than one regular expression, because a repeated capture
428
+ * group keeps only its last match and the checker needs an offset per token to
429
+ * point at the character that broke.
430
+ */
148
431
  function parseVantageDirective(comment) {
149
- const sentinel = SENTINEL_PREFIX.exec(comment);
150
- if (sentinel === null) return null;
151
- let at = sentinel[0].length;
152
- at += skipWhitespace(comment, at);
153
- const nameOffset = at;
154
- const name = matchAt(NAME, comment, at);
155
- if (name === null) {
156
- return malformed("no directive name after `vantage:`", at);
157
- }
158
- at += name.length;
159
- const pairs = [];
160
- while (at < comment.length) {
161
- const gap = skipWhitespace(comment, at);
162
- at += gap;
163
- if (at >= comment.length) break;
164
- if (gap === 0) {
165
- return malformed(`\`${token(comment, at)}\` needs a space before it`, at);
166
- }
167
- const keyOffset = at;
168
- const key = matchAt(NAME, comment, at);
169
- if (key === null) {
170
- return malformed(
171
- `\`${token(comment, at)}\` is not a \`key=value\` pair`,
172
- at
173
- );
174
- }
175
- at += key.length;
176
- if (comment[at] !== "=") {
177
- return malformed(`\`${key}\` is not followed by \`=value\``, at);
178
- }
179
- at += 1;
180
- const valueOffset = at;
181
- const quoted = matchAt(QUOTED, comment, at);
182
- if (quoted !== null) {
183
- at += quoted.length;
184
- pairs.push({
185
- key,
186
- value: quoted.slice(1, -1),
187
- keyOffset,
188
- valueOffset,
189
- quoted: true
190
- });
191
- continue;
192
- }
193
- const unquoted = matchAt(UNQUOTED, comment, at);
194
- if (unquoted === null) {
195
- const found = token(comment, at);
196
- return malformed(
197
- found === "" ? `\`${key}=\` has no value` : `\`${found}\` is not a valid value for \`${key}\``,
198
- at
199
- );
200
- }
201
- at += unquoted.length;
202
- pairs.push({ key, value: unquoted, keyOffset, valueOffset, quoted: false });
203
- }
204
- return { kind: "directive", name, nameOffset, pairs };
432
+ const sentinel = SENTINEL_PREFIX.exec(comment);
433
+ if (sentinel === null) return null;
434
+ let at = sentinel[0].length;
435
+ at += skipWhitespace(comment, at);
436
+ const nameOffset = at;
437
+ const name = matchAt(NAME, comment, at);
438
+ if (name === null) return malformed("no directive name after `vantage:`", at);
439
+ at += name.length;
440
+ const pairs = [];
441
+ while (at < comment.length) {
442
+ const gap = skipWhitespace(comment, at);
443
+ at += gap;
444
+ if (at >= comment.length) break;
445
+ if (gap === 0) return malformed(`\`${token(comment, at)}\` needs a space before it`, at);
446
+ const keyOffset = at;
447
+ const key = matchAt(NAME, comment, at);
448
+ if (key === null) return malformed(`\`${token(comment, at)}\` is not a \`key=value\` pair`, at);
449
+ at += key.length;
450
+ if (comment[at] !== "=") return malformed(`\`${key}\` is not followed by \`=value\``, at);
451
+ at += 1;
452
+ const valueOffset = at;
453
+ const quoted = matchAt(QUOTED, comment, at);
454
+ if (quoted !== null) {
455
+ at += quoted.length;
456
+ pairs.push({
457
+ key,
458
+ value: quoted.slice(1, -1),
459
+ keyOffset,
460
+ valueOffset,
461
+ quoted: true
462
+ });
463
+ continue;
464
+ }
465
+ const unquoted = matchAt(UNQUOTED, comment, at);
466
+ if (unquoted === null) {
467
+ const found = token(comment, at);
468
+ return malformed(found === "" ? `\`${key}=\` has no value` : `\`${found}\` is not a valid value for \`${key}\``, at);
469
+ }
470
+ at += unquoted.length;
471
+ pairs.push({
472
+ key,
473
+ value: unquoted,
474
+ keyOffset,
475
+ valueOffset,
476
+ quoted: false
477
+ });
478
+ }
479
+ return {
480
+ kind: "directive",
481
+ name,
482
+ nameOffset,
483
+ pairs
484
+ };
205
485
  }
206
-
207
- // src/rehypeVantageDirectives.ts
208
- var STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
209
- var ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
210
- var HEADING_DEPTHS = /* @__PURE__ */ new Map([
211
- ["h1", 1],
212
- ["h2", 2],
213
- ["h3", 3],
214
- ["h4", 4],
215
- ["h5", 5],
216
- ["h6", 6]
217
- ]);
218
- var RANGE_PROPERTIES = /* @__PURE__ */ new Map([
219
- ["tone", "dataVantageTone"],
220
- ["emphasis", "dataVantageEmphasis"]
486
+ //#endregion
487
+ //#region src/rehypeVantageDirectives.ts
488
+ /**
489
+ * What a `section`/`block` and an `oq` directive may stamp.
490
+ *
491
+ * Both lists live in `vantageDirectives.ts`, with the reasoning for each tag,
492
+ * because the CLI checker resolves the same question over mdast and must reach
493
+ * the same answer (D5).
494
+ */
495
+ const STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
496
+ const ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
497
+ const HEADING_DEPTHS = /* @__PURE__ */ new Map([
498
+ ["h1", 1],
499
+ ["h2", 2],
500
+ ["h3", 3],
501
+ ["h4", 4],
502
+ ["h5", 5],
503
+ ["h6", 6]
221
504
  ]);
222
- var POINT_PROPERTIES = /* @__PURE__ */ new Map([["badge", "dataVantageBadge"]]);
223
- var RUN_PROPERTY = "dataVantageRun";
224
- var OQ_PROPERTY = "dataVantageOq";
225
- var LEANING_PROPERTY = "dataVantageLeaning";
226
- var COLLAPSED_PROPERTY = "dataVantageCollapsed";
227
- var COLLAPSE_GROUP_PROPERTY = "dataVantageCollapseGroup";
228
- var COLLAPSE_TOGGLE_PROPERTY = "dataVantageCollapseToggle";
229
- var MAX_LEANING = 500;
505
+ /**
506
+ * Key → hast property, for the keys that treat a whole run.
507
+ *
508
+ * A camelCase hast property serialises to the kebab-case attribute, so
509
+ * `dataVantageTone` is `data-vantage-tone` in every renderer.
510
+ *
511
+ * `tone` and `emphasis` describe what a section *is* and how loud it is, so
512
+ * every block in the range wears them: the tone rule is a slice of one
513
+ * continuous line down the section, and the weight applies to all of its prose.
514
+ *
515
+ * `collapsed` is not here because it is not one property on one block: it puts a
516
+ * toggle on the heading and a collapsed flag plus a group id on every block the
517
+ * heading hides, which `stampStyle` does with the three properties below.
518
+ */
519
+ const RANGE_PROPERTIES = /* @__PURE__ */ new Map([["tone", "dataVantageTone"], ["emphasis", "dataVantageEmphasis"]]);
520
+ /**
521
+ * Key → hast property, for the keys that mark one block: the directive's target.
522
+ *
523
+ * `badge` is the asymmetry in the vocabulary and the reason this second map
524
+ * exists. It is not a treatment of a run but a single chip — "a small chip after
525
+ * the heading text" (§4.3), drawn as `[data-vantage-badge]::after` — so a
526
+ * section-wide stamp paints the word once per paragraph, list, table and fence
527
+ * under the heading instead of once beside it.
528
+ *
529
+ * The chip is fixed here rather than in the stylesheet, because narrowing the
530
+ * CSS to `:is(h1, …, h6)` would silently draw nothing for the two placements
531
+ * that legitimately badge a non-heading — `block badge=…` on a paragraph, and a
532
+ * `section` that degraded onto one (A1) — and would leave an attribute stamped
533
+ * on every block that says something untrue about it.
534
+ */
535
+ const POINT_PROPERTIES = /* @__PURE__ */ new Map([["badge", "dataVantageBadge"]]);
536
+ const RUN_PROPERTY = "dataVantageRun";
537
+ const OQ_PROPERTY = "dataVantageOq";
538
+ const LEANING_PROPERTY = "dataVantageLeaning";
539
+ /**
540
+ * The three properties `collapsed=true` stamps across a section.
541
+ *
542
+ * The heading takes a *different* attribute from the blocks it hides, and that
543
+ * asymmetry is the whole design (A3): a nested `###` inside a collapsed `##` is
544
+ * both a hidden member of the outer group and the toggle for its own, so one
545
+ * shared attribute would make it permanently invisible and unreachable by
546
+ * either toggle. There is no `<details>` and no wrapper — the run stays a flat
547
+ * list of siblings, which is what keeps review comment cards, the typography
548
+ * plugin's `h2 + *` margin resets and the anchor surface working.
549
+ *
550
+ * Hiding is CSS, and that CSS is gated on two markers the toggle JS sets — the
551
+ * prose container's readiness, and an armed marker on each block whose group it
552
+ * gave a caret (`docs/reference/inline-markup.md`, "Collapse without a wrapper"). A renderer without the JS
553
+ * — the CLI checker's HTML, an external consumer of this package — shows every
554
+ * block, and so does any block that ended up with no control.
555
+ */
556
+ const COLLAPSED_PROPERTY = "dataVantageCollapsed";
557
+ const COLLAPSE_GROUP_PROPERTY = "dataVantageCollapseGroup";
558
+ const COLLAPSE_TOGGLE_PROPERTY = "dataVantageCollapseToggle";
559
+ /** A review-comment body, not prose. Bounds the attribute; 500 is generous. */
560
+ const MAX_LEANING = 500;
561
+ /**
562
+ * Nodes that may sit between a directive and its target.
563
+ *
564
+ * A whitespace-only `text` node always does — measured, with or without a blank
565
+ * line in the source. Comments do too, and an unrelated `<!-- TODO -->` must not
566
+ * break the chain: it is invisible in every renderer and deleted by the
567
+ * sanitiser, so letting it change a directive's meaning would make behaviour
568
+ * depend on something no reader can see.
569
+ */
230
570
  function isSkippable(node) {
231
- if (node.type === "comment" || node.type === "doctype") return true;
232
- if (node.type === "text") return node.value.trim() === "";
233
- return false;
571
+ if (node.type === "comment" || node.type === "doctype") return true;
572
+ if (node.type === "text") return node.value.trim() === "";
573
+ return false;
234
574
  }
235
575
  function headingDepth(node) {
236
- if (node.type !== "element") return void 0;
237
- return HEADING_DEPTHS.get(node.tagName);
576
+ if (node.type !== "element") return void 0;
577
+ return HEADING_DEPTHS.get(node.tagName);
238
578
  }
239
579
  function setProperty(element, property, value) {
240
- element.properties = element.properties ?? {};
241
- element.properties[property] = value;
580
+ element.properties = element.properties ?? {};
581
+ element.properties[property] = value;
242
582
  }
583
+ /**
584
+ * Where a member sits in a stamped run: `only` for a lone block, otherwise
585
+ * `start`, `middle`, `end`. See `VANTAGE_RUNS`.
586
+ */
243
587
  function runValue(index, length) {
244
- if (length === 1) return "only";
245
- if (index === 0) return "start";
246
- return index === length - 1 ? "end" : "middle";
588
+ if (length === 1) return "only";
589
+ if (index === 0) return "start";
590
+ return index === length - 1 ? "end" : "middle";
247
591
  }
592
+ /** The closed value set for one key, or `undefined` when the key is unknown. */
248
593
  function vocabularyOf(name, key) {
249
- return DIRECTIVE_VOCABULARY[name]?.[key];
594
+ return DIRECTIVE_VOCABULARY[name]?.[key];
250
595
  }
251
596
  function accepts(name, key, value) {
252
- const values = vocabularyOf(name, key);
253
- if (values === void 0) return false;
254
- return values === null || values.includes(value);
597
+ const values = vocabularyOf(name, key);
598
+ if (values === void 0) return false;
599
+ return values === null || values.includes(value);
255
600
  }
601
+ /**
602
+ * The nodes one style directive reaches, as indexes into its own parent's
603
+ * children — never outside that array, so a directive inside a blockquote or a
604
+ * list item cannot stamp past it.
605
+ *
606
+ * Position picks the target (the next sibling element); the name picks how far
607
+ * the stamp goes. `section` before a heading takes the heading and every
608
+ * following sibling until the first heading of the same or shallower depth;
609
+ * `section` before anything else degrades to that one block, and `block` is
610
+ * always that one block. A heading nested inside a stamped `blockquote` or
611
+ * `li` does not end the section: the walk never descends.
612
+ */
256
613
  function styleRange(children, targetIndex, name) {
257
- const range = [targetIndex];
258
- const depth = name === "section" ? headingDepth(children[targetIndex]) : void 0;
259
- if (depth === void 0) return range;
260
- for (let i = targetIndex + 1; i < children.length; i++) {
261
- const node = children[i];
262
- const nodeDepth = headingDepth(node);
263
- if (nodeDepth !== void 0 && nodeDepth <= depth) break;
264
- if (node.type === "element" && STYLE_TARGET_TAGS.has(node.tagName)) {
265
- range.push(i);
266
- }
267
- }
268
- return range;
614
+ const range = [targetIndex];
615
+ const depth = name === "section" ? headingDepth(children[targetIndex]) : void 0;
616
+ if (depth === void 0) return range;
617
+ for (let i = targetIndex + 1; i < children.length; i++) {
618
+ const node = children[i];
619
+ const nodeDepth = headingDepth(node);
620
+ if (nodeDepth !== void 0 && nodeDepth <= depth) break;
621
+ if (node.type === "element" && STYLE_TARGET_TAGS.has(node.tagName)) range.push(i);
622
+ }
623
+ return range;
269
624
  }
625
+ /**
626
+ * Whether this directive collapses its section — three ways to say no.
627
+ *
628
+ * `collapsed=false` stamps nothing: it is the default written down, and "not
629
+ * collapsed" is not a thing an attribute can usefully say. Its only effect is
630
+ * upstream of here — `stampRun` merges a run of comments last-key-wins, so a
631
+ * `false` cancels a `true` in the *same* run. It does not cancel an *enclosing*
632
+ * section: `styleRange` walks the outer heading's whole sibling span before any
633
+ * inner directive is resolved, and a nested heading being a hidden member of the
634
+ * outer group is the design (A3), not an oversight.
635
+ *
636
+ * A `block` scope is **dropped**, and so is a `section` that degraded onto
637
+ * a non-heading, because both would hide a lone paragraph with nothing left
638
+ * behind to reveal it — content that is simply gone, which is the P1/D8 failure
639
+ * the readiness gate exists to prevent. Only a heading can be a summary.
640
+ */
270
641
  function collapsesSection(name, pairs, target) {
271
- if (name !== "section") return false;
272
- if (pairs.get("collapsed") !== "true") return false;
273
- return headingDepth(target) !== void 0;
642
+ if (name !== "section") return false;
643
+ if (pairs.get("collapsed") !== "true") return false;
644
+ return headingDepth(target) !== void 0;
274
645
  }
275
646
  function stampStyle(children, targetIndex, name, pairs, state) {
276
- const target = children[targetIndex];
277
- if (!STYLE_TARGET_TAGS.has(target.tagName)) return;
278
- const rangeStamps = [];
279
- const targetStamps = [];
280
- for (const [key, value] of pairs) {
281
- if (!accepts(name, key, value)) continue;
282
- const rangeProperty = RANGE_PROPERTIES.get(key);
283
- if (rangeProperty !== void 0) {
284
- rangeStamps.push([rangeProperty, value]);
285
- continue;
286
- }
287
- const pointProperty = POINT_PROPERTIES.get(key);
288
- if (pointProperty !== void 0) targetStamps.push([pointProperty, value]);
289
- }
290
- const collapses = collapsesSection(name, pairs, target);
291
- if (rangeStamps.length === 0 && targetStamps.length === 0 && !collapses) {
292
- return;
293
- }
294
- const range = styleRange(children, targetIndex, name);
295
- const group = collapses && range.length > 1 ? String(state.nextGroup++) : void 0;
296
- for (let i = 0; i < range.length; i++) {
297
- const element = children[range[i]];
298
- for (const [property, value] of rangeStamps) {
299
- setProperty(element, property, value);
300
- }
301
- if (i === 0) {
302
- for (const [property, value] of targetStamps) {
303
- setProperty(element, property, value);
304
- }
305
- }
306
- if (rangeStamps.length > 0) {
307
- setProperty(element, RUN_PROPERTY, runValue(i, range.length));
308
- }
309
- if (group === void 0) continue;
310
- if (i === 0) {
311
- setProperty(element, COLLAPSE_TOGGLE_PROPERTY, group);
312
- } else {
313
- setProperty(element, COLLAPSED_PROPERTY, "true");
314
- setProperty(element, COLLAPSE_GROUP_PROPERTY, group);
315
- }
316
- }
647
+ const target = children[targetIndex];
648
+ if (!STYLE_TARGET_TAGS.has(target.tagName)) return;
649
+ const rangeStamps = [];
650
+ const targetStamps = [];
651
+ for (const [key, value] of pairs) {
652
+ if (!accepts(name, key, value)) continue;
653
+ const rangeProperty = RANGE_PROPERTIES.get(key);
654
+ if (rangeProperty !== void 0) {
655
+ rangeStamps.push([rangeProperty, value]);
656
+ continue;
657
+ }
658
+ const pointProperty = POINT_PROPERTIES.get(key);
659
+ if (pointProperty !== void 0) targetStamps.push([pointProperty, value]);
660
+ }
661
+ const collapses = collapsesSection(name, pairs, target);
662
+ if (rangeStamps.length === 0 && targetStamps.length === 0 && !collapses) return;
663
+ const range = styleRange(children, targetIndex, name);
664
+ const group = collapses && range.length > 1 ? String(state.nextGroup++) : void 0;
665
+ for (let i = 0; i < range.length; i++) {
666
+ const element = children[range[i]];
667
+ for (const [property, value] of rangeStamps) setProperty(element, property, value);
668
+ if (i === 0) for (const [property, value] of targetStamps) setProperty(element, property, value);
669
+ if (rangeStamps.length > 0) setProperty(element, RUN_PROPERTY, runValue(i, range.length));
670
+ if (group === void 0) continue;
671
+ if (i === 0) setProperty(element, COLLAPSE_TOGGLE_PROPERTY, group);
672
+ else {
673
+ setProperty(element, COLLAPSED_PROPERTY, "true");
674
+ setProperty(element, COLLAPSE_GROUP_PROPERTY, group);
675
+ }
676
+ }
317
677
  }
318
678
  function stampOq(target, pairs) {
319
- setProperty(target, OQ_PROPERTY, "true");
320
- const leaning = pairs.get("leaning");
321
- if (leaning === void 0) return;
322
- const text = leaning.replace(/\s+/g, " ").trim().slice(0, MAX_LEANING);
323
- if (text !== "") setProperty(target, LEANING_PROPERTY, text);
679
+ setProperty(target, OQ_PROPERTY, "true");
680
+ const leaning = pairs.get("leaning");
681
+ if (leaning === void 0) return;
682
+ const text = leaning.replace(/\s+/g, " ").trim().slice(0, MAX_LEANING);
683
+ if (text !== "") setProperty(target, LEANING_PROPERTY, text);
324
684
  }
685
+ /**
686
+ * Merge one run of directives onto one target, then stamp.
687
+ *
688
+ * Merging is defined on the tree, not on the source: every directive comment up
689
+ * to the target merges, last-key-wins, whether or not blank lines separate
690
+ * them. Measured — adjacent comments and comments separated by a blank line
691
+ * produce byte-identical trees, so a rule that told them apart would have to
692
+ * re-read line numbers to do it.
693
+ */
325
694
  function stampRun(children, targetIndex, run, state) {
326
- const target = children[targetIndex];
327
- const style = /* @__PURE__ */ new Map();
328
- const oq = /* @__PURE__ */ new Map();
329
- let styleName;
330
- let hasOq = false;
331
- for (const directive of run) {
332
- if (directive.name === "section" || directive.name === "block") {
333
- styleName = directive.name;
334
- for (const pair of directive.pairs) style.set(pair.key, pair.value);
335
- } else if (directive.name === "oq") {
336
- hasOq = true;
337
- for (const pair of directive.pairs) oq.set(pair.key, pair.value);
338
- }
339
- }
340
- if (styleName !== void 0) {
341
- stampStyle(children, targetIndex, styleName, style, state);
342
- }
343
- if (hasOq && ANCHOR_TARGET_TAGS.has(target.tagName)) {
344
- stampOq(target, oq);
345
- }
695
+ const target = children[targetIndex];
696
+ const style = /* @__PURE__ */ new Map();
697
+ const oq = /* @__PURE__ */ new Map();
698
+ let styleName;
699
+ let hasOq = false;
700
+ for (const directive of run) if (directive.name === "section" || directive.name === "block") {
701
+ styleName = directive.name;
702
+ for (const pair of directive.pairs) style.set(pair.key, pair.value);
703
+ } else if (directive.name === "oq") {
704
+ hasOq = true;
705
+ for (const pair of directive.pairs) oq.set(pair.key, pair.value);
706
+ }
707
+ if (styleName !== void 0) stampStyle(children, targetIndex, styleName, style, state);
708
+ if (hasOq && ANCHOR_TARGET_TAGS.has(target.tagName)) stampOq(target, oq);
346
709
  }
710
+ /** A directive comment, or `undefined` for anything else — malformed included. */
347
711
  function directiveOf(node) {
348
- if (node.type !== "comment") return void 0;
349
- const parsed = parseVantageDirective(node.value);
350
- return parsed !== null && parsed.kind === "directive" ? parsed : void 0;
712
+ if (node.type !== "comment") return void 0;
713
+ const parsed = parseVantageDirective(node.value);
714
+ return parsed !== null && parsed.kind === "directive" ? parsed : void 0;
351
715
  }
716
+ /**
717
+ * One left-to-right pass over a parent's children, recursing into elements.
718
+ *
719
+ * The whole tree, not just the root: `rehype-raw` leaves comment nodes inside
720
+ * `blockquote`, inside `li`, inside `td` and inline inside `p`, and the real
721
+ * Open Questions layout puts the `oq` directive inside a list item — so a
722
+ * root-only walk finds none of them.
723
+ *
724
+ * Pass order is also what resolves a nested section: an inner heading's
725
+ * directive necessarily sits at a higher child index than the outer directive
726
+ * that ranged over it, so each property is simply last-write-wins.
727
+ */
352
728
  function processChildren(parent, state) {
353
- const children = parent.children;
354
- let i = 0;
355
- while (i < children.length) {
356
- const node = children[i];
357
- if (node.type === "element") {
358
- processChildren(node, state);
359
- i++;
360
- continue;
361
- }
362
- const first = directiveOf(node);
363
- if (first === void 0) {
364
- i++;
365
- continue;
366
- }
367
- const run = [first];
368
- let j = i + 1;
369
- let targetIndex = -1;
370
- for (; j < children.length; j++) {
371
- const next = children[j];
372
- if (next.type === "element") {
373
- targetIndex = j;
374
- break;
375
- }
376
- if (!isSkippable(next)) break;
377
- const directive = directiveOf(next);
378
- if (directive !== void 0) run.push(directive);
379
- }
380
- if (targetIndex >= 0) stampRun(children, targetIndex, run, state);
381
- i = j;
382
- }
729
+ const children = parent.children;
730
+ let i = 0;
731
+ while (i < children.length) {
732
+ const node = children[i];
733
+ if (node.type === "element") {
734
+ processChildren(node, state);
735
+ i++;
736
+ continue;
737
+ }
738
+ const first = directiveOf(node);
739
+ if (first === void 0) {
740
+ i++;
741
+ continue;
742
+ }
743
+ const run = [first];
744
+ let j = i + 1;
745
+ let targetIndex = -1;
746
+ for (; j < children.length; j++) {
747
+ const next = children[j];
748
+ if (next.type === "element") {
749
+ targetIndex = j;
750
+ break;
751
+ }
752
+ if (!isSkippable(next)) break;
753
+ const directive = directiveOf(next);
754
+ if (directive !== void 0) run.push(directive);
755
+ }
756
+ if (targetIndex >= 0) stampRun(children, targetIndex, run, state);
757
+ i = j;
758
+ }
383
759
  }
384
- var rehypeVantageDirectives = () => {
385
- return (tree) => {
386
- processChildren(tree, { nextGroup: 1 });
387
- };
760
+ const rehypeVantageDirectives = () => {
761
+ return (tree) => {
762
+ processChildren(tree, { nextGroup: 1 });
763
+ };
388
764
  };
389
- var rehypeVantageDirectives_default = rehypeVantageDirectives;
390
-
391
- // src/rehypeVantageMathStamps.ts
392
- var CARRIED_KEY = "vantageDisplayMathStamps";
765
+ //#endregion
766
+ //#region src/rehypeVantageMathStamps.ts
767
+ /**
768
+ * Where the snapshot lives between the two halves.
769
+ *
770
+ * `file.data` rather than a closure or a module-level map: `buildPipeline` is
771
+ * allowed to be built once and run over many documents, and per-file state is
772
+ * the only kind that cannot leak from one of those to the next.
773
+ */
774
+ const CARRIED_KEY = "vantageDisplayMathStamps";
393
775
  function classNames(node) {
394
- const value = node.properties?.className;
395
- return Array.isArray(value) ? value.map(String) : [];
776
+ const value = node.properties?.className;
777
+ return Array.isArray(value) ? value.map(String) : [];
396
778
  }
779
+ /**
780
+ * A `<pre>` `rehype-katex` will replace — its own condition, restated.
781
+ *
782
+ * `language-math` is the only class to test: `rehype-katex` keys the
783
+ * pre-as-scope branch on it, and the sanitiser strips the `math-display` that
784
+ * `remark-math` also emits (measured — a stamped fence arrives here with
785
+ * `className: ["language-math"]` alone).
786
+ */
397
787
  function isDisplayMath(node) {
398
- if (node.type !== "element" || node.tagName !== "pre") return false;
399
- return node.children.some(
400
- (child) => child.type === "element" && child.tagName === "code" && classNames(child).includes("language-math")
401
- );
788
+ if (node.type !== "element" || node.tagName !== "pre") return false;
789
+ return node.children.some((child) => child.type === "element" && child.tagName === "code" && classNames(child).includes("language-math"));
402
790
  }
791
+ /**
792
+ * What is worth carrying: everything this pipeline stamped itself.
793
+ *
794
+ * Deliberately not the whole property bag. `className`, `style` and `id` belong
795
+ * to the element KaTeX is about to build, and copying a `<pre>`'s onto a
796
+ * `<span class="katex-display">` would fight it.
797
+ */
403
798
  function carriedProperties(properties) {
404
- const carried = {};
405
- for (const [key, value] of Object.entries(properties ?? {})) {
406
- if (key === "dataSourceLine" || key.startsWith("dataVantage")) {
407
- carried[key] = value;
408
- }
409
- }
410
- return carried;
799
+ const carried = {};
800
+ for (const [key, value] of Object.entries(properties ?? {})) if (key === "dataSourceLine" || key.startsWith("dataVantage")) carried[key] = value;
801
+ return carried;
411
802
  }
412
803
  function collect(parent, out) {
413
- const children = parent.children;
414
- for (let i = 0; i < children.length; i++) {
415
- const node = children[i];
416
- if (node.type !== "element") continue;
417
- if (isDisplayMath(node)) {
418
- const properties = carriedProperties(node.properties);
419
- if (Object.keys(properties).length > 0) {
420
- out.push({
421
- parent,
422
- anchor: i === 0 ? void 0 : children[i - 1],
423
- properties
424
- });
425
- }
426
- continue;
427
- }
428
- collect(node, out);
429
- }
804
+ const children = parent.children;
805
+ for (let i = 0; i < children.length; i++) {
806
+ const node = children[i];
807
+ if (node.type !== "element") continue;
808
+ if (isDisplayMath(node)) {
809
+ const properties = carriedProperties(node.properties);
810
+ if (Object.keys(properties).length > 0) out.push({
811
+ parent,
812
+ anchor: i === 0 ? void 0 : children[i - 1],
813
+ properties
814
+ });
815
+ continue;
816
+ }
817
+ collect(node, out);
818
+ }
430
819
  }
431
820
  function reapply(carried) {
432
- for (const { parent, anchor, properties } of carried) {
433
- const siblings = parent.children;
434
- let index = 0;
435
- if (anchor !== void 0) {
436
- const at = siblings.indexOf(anchor);
437
- if (at === -1) continue;
438
- index = at + 1;
439
- }
440
- const replacement = siblings[index];
441
- if (replacement === void 0 || replacement.type !== "element") continue;
442
- if (!classNames(replacement).some((name) => name.startsWith("katex"))) {
443
- continue;
444
- }
445
- replacement.properties ??= {};
446
- for (const [key, value] of Object.entries(properties)) {
447
- replacement.properties[key] ??= value;
448
- }
449
- }
821
+ for (const { parent, anchor, properties } of carried) {
822
+ const siblings = parent.children;
823
+ let index = 0;
824
+ if (anchor !== void 0) {
825
+ const at = siblings.indexOf(anchor);
826
+ if (at === -1) continue;
827
+ index = at + 1;
828
+ }
829
+ const replacement = siblings[index];
830
+ if (replacement === void 0 || replacement.type !== "element") continue;
831
+ if (!classNames(replacement).some((name) => name.startsWith("katex"))) continue;
832
+ replacement.properties ??= {};
833
+ for (const [key, value] of Object.entries(properties)) replacement.properties[key] ??= value;
834
+ }
450
835
  }
451
- var rehypeCaptureMathStamps = () => {
452
- return (tree, file) => {
453
- const carried = [];
454
- collect(tree, carried);
455
- file.data[CARRIED_KEY] = carried;
456
- };
836
+ /** Snapshot every stamped display-math block. Register before `rehypeKatex`. */
837
+ const rehypeCaptureMathStamps = () => {
838
+ return (tree, file) => {
839
+ const carried = [];
840
+ collect(tree, carried);
841
+ file.data[CARRIED_KEY] = carried;
842
+ };
457
843
  };
458
- var rehypeRestoreMathStamps = () => {
459
- return (_tree, file) => {
460
- const carried = file.data[CARRIED_KEY];
461
- delete file.data[CARRIED_KEY];
462
- if (Array.isArray(carried)) reapply(carried);
463
- };
844
+ /** Re-apply the snapshot. Register immediately after `rehypeKatex`. */
845
+ const rehypeRestoreMathStamps = () => {
846
+ return (_tree, file) => {
847
+ const carried = file.data[CARRIED_KEY];
848
+ delete file.data[CARRIED_KEY];
849
+ if (Array.isArray(carried)) reapply(carried);
850
+ };
464
851
  };
465
- var SAFE_STYLE_PROPERTIES = [
466
- // Box metrics. `top`/`right`/`bottom`/`left` are inert now that `position` is
467
- // banned, and they stay only because dropping them would fail the whole
468
- // attribute for a document that writes one — the all-or-nothing rule below
469
- // makes every removal a behaviour change. They buy an attacker nothing that
470
- // negative `margin` does not already buy.
471
- "height",
472
- "min-height",
473
- "max-height",
474
- "width",
475
- "min-width",
476
- "max-width",
477
- "top",
478
- "bottom",
479
- "left",
480
- "right",
481
- "margin",
482
- "margin-top",
483
- "margin-right",
484
- "margin-bottom",
485
- "margin-left",
486
- "padding",
487
- "padding-top",
488
- "padding-right",
489
- "padding-bottom",
490
- "padding-left",
491
- // Rules and boxes.
492
- "border",
493
- "border-style",
494
- "border-color",
495
- "border-width",
496
- "border-top-width",
497
- "border-right-width",
498
- "border-bottom-width",
499
- "border-left-width",
500
- "border-top-style",
501
- "border-right-style",
502
- "border-bottom-style",
503
- "border-left-style",
504
- "border-top-color",
505
- "border-right-color",
506
- "border-bottom-color",
507
- "border-left-color",
508
- "border-radius",
509
- // Typography.
510
- "color",
511
- "background-color",
512
- "font",
513
- "font-size",
514
- "font-style",
515
- "font-weight",
516
- "font-family",
517
- "font-variant",
518
- "line-height",
519
- "letter-spacing",
520
- "word-spacing",
521
- "text-align",
522
- "text-decoration",
523
- "text-indent",
524
- "white-space",
525
- "vertical-align",
526
- "list-style-type",
527
- // Flow.
528
- "display",
529
- "float",
530
- "clear",
531
- "opacity",
532
- "overflow"
533
- ];
534
- var VALUE = `[^;:()"'\\\\]*`;
535
- var DECLARATION = `(?:(?:${SAFE_STYLE_PROPERTIES.join("|")})\\s*:${VALUE})`;
536
- var SAFE_STYLE = new RegExp(
537
- `^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`,
538
- "i"
539
- );
540
- var COLLAPSE_GROUP_ID = /^[0-9]+$/;
541
- var sanitizeSchema = {
542
- ...defaultSchema,
543
- tagNames: [
544
- ...defaultSchema.tagNames || [],
545
- // KaTeX MathML elements
546
- "math",
547
- "semantics",
548
- "mrow",
549
- "mi",
550
- "mo",
551
- "mn",
552
- "msup",
553
- "msub",
554
- "mfrac",
555
- "mover",
556
- "munder",
557
- "msqrt",
558
- "mroot",
559
- "mtable",
560
- "mtr",
561
- "mtd",
562
- "mtext",
563
- "mspace",
564
- "annotation",
565
- // Other
566
- "figure",
567
- "figcaption",
568
- "summary",
569
- "details"
570
- ],
571
- attributes: {
572
- ...defaultSchema.attributes,
573
- "*": [
574
- ...defaultSchema.attributes?.["*"] || [],
575
- "className",
576
- ["style", SAFE_STYLE],
577
- "dataSourceLine",
578
- // What `rehypeVantageDirectives` compiles a `<!-- vantage: … -->` comment
579
- // into, named individually — never by a `data-vantage-*` wildcard, which
580
- // would readmit whatever a future bug emits and whatever a document
581
- // hand-writes as raw HTML.
582
- //
583
- // The value lists are the belt to the plugin's braces: the vocabulary is
584
- // closed in the plugin *and* here, imported from the one module that
585
- // defines it, so even if a refactor let an unvalidated value reach the
586
- // tree the sanitiser still refuses it.
587
- ["dataVantageTone", ...VANTAGE_TONES],
588
- ["dataVantageEmphasis", ...VANTAGE_EMPHASIS],
589
- ["dataVantageBadge", ...VANTAGE_BADGES],
590
- ["dataVantageCollapsed", ...VANTAGE_COLLAPSED],
591
- // The other half of `collapsed`: which group a hidden block belongs to,
592
- // and which group a heading toggles. Both are plugin-minted counters with
593
- // no vocabulary to allowlist, so they take a pattern instead —
594
- // `hast-util-sanitize` accepts a `RegExp` in place of a literal value.
595
- // A pattern rather than a bare name because the JS interpolates the value
596
- // into a selector: anything but digits has no business reaching it.
597
- ["dataVantageCollapseGroup", COLLAPSE_GROUP_ID],
598
- ["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
599
- ["dataVantageRun", ...VANTAGE_RUNS],
600
- ["dataVantageOq", "true"],
601
- // The design's one genuinely free-text value: the body of a review
602
- // comment, so it cannot be value-allowlisted and this entry is name-only.
603
- // Two defences remain rather than three — `hast` escapes the value on
604
- // serialisation and React sets it through the DOM property path, so it
605
- // cannot break out of the attribute — and the honest record of that is in
606
- // the design doc rather than a third layer implied here.
607
- "dataVantageLeaning"
608
- ],
609
- code: [...defaultSchema.attributes?.code || [], "className"],
610
- span: [
611
- ...defaultSchema.attributes?.span || [],
612
- "className",
613
- ["style", SAFE_STYLE]
614
- ],
615
- div: [
616
- ...defaultSchema.attributes?.div || [],
617
- "className",
618
- ["style", SAFE_STYLE]
619
- ],
620
- a: [...defaultSchema.attributes?.a || [], "id", "className"],
621
- math: ["xmlns"],
622
- annotation: ["encoding"],
623
- img: [...defaultSchema.attributes?.img || [], "loading"],
624
- td: [...defaultSchema.attributes?.td || [], ["style", SAFE_STYLE]],
625
- th: [...defaultSchema.attributes?.th || [], ["style", SAFE_STYLE]]
626
- }
852
+ //#endregion
853
+ //#region src/sanitize.ts
854
+ /**
855
+ * Sanitization schema for the rendering pipeline.
856
+ * Allows GFM, KaTeX MathML, syntax highlighting classes, and
857
+ * data-source-line attributes while blocking XSS vectors.
858
+ */
859
+ const DECLARATION = `(?:(?:${[
860
+ "height",
861
+ "min-height",
862
+ "max-height",
863
+ "width",
864
+ "min-width",
865
+ "max-width",
866
+ "top",
867
+ "bottom",
868
+ "left",
869
+ "right",
870
+ "margin",
871
+ "margin-top",
872
+ "margin-right",
873
+ "margin-bottom",
874
+ "margin-left",
875
+ "padding",
876
+ "padding-top",
877
+ "padding-right",
878
+ "padding-bottom",
879
+ "padding-left",
880
+ "border",
881
+ "border-style",
882
+ "border-color",
883
+ "border-width",
884
+ "border-top-width",
885
+ "border-right-width",
886
+ "border-bottom-width",
887
+ "border-left-width",
888
+ "border-top-style",
889
+ "border-right-style",
890
+ "border-bottom-style",
891
+ "border-left-style",
892
+ "border-top-color",
893
+ "border-right-color",
894
+ "border-bottom-color",
895
+ "border-left-color",
896
+ "border-radius",
897
+ "color",
898
+ "background-color",
899
+ "font",
900
+ "font-size",
901
+ "font-style",
902
+ "font-weight",
903
+ "font-family",
904
+ "font-variant",
905
+ "line-height",
906
+ "letter-spacing",
907
+ "word-spacing",
908
+ "text-align",
909
+ "text-decoration",
910
+ "text-indent",
911
+ "white-space",
912
+ "vertical-align",
913
+ "list-style-type",
914
+ "display",
915
+ "float",
916
+ "clear",
917
+ "opacity",
918
+ "overflow"
919
+ ].join("|")})\\s*:[^;:()"'\\\\]*)`;
920
+ const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "i");
921
+ /**
922
+ * A collapse group id: one or more digits, anchored.
923
+ *
924
+ * The plugin mints these as a per-document counter, so there is no vocabulary to
925
+ * list. Keeping the shape narrow matters anyway — the toggle JS builds a
926
+ * `[data-vantage-collapse-group="…"]` selector out of the value, and a document
927
+ * that hand-wrote raw HTML is the only way a non-numeric one could ever appear.
928
+ */
929
+ const COLLAPSE_GROUP_ID = /^[0-9]+$/;
930
+ /**
931
+ * Never set `allowComments` here.
932
+ *
933
+ * `hast-util-sanitize` drops comment nodes because that boolean defaults to
934
+ * `false` — comments are not elements, so `tagNames` has nothing to do with it.
935
+ * `rehypeVantageDirectives` relies on that deletion: it consumes a
936
+ * `<!-- vantage: … -->` comment into attributes and deliberately leaves the node
937
+ * for the sanitiser. Turning the switch on readmits every directive comment —
938
+ * valid and malformed alike — into the rendered HTML, which breaks the carrier's
939
+ * whole premise. `vantageDirectives.test.ts` ("leaves no comment in the rendered
940
+ * markup") is the guard.
941
+ */
942
+ const sanitizeSchema = {
943
+ ...defaultSchema,
944
+ tagNames: [
945
+ ...defaultSchema.tagNames || [],
946
+ "math",
947
+ "semantics",
948
+ "mrow",
949
+ "mi",
950
+ "mo",
951
+ "mn",
952
+ "msup",
953
+ "msub",
954
+ "mfrac",
955
+ "mover",
956
+ "munder",
957
+ "msqrt",
958
+ "mroot",
959
+ "mtable",
960
+ "mtr",
961
+ "mtd",
962
+ "mtext",
963
+ "mspace",
964
+ "annotation",
965
+ "figure",
966
+ "figcaption",
967
+ "summary",
968
+ "details"
969
+ ],
970
+ attributes: {
971
+ ...defaultSchema.attributes,
972
+ "*": [
973
+ ...defaultSchema.attributes?.["*"] || [],
974
+ "className",
975
+ ["style", SAFE_STYLE],
976
+ "dataSourceLine",
977
+ ["dataVantageTone", ...VANTAGE_TONES],
978
+ ["dataVantageEmphasis", ...VANTAGE_EMPHASIS],
979
+ ["dataVantageBadge", ...VANTAGE_BADGES],
980
+ ["dataVantageCollapsed", ...VANTAGE_COLLAPSED],
981
+ ["dataVantageCollapseGroup", COLLAPSE_GROUP_ID],
982
+ ["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
983
+ ["dataVantageRun", ...VANTAGE_RUNS],
984
+ ["dataVantageOq", "true"],
985
+ ["dataVantageAlert", ...VANTAGE_ALERTS],
986
+ "dataVantageLeaning"
987
+ ],
988
+ code: [...defaultSchema.attributes?.code || [], "className"],
989
+ span: [
990
+ ...defaultSchema.attributes?.span || [],
991
+ "className",
992
+ ["style", SAFE_STYLE]
993
+ ],
994
+ div: [
995
+ ...defaultSchema.attributes?.div || [],
996
+ "className",
997
+ ["style", SAFE_STYLE]
998
+ ],
999
+ a: [
1000
+ ...defaultSchema.attributes?.a || [],
1001
+ "id",
1002
+ "className"
1003
+ ],
1004
+ math: ["xmlns"],
1005
+ annotation: ["encoding"],
1006
+ img: [...defaultSchema.attributes?.img || [], "loading"],
1007
+ td: [...defaultSchema.attributes?.td || [], ["style", SAFE_STYLE]],
1008
+ th: [...defaultSchema.attributes?.th || [], ["style", SAFE_STYLE]]
1009
+ }
627
1010
  };
628
-
629
- // src/pipeline.ts
1011
+ //#endregion
1012
+ //#region src/pipeline.ts
1013
+ /**
1014
+ * The mdast half of the chain. Exported on its own because there is a real
1015
+ * mdast-only consumer: the CLI checker parses documents without ever running
1016
+ * rehype (`packages/vantage-check/src/core/document.ts`), and it has to parse
1017
+ * them exactly the way the viewer does.
1018
+ */
630
1019
  function buildRemarkPlugins(options = {}) {
631
- const { gfm = true, math = true } = options;
632
- const plugins = [];
633
- if (gfm) plugins.push([remarkGfm, { singleTilde: false }]);
634
- if (math) plugins.push([remarkMath, { singleDollarTextMath: false }]);
635
- return plugins;
1020
+ const { gfm = true, math = true } = options;
1021
+ const plugins = [];
1022
+ if (gfm) plugins.push([remarkGfm, { singleTilde: false }]);
1023
+ if (math) plugins.push([remarkMath, { singleDollarTextMath: false }]);
1024
+ return plugins;
636
1025
  }
1026
+ /** The hast half. Deliberately not exported: see `buildPipeline`. */
637
1027
  function buildRehypePlugins(options = {}) {
638
- const {
639
- math = true,
640
- highlight = true,
641
- sourceLines = true,
642
- sanitize = true,
643
- bodyLineOffset = 0
644
- } = options;
645
- const plugins = [rehypeRaw];
646
- if (sourceLines) {
647
- plugins.push([rehypeSourceLines_default, { offset: bodyLineOffset }]);
648
- }
649
- plugins.push(rehypeVantageDirectives_default);
650
- if (sanitize) plugins.push([rehypeSanitize, sanitizeSchema]);
651
- plugins.push(rehypeSlug);
652
- if (highlight) plugins.push(rehypeHighlight);
653
- if (math) {
654
- plugins.push(rehypeCaptureMathStamps, rehypeKatex, rehypeRestoreMathStamps);
655
- }
656
- return plugins;
1028
+ const { math = true, highlight = true, sourceLines = true, sanitize = true, bodyLineOffset = 0 } = options;
1029
+ const plugins = [rehypeRaw];
1030
+ if (sourceLines) plugins.push([rehypeSourceLines, { offset: bodyLineOffset }]);
1031
+ plugins.push(rehypeVantageAlerts);
1032
+ plugins.push(rehypeVantageDirectives);
1033
+ if (sanitize) plugins.push([rehypeSanitize, sanitizeSchema]);
1034
+ plugins.push(rehypeSlug);
1035
+ if (highlight) plugins.push(rehypeHighlight);
1036
+ if (math) plugins.push(rehypeCaptureMathStamps, rehypeKatex, rehypeRestoreMathStamps);
1037
+ return plugins;
657
1038
  }
1039
+ /**
1040
+ * Both halves from one options object.
1041
+ *
1042
+ * This is what every renderer calls. It takes one object rather than exposing
1043
+ * the two builders because `math` spans both halves — `remark-math` parses the
1044
+ * delimiters, `rehype-katex` renders the result — and two calls are two places
1045
+ * to forget the second one.
1046
+ *
1047
+ * Returns fresh arrays on every call and reads no module-level state; keep it
1048
+ * that way, so a plugin in the chain cannot become a function of how many times
1049
+ * the chain has been built.
1050
+ */
658
1051
  function buildPipeline(options = {}) {
659
- return {
660
- remarkPlugins: buildRemarkPlugins(options),
661
- rehypePlugins: buildRehypePlugins(options)
662
- };
1052
+ return {
1053
+ remarkPlugins: buildRemarkPlugins(options),
1054
+ rehypePlugins: buildRehypePlugins(options)
1055
+ };
663
1056
  }
1057
+ //#endregion
1058
+ //#region src/frontmatter.ts
1059
+ /**
1060
+ * Frontmatter parser for YAML (---) and TOML (+++) delimited content.
1061
+ * Works in both browser and server environments.
1062
+ */
1063
+ /**
1064
+ * Parse frontmatter from markdown content.
1065
+ * Supports YAML (delimited by ---) and TOML (delimited by +++).
1066
+ */
664
1067
  function parseFrontmatter(content) {
665
- if (content.startsWith("+++")) {
666
- return parseFrontmatterWithDelimiter(content, "+++", "toml");
667
- }
668
- if (content.startsWith("---")) {
669
- return parseFrontmatterWithDelimiter(content, "---", "yaml");
670
- }
671
- return withOffset(content, {
672
- frontmatter: {},
673
- body: content,
674
- format: "none"
675
- });
1068
+ if (content.startsWith("+++")) return parseFrontmatterWithDelimiter(content, "+++", "toml");
1069
+ if (content.startsWith("---")) return parseFrontmatterWithDelimiter(content, "---", "yaml");
1070
+ return withOffset(content, {
1071
+ frontmatter: {},
1072
+ body: content,
1073
+ format: "none"
1074
+ });
676
1075
  }
1076
+ /**
1077
+ * Fill in `bodyLineOffset`. `body` is always a suffix of `content`, so the
1078
+ * newlines in the prefix that was stripped are exactly the shift — which also
1079
+ * accounts for the blank line consumed after the closing delimiter.
1080
+ */
677
1081
  function withOffset(content, parsed) {
678
- const stripped = content.slice(0, content.length - parsed.body.length);
679
- let bodyLineOffset = 0;
680
- for (const ch of stripped) {
681
- if (ch === "\n") bodyLineOffset++;
682
- }
683
- return { ...parsed, bodyLineOffset };
1082
+ const stripped = content.slice(0, content.length - parsed.body.length);
1083
+ let bodyLineOffset = 0;
1084
+ for (const ch of stripped) if (ch === "\n") bodyLineOffset++;
1085
+ return {
1086
+ ...parsed,
1087
+ bodyLineOffset
1088
+ };
684
1089
  }
685
1090
  function parseFrontmatterWithDelimiter(content, delimiter, format) {
686
- const searchStart = delimiter.length;
687
- const endIndex = content.indexOf(`
688
- ${delimiter}`, searchStart);
689
- if (endIndex === -1) {
690
- return withOffset(content, {
691
- frontmatter: {},
692
- body: content,
693
- format: "none",
694
- problem: { kind: "unterminated", delimiter }
695
- });
696
- }
697
- const raw = content.slice(searchStart + 1, endIndex).trim();
698
- const bodyStart = endIndex + 1 + delimiter.length;
699
- const body = content.slice(bodyStart).replace(/^\n/, "");
700
- try {
701
- const parsed = format === "toml" ? parse(raw) : YAML.parse(raw);
702
- return withOffset(content, {
703
- frontmatter: parsed || {},
704
- body,
705
- format,
706
- ...isMapping(parsed) ? {} : { problem: { kind: "not-a-mapping", delimiter } }
707
- });
708
- } catch (error) {
709
- return withOffset(content, {
710
- frontmatter: {},
711
- body: content,
712
- format: "none",
713
- problem: { kind: "invalid", delimiter, ...errorPosition(error) }
714
- });
715
- }
1091
+ const searchStart = delimiter.length;
1092
+ const endIndex = content.indexOf(`\n${delimiter}`, searchStart);
1093
+ if (endIndex === -1) return withOffset(content, {
1094
+ frontmatter: {},
1095
+ body: content,
1096
+ format: "none",
1097
+ problem: {
1098
+ kind: "unterminated",
1099
+ delimiter
1100
+ }
1101
+ });
1102
+ const raw = content.slice(searchStart + 1, endIndex).trim();
1103
+ const bodyStart = endIndex + 1 + delimiter.length;
1104
+ const body = content.slice(bodyStart).replace(/^\n/, "");
1105
+ try {
1106
+ const parsed = format === "toml" ? parse(raw) : YAML.parse(raw);
1107
+ return withOffset(content, {
1108
+ frontmatter: parsed || {},
1109
+ body,
1110
+ format,
1111
+ ...isMapping(parsed) ? {} : { problem: {
1112
+ kind: "not-a-mapping",
1113
+ delimiter
1114
+ } }
1115
+ });
1116
+ } catch (error) {
1117
+ return withOffset(content, {
1118
+ frontmatter: {},
1119
+ body: content,
1120
+ format: "none",
1121
+ problem: {
1122
+ kind: "invalid",
1123
+ delimiter,
1124
+ ...errorPosition(error)
1125
+ }
1126
+ });
1127
+ }
716
1128
  }
1129
+ /** Empty frontmatter is fine; a scalar or a list where a table belongs is not. */
717
1130
  function isMapping(value) {
718
- return value === null || value === void 0 || typeof value === "object" && !Array.isArray(value);
1131
+ return value === null || value === void 0 || typeof value === "object" && !Array.isArray(value);
719
1132
  }
1133
+ /**
1134
+ * Pull the parser's message and, where it gave one, the position inside the
1135
+ * frontmatter block. `yaml` reports `linePos`; `smol-toml` reports `line` and
1136
+ * `column`. Both are optional and both are read defensively — a missing
1137
+ * position costs a less precise report, a wrong assumption costs a crash.
1138
+ */
720
1139
  function errorPosition(error) {
721
- const message = error instanceof Error ? error.message : String(error);
722
- const source = error;
723
- const yamlPosition = source?.linePos?.[0];
724
- if (typeof yamlPosition?.line === "number") {
725
- return {
726
- message,
727
- line: yamlPosition.line,
728
- ...typeof yamlPosition.col === "number" ? { column: yamlPosition.col } : {}
729
- };
730
- }
731
- if (typeof source?.line === "number") {
732
- return {
733
- message,
734
- line: source.line,
735
- ...typeof source.column === "number" ? { column: source.column } : {}
736
- };
737
- }
738
- return { message };
1140
+ const message = error instanceof Error ? error.message : String(error);
1141
+ const source = error;
1142
+ const yamlPosition = source?.linePos?.[0];
1143
+ if (typeof yamlPosition?.line === "number") return {
1144
+ message,
1145
+ line: yamlPosition.line,
1146
+ ...typeof yamlPosition.col === "number" ? { column: yamlPosition.col } : {}
1147
+ };
1148
+ if (typeof source?.line === "number") return {
1149
+ message,
1150
+ line: source.line,
1151
+ ...typeof source.column === "number" ? { column: source.column } : {}
1152
+ };
1153
+ return { message };
739
1154
  }
740
-
741
- // src/renderMarkdown.ts
1155
+ //#endregion
1156
+ //#region src/renderMarkdown.ts
1157
+ /**
1158
+ * Framework-agnostic markdown -> HTML rendering pipeline.
1159
+ * Uses the same remark/rehype chain as the Vantage viewer.
1160
+ */
1161
+ /**
1162
+ * Render a markdown string to HTML using the full Vantage pipeline.
1163
+ *
1164
+ * Features (all enabled by default):
1165
+ * - GitHub Flavored Markdown (tables, strikethrough, task lists)
1166
+ * - KaTeX math rendering, inline and block ($$...$$ only; single $ is not a delimiter)
1167
+ * - Syntax highlighting via highlight.js
1168
+ * - `data-source-line` attributes for line anchors
1169
+ * - XSS sanitization
1170
+ * - Heading slugs/anchors
1171
+ * - YAML/TOML frontmatter parsing
1172
+ *
1173
+ * Mermaid diagrams are NOT rendered server-side (they require a browser).
1174
+ * Mermaid code blocks are preserved as `<pre><code class="language-mermaid">`.
1175
+ * Use the React `<MarkdownViewer>` component for client-side mermaid rendering.
1176
+ */
742
1177
  async function renderMarkdown(content, options = {}) {
743
- const {
744
- gfm = true,
745
- math = true,
746
- highlight = true,
747
- sourceLines = true,
748
- sanitize = true,
749
- frontmatter: parseFm = true
750
- } = options;
751
- let parsed;
752
- if (parseFm) {
753
- parsed = parseFrontmatter(content);
754
- } else {
755
- parsed = {
756
- frontmatter: {},
757
- body: content,
758
- format: "none",
759
- bodyLineOffset: 0
760
- };
761
- }
762
- const { remarkPlugins, rehypePlugins } = buildPipeline({
763
- gfm,
764
- math,
765
- highlight,
766
- sourceLines,
767
- sanitize,
768
- bodyLineOffset: parsed.bodyLineOffset
769
- });
770
- const processor = unified().use(remarkParse).use(remarkPlugins).use(remarkRehype, { allowDangerousHtml: true }).use(rehypePlugins).use(rehypeStringify);
771
- const result = await processor.process(parsed.body);
772
- return {
773
- html: String(result),
774
- frontmatter: parsed.frontmatter,
775
- body: parsed.body
776
- };
1178
+ const { gfm = true, math = true, highlight = true, sourceLines = true, sanitize = true, frontmatter: parseFm = true } = options;
1179
+ let parsed;
1180
+ if (parseFm) parsed = parseFrontmatter(content);
1181
+ else parsed = {
1182
+ frontmatter: {},
1183
+ body: content,
1184
+ format: "none",
1185
+ bodyLineOffset: 0
1186
+ };
1187
+ const { remarkPlugins, rehypePlugins } = buildPipeline({
1188
+ gfm,
1189
+ math,
1190
+ highlight,
1191
+ sourceLines,
1192
+ sanitize,
1193
+ bodyLineOffset: parsed.bodyLineOffset
1194
+ });
1195
+ const result = await unified().use(remarkParse).use(remarkPlugins).use(remarkRehype, { allowDangerousHtml: true }).use(rehypePlugins).use(rehypeStringify).process(parsed.body);
1196
+ return {
1197
+ html: String(result),
1198
+ frontmatter: parsed.frontmatter,
1199
+ body: parsed.body
1200
+ };
777
1201
  }
778
-
779
- // src/lineAnchor.ts
1202
+ //#endregion
1203
+ //#region src/lineAnchor.ts
1204
+ /**
1205
+ * Parsing for GitHub-style line anchors, with no DOM in sight.
1206
+ *
1207
+ * Split out from scrollToLineAnchor.ts so that non-browser consumers — the
1208
+ * `vantage-check` CLI, which validates `#L42` links against the file on disk —
1209
+ * can share the *same* syntax the viewer honours instead of reimplementing it
1210
+ * and drifting.
1211
+ */
1212
+ /**
1213
+ * Parse a GitHub-style line anchor hash.
1214
+ * Supports: #L42, #L42-L50, #L42-50
1215
+ * Returns null if the hash is not a line anchor.
1216
+ */
780
1217
  function parseLineAnchor(hash) {
781
- if (!hash) return null;
782
- const frag = hash.startsWith("#") ? hash.slice(1) : hash;
783
- const match = frag.match(/^L(\d+)(?:-L?(\d+))?$/);
784
- if (!match) return null;
785
- const start = parseInt(match[1], 10);
786
- const end = match[2] ? parseInt(match[2], 10) : start;
787
- return { start: Math.min(start, end), end: Math.max(start, end) };
1218
+ if (!hash) return null;
1219
+ const match = (hash.startsWith("#") ? hash.slice(1) : hash).match(/^L(\d+)(?:-L?(\d+))?$/);
1220
+ if (!match) return null;
1221
+ const start = parseInt(match[1], 10);
1222
+ const end = match[2] ? parseInt(match[2], 10) : start;
1223
+ return {
1224
+ start: Math.min(start, end),
1225
+ end: Math.max(start, end)
1226
+ };
788
1227
  }
789
-
790
- // src/scrollToLineAnchor.ts
791
- var HIGHLIGHT_CLASS = "line-anchor-highlight";
1228
+ //#endregion
1229
+ //#region src/scrollToLineAnchor.ts
1230
+ /**
1231
+ * Framework-agnostic line anchor utilities.
1232
+ * Scroll to and highlight the elements a GitHub-style line anchor
1233
+ * (#L42, #L42-L50) names. The parsing half lives in lineAnchor.ts, which has
1234
+ * no DOM dependency.
1235
+ */
1236
+ const HIGHLIGHT_CLASS = "line-anchor-highlight";
1237
+ /**
1238
+ * Clear all line anchor highlights from a container.
1239
+ */
792
1240
  function clearLineAnchorHighlights(container) {
793
- container.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((node) => {
794
- node.classList.remove(HIGHLIGHT_CLASS);
795
- });
1241
+ container.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((node) => {
1242
+ node.classList.remove(HIGHLIGHT_CLASS);
1243
+ });
796
1244
  }
1245
+ /**
1246
+ * Scroll to and highlight line-anchored elements in a container.
1247
+ *
1248
+ * @param container - The DOM element containing rendered markdown
1249
+ * @param hash - The URL hash (e.g. "#L42" or "#L42-L50")
1250
+ * @returns A cleanup function that removes the highlights
1251
+ */
797
1252
  function scrollToLineAnchor(container, hash) {
798
- clearLineAnchorHighlights(container);
799
- const range = parseLineAnchor(hash);
800
- if (!range) return null;
801
- const blocks = container.querySelectorAll("[data-source-line]");
802
- let firstMatch = null;
803
- for (const block of blocks) {
804
- const line = parseInt(block.dataset.sourceLine || "0", 10);
805
- if (line >= range.start && line <= range.end) {
806
- block.classList.add(HIGHLIGHT_CLASS);
807
- if (!firstMatch) firstMatch = block;
808
- }
809
- }
810
- if (!firstMatch) {
811
- let closest = null;
812
- let closestLine = 0;
813
- for (const block of blocks) {
814
- const line = parseInt(
815
- block.dataset.sourceLine || "0",
816
- 10
817
- );
818
- if (line <= range.start && line > closestLine) {
819
- closestLine = line;
820
- closest = block;
821
- }
822
- }
823
- if (closest) {
824
- closest.classList.add(HIGHLIGHT_CLASS);
825
- firstMatch = closest;
826
- }
827
- }
828
- if (firstMatch) {
829
- requestAnimationFrame(() => {
830
- const scrollParent = findScrollParent(container);
831
- if (scrollParent) {
832
- const offset = firstMatch.getBoundingClientRect().top - scrollParent.getBoundingClientRect().top + scrollParent.scrollTop;
833
- scrollParent.scrollTo({ top: offset - 32, behavior: "smooth" });
834
- } else {
835
- firstMatch.scrollIntoView({ behavior: "smooth", block: "start" });
836
- }
837
- });
838
- }
839
- return () => clearLineAnchorHighlights(container);
1253
+ clearLineAnchorHighlights(container);
1254
+ const range = parseLineAnchor(hash);
1255
+ if (!range) return null;
1256
+ const blocks = container.querySelectorAll("[data-source-line]");
1257
+ let firstMatch = null;
1258
+ for (const block of blocks) {
1259
+ const line = parseInt(block.dataset.sourceLine || "0", 10);
1260
+ if (line >= range.start && line <= range.end) {
1261
+ block.classList.add(HIGHLIGHT_CLASS);
1262
+ if (!firstMatch) firstMatch = block;
1263
+ }
1264
+ }
1265
+ if (!firstMatch) {
1266
+ let closest = null;
1267
+ let closestLine = 0;
1268
+ for (const block of blocks) {
1269
+ const line = parseInt(block.dataset.sourceLine || "0", 10);
1270
+ if (line <= range.start && line > closestLine) {
1271
+ closestLine = line;
1272
+ closest = block;
1273
+ }
1274
+ }
1275
+ if (closest) {
1276
+ closest.classList.add(HIGHLIGHT_CLASS);
1277
+ firstMatch = closest;
1278
+ }
1279
+ }
1280
+ if (firstMatch) requestAnimationFrame(() => {
1281
+ const scrollParent = findScrollParent(container);
1282
+ if (scrollParent) {
1283
+ const offset = firstMatch.getBoundingClientRect().top - scrollParent.getBoundingClientRect().top + scrollParent.scrollTop;
1284
+ scrollParent.scrollTo({
1285
+ top: offset - 32,
1286
+ behavior: "smooth"
1287
+ });
1288
+ } else firstMatch.scrollIntoView({
1289
+ behavior: "smooth",
1290
+ block: "start"
1291
+ });
1292
+ });
1293
+ return () => clearLineAnchorHighlights(container);
840
1294
  }
841
1295
  function findScrollParent(el) {
842
- let node = el;
843
- while (node) {
844
- const overflow = getComputedStyle(node).overflowY;
845
- if (overflow === "auto" || overflow === "scroll") return node;
846
- node = node.parentElement;
847
- }
848
- return null;
1296
+ let node = el;
1297
+ while (node) {
1298
+ const overflow = getComputedStyle(node).overflowY;
1299
+ if (overflow === "auto" || overflow === "scroll") return node;
1300
+ node = node.parentElement;
1301
+ }
1302
+ return null;
849
1303
  }
850
-
851
- // src/vantageFrontmatter.ts
852
- var DOC_STATUSES = [
853
- "draft",
854
- "in-review",
855
- "accepted",
856
- "deprecated"
1304
+ //#endregion
1305
+ //#region src/vantageFrontmatter.ts
1306
+ /**
1307
+ * The document lifecycle vocabulary. Closed; extending it is a code change.
1308
+ *
1309
+ * This is the repo's own existing set, not a new one — `styleGuide.ts` tells
1310
+ * every agent to write `status: in-review # draft | in-review | accepted |
1311
+ * deprecated`, and every document under `docs/` follows it. It is deliberately
1312
+ * *not* the `badge` set (`draft stale blocked done wip`): `badge` is
1313
+ * section-scoped workflow state, `status` is document lifecycle state, and
1314
+ * `in-review` — the value the design doc's own only example renders — is not a
1315
+ * badge word at all. Only `draft` is a member of both, and a token set is per key.
1316
+ */
1317
+ const DOC_STATUSES = [
1318
+ "draft",
1319
+ "in-review",
1320
+ "accepted",
1321
+ "deprecated"
857
1322
  ];
858
- var VANTAGE_FRONTMATTER_KEYS = ["status-chip"];
859
- var DOC_STATUS_TONES = {
860
- draft: "muted",
861
- "in-review": "warning",
862
- accepted: "tip",
863
- deprecated: "caution"
1323
+ /** Every key this build knows under `vantage:`. Closed. */
1324
+ const VANTAGE_FRONTMATTER_KEYS = ["status-chip"];
1325
+ /**
1326
+ * Which tone each status borrows its colours from.
1327
+ *
1328
+ * The chip has no palette of its own: it reuses the tone chips
1329
+ * (`.vantage-chip--<tone>` in `styles/directives.css`), which is also what makes
1330
+ * a `draft` chip and a `badge=draft` chip the same visual object. A map rather
1331
+ * than a computed class name, so the whole status→tone relation is one readable
1332
+ * table and a test can assert it covers the vocabulary.
1333
+ */
1334
+ const DOC_STATUS_TONES = {
1335
+ draft: "muted",
1336
+ "in-review": "warning",
1337
+ accepted: "tip",
1338
+ deprecated: "caution"
864
1339
  };
865
- var STATUS_CHIP_VALUES = [
866
- ...DOC_STATUSES,
867
- "true",
868
- "false"
1340
+ /** The legal `status-chip` values, in the order a message should list them. */
1341
+ const STATUS_CHIP_VALUES = [
1342
+ ...DOC_STATUSES,
1343
+ "true",
1344
+ "false"
869
1345
  ];
1346
+ /** Narrowing helper the chip and the checker both use. */
870
1347
  function isDocStatus(value) {
871
- return typeof value === "string" && DOC_STATUSES.includes(value);
1348
+ return typeof value === "string" && DOC_STATUSES.includes(value);
872
1349
  }
873
1350
  function isTable(value) {
874
- return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
1351
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
875
1352
  }
1353
+ /**
1354
+ * Read the `vantage:` key out of parsed frontmatter.
1355
+ *
1356
+ * Pure: no module state, no mutation of the input, no logging. The same object
1357
+ * in twice gives equal results out.
1358
+ */
876
1359
  function readVantageFrontmatter(frontmatter) {
877
- const issues = [];
878
- if (!Object.hasOwn(frontmatter, "vantage")) return { issues };
879
- const value = frontmatter["vantage"];
880
- if (!isTable(value)) {
881
- issues.push({ kind: "not-a-table", value });
882
- return { issues };
883
- }
884
- let statusChip;
885
- for (const key of Object.keys(value)) {
886
- if (!VANTAGE_FRONTMATTER_KEYS.includes(key)) {
887
- issues.push({ kind: "unknown-key", key });
888
- continue;
889
- }
890
- if (key === "status-chip") {
891
- statusChip = readStatusChip(frontmatter, value[key], issues);
892
- }
893
- }
894
- return { ...statusChip === void 0 ? {} : { statusChip }, issues };
1360
+ const issues = [];
1361
+ if (!Object.hasOwn(frontmatter, "vantage")) return { issues };
1362
+ const value = frontmatter["vantage"];
1363
+ if (!isTable(value)) {
1364
+ issues.push({
1365
+ kind: "not-a-table",
1366
+ value
1367
+ });
1368
+ return { issues };
1369
+ }
1370
+ let statusChip;
1371
+ for (const key of Object.keys(value)) {
1372
+ if (!VANTAGE_FRONTMATTER_KEYS.includes(key)) {
1373
+ issues.push({
1374
+ kind: "unknown-key",
1375
+ key
1376
+ });
1377
+ continue;
1378
+ }
1379
+ if (key === "status-chip") statusChip = readStatusChip(frontmatter, value[key], issues);
1380
+ }
1381
+ return {
1382
+ ...statusChip === void 0 ? {} : { statusChip },
1383
+ issues
1384
+ };
895
1385
  }
1386
+ /**
1387
+ * `status-chip` takes two shapes, and the boolean one is the recommended shape.
1388
+ *
1389
+ * `true` **inherits** the document's own top-level `status:`, so the chip cannot
1390
+ * disagree with it — which is the entire point of §5.3 ("makes `status: draft`
1391
+ * visible rather than only buried in a metadata card"; the row stays, the chip
1392
+ * promotes the value rather than moving it). A literal token is kept
1393
+ * because the design doc's first draft of that example used one, and the
1394
+ * disagreement it makes possible is turned into a checker finding rather than
1395
+ * banned.
1396
+ *
1397
+ * Discrimination is on `typeof`, never truthiness: `true` is a YAML boolean and
1398
+ * `2026-08-31` is a `Date`, and both would sail through a truthy test.
1399
+ */
896
1400
  function readStatusChip(frontmatter, raw, issues) {
897
- const status = frontmatter["status"];
898
- if (raw === false) return void 0;
899
- if (raw === true) {
900
- if (isDocStatus(status)) return status;
901
- issues.push({ kind: "status-chip-orphan", status });
902
- return void 0;
903
- }
904
- if (isDocStatus(raw)) {
905
- if (isDocStatus(status) && status !== raw) {
906
- issues.push({ kind: "status-chip-disagrees", chip: raw, status });
907
- }
908
- return raw;
909
- }
910
- issues.push({
911
- kind: "bad-value",
912
- key: "status-chip",
913
- value: raw,
914
- legal: STATUS_CHIP_VALUES
915
- });
916
- return void 0;
1401
+ const status = frontmatter["status"];
1402
+ if (raw === false) return void 0;
1403
+ if (raw === true) {
1404
+ if (isDocStatus(status)) return status;
1405
+ issues.push({
1406
+ kind: "status-chip-orphan",
1407
+ status
1408
+ });
1409
+ return;
1410
+ }
1411
+ if (isDocStatus(raw)) {
1412
+ if (isDocStatus(status) && status !== raw) issues.push({
1413
+ kind: "status-chip-disagrees",
1414
+ chip: raw,
1415
+ status
1416
+ });
1417
+ return raw;
1418
+ }
1419
+ issues.push({
1420
+ kind: "bad-value",
1421
+ key: "status-chip",
1422
+ value: raw,
1423
+ legal: STATUS_CHIP_VALUES
1424
+ });
917
1425
  }
918
-
919
- // src/mermaidCache.ts
920
- var svgCache = /* @__PURE__ */ new Map();
921
-
922
- // src/mermaidLoader.ts
923
- var mermaidInstance = null;
924
- var mermaidLoading = null;
925
- var isDark = () => typeof document !== "undefined" && document.documentElement.classList.contains("dark");
1426
+ //#endregion
1427
+ //#region src/mermaidCache.ts
1428
+ const svgCache = /* @__PURE__ */ new Map();
1429
+ //#endregion
1430
+ //#region src/mermaidLoader.ts
1431
+ let mermaidInstance = null;
1432
+ let mermaidLoading = null;
1433
+ const isDark = () => typeof document !== "undefined" && document.documentElement.classList.contains("dark");
926
1434
  async function getMermaid() {
927
- if (mermaidInstance) return mermaidInstance;
928
- if (!mermaidLoading) {
929
- mermaidLoading = import('mermaid').then((mod) => {
930
- const m = mod.default;
931
- m.initialize({
932
- startOnLoad: false,
933
- theme: isDark() ? "dark" : "default",
934
- securityLevel: "strict",
935
- suppressErrorRendering: true
936
- });
937
- mermaidInstance = m;
938
- return m;
939
- });
940
- }
941
- return mermaidLoading;
1435
+ if (mermaidInstance) return mermaidInstance;
1436
+ if (!mermaidLoading) mermaidLoading = import("mermaid").then((mod) => {
1437
+ const m = mod.default;
1438
+ m.initialize({
1439
+ startOnLoad: false,
1440
+ theme: isDark() ? "dark" : "default",
1441
+ securityLevel: "strict",
1442
+ suppressErrorRendering: true
1443
+ });
1444
+ mermaidInstance = m;
1445
+ return m;
1446
+ });
1447
+ return mermaidLoading;
942
1448
  }
943
-
944
- // src/renderMermaidBlocks.ts
1449
+ //#endregion
1450
+ //#region src/renderMermaidBlocks.ts
1451
+ /**
1452
+ * Client-side utility to find and render mermaid code blocks in a container.
1453
+ *
1454
+ * After calling `renderMarkdown()`, mermaid blocks come through as
1455
+ * `<pre><code class="language-mermaid">...</code></pre>`. This function
1456
+ * finds those blocks and replaces them with rendered SVG diagrams.
1457
+ *
1458
+ * Framework-agnostic — works in any browser environment.
1459
+ */
1460
+ /**
1461
+ * Find all `<pre><code class="language-mermaid">` blocks in a container
1462
+ * and replace them with rendered SVG diagrams.
1463
+ *
1464
+ * @param container - DOM element containing rendered markdown HTML
1465
+ * @param options - Optional configuration
1466
+ * @returns Promise that resolves when all diagrams are rendered
1467
+ *
1468
+ * @example
1469
+ * ```ts
1470
+ * import { renderMarkdown, renderMermaidBlocks } from "vantage-md";
1471
+ *
1472
+ * const { html } = await renderMarkdown(content);
1473
+ * container.innerHTML = html;
1474
+ * await renderMermaidBlocks(container);
1475
+ * ```
1476
+ */
945
1477
  async function renderMermaidBlocks(container, options = {}) {
946
- const { className = "mermaid", onError } = options;
947
- const codeBlocks = container.querySelectorAll(
948
- 'pre > code.language-mermaid, pre > code[class*="language-mermaid"]'
949
- );
950
- if (codeBlocks.length === 0) return;
951
- const mermaid = await getMermaid();
952
- const renderPromises = Array.from(codeBlocks).map(async (codeEl) => {
953
- const preEl = codeEl.parentElement;
954
- if (!preEl) return;
955
- const code = codeEl.textContent || "";
956
- if (!code.trim()) return;
957
- const cached = svgCache.get(code);
958
- if (cached) {
959
- replaceWithSvg(preEl, cached, className);
960
- return;
961
- }
962
- try {
963
- let hash = 0;
964
- for (let i = 0; i < code.length; i++) {
965
- hash = (hash << 5) - hash + code.charCodeAt(i);
966
- hash = hash & hash;
967
- }
968
- const id = `mermaid-${Math.abs(hash).toString(36)}-${Date.now()}`;
969
- const { svg } = await mermaid.render(id, code);
970
- svgCache.set(code, svg);
971
- replaceWithSvg(preEl, svg, className);
972
- } catch (err) {
973
- if (onError) {
974
- onError(code, err instanceof Error ? err : new Error(String(err)));
975
- }
976
- }
977
- });
978
- await Promise.all(renderPromises);
1478
+ const { className = "mermaid", onError } = options;
1479
+ const codeBlocks = container.querySelectorAll("pre > code.language-mermaid, pre > code[class*=\"language-mermaid\"]");
1480
+ if (codeBlocks.length === 0) return;
1481
+ const mermaid = await getMermaid();
1482
+ const renderPromises = Array.from(codeBlocks).map(async (codeEl) => {
1483
+ const preEl = codeEl.parentElement;
1484
+ if (!preEl) return;
1485
+ const code = codeEl.textContent || "";
1486
+ if (!code.trim()) return;
1487
+ const cached = svgCache.get(code);
1488
+ if (cached) {
1489
+ replaceWithSvg(preEl, cached, className);
1490
+ return;
1491
+ }
1492
+ try {
1493
+ let hash = 0;
1494
+ for (let i = 0; i < code.length; i++) {
1495
+ hash = (hash << 5) - hash + code.charCodeAt(i);
1496
+ hash = hash & hash;
1497
+ }
1498
+ const id = `mermaid-${Math.abs(hash).toString(36)}-${Date.now()}`;
1499
+ const { svg } = await mermaid.render(id, code);
1500
+ svgCache.set(code, svg);
1501
+ replaceWithSvg(preEl, svg, className);
1502
+ } catch (err) {
1503
+ if (onError) onError(code, err instanceof Error ? err : new Error(String(err)));
1504
+ }
1505
+ });
1506
+ await Promise.all(renderPromises);
979
1507
  }
980
1508
  function replaceWithSvg(preEl, svg, className) {
981
- const wrapper = document.createElement("div");
982
- wrapper.className = className;
983
- wrapper.innerHTML = svg;
984
- preEl.replaceWith(wrapper);
1509
+ const wrapper = document.createElement("div");
1510
+ wrapper.className = className;
1511
+ wrapper.innerHTML = svg;
1512
+ preEl.replaceWith(wrapper);
985
1513
  }
986
-
987
- // src/resolveLinks.ts
1514
+ //#endregion
1515
+ //#region src/resolveLinks.ts
1516
+ /**
1517
+ * Rewrite relative links in rendered HTML.
1518
+ *
1519
+ * Processes all `href="..."` attributes, skipping:
1520
+ * - Absolute URLs (http://, https://, mailto:, etc.)
1521
+ * - Anchor-only links (#section)
1522
+ * - Already-absolute paths (/path/to/file)
1523
+ *
1524
+ * @example
1525
+ * ```ts
1526
+ * import { renderMarkdown, resolveLinks } from "vantage-md";
1527
+ *
1528
+ * const { html } = await renderMarkdown(content);
1529
+ *
1530
+ * // Simple: prepend a base path
1531
+ * const resolved = resolveLinks(html, { basePath: "/docs/", currentPath: "guides/setup.md" });
1532
+ *
1533
+ * // Custom: full control over link rewriting
1534
+ * const resolved = resolveLinks(html, {
1535
+ * currentPath: "guides/setup.md",
1536
+ * rewriter: (href, currentPath) => `/kb/${currentPath}/../${href}`,
1537
+ * });
1538
+ * ```
1539
+ */
988
1540
  function resolveLinks(html, options = {}) {
989
- const { basePath = "/", rewriter, currentPath = "" } = options;
990
- const parts = currentPath.split("/");
991
- parts.pop();
992
- const currentDir = parts.join("/");
993
- return html.replace(
994
- /href="([^"]*?)"/g,
995
- (_match, href) => {
996
- if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("mailto:") || href.startsWith("data:") || href.startsWith("#") || href.startsWith("/")) {
997
- return `href="${href}"`;
998
- }
999
- if (rewriter) {
1000
- const result = rewriter(href, currentPath);
1001
- if (result !== null) {
1002
- return `href="${result}"`;
1003
- }
1004
- return `href="${href}"`;
1005
- }
1006
- const [pathPart, hashPart] = href.split("#");
1007
- const cleanHref = pathPart.replace(/^\.\//, "");
1008
- const resolvedPath = currentDir ? `${currentDir}/${cleanHref}` : cleanHref;
1009
- const base = basePath.endsWith("/") ? basePath : `${basePath}/`;
1010
- const finalHref = `${base}${resolvedPath}${hashPart ? `#${hashPart}` : ""}`;
1011
- return `href="${finalHref}"`;
1012
- }
1013
- );
1541
+ const { basePath = "/", rewriter, currentPath = "" } = options;
1542
+ const parts = currentPath.split("/");
1543
+ parts.pop();
1544
+ const currentDir = parts.join("/");
1545
+ return html.replace(/href="([^"]*?)"/g, (_match, href) => {
1546
+ if (href.startsWith("http://") || href.startsWith("https://") || href.startsWith("mailto:") || href.startsWith("data:") || href.startsWith("#") || href.startsWith("/")) return `href="${href}"`;
1547
+ if (rewriter) {
1548
+ const result = rewriter(href, currentPath);
1549
+ if (result !== null) return `href="${result}"`;
1550
+ return `href="${href}"`;
1551
+ }
1552
+ const [pathPart, hashPart] = href.split("#");
1553
+ const cleanHref = pathPart.replace(/^\.\//, "");
1554
+ const resolvedPath = currentDir ? `${currentDir}/${cleanHref}` : cleanHref;
1555
+ return `href="${`${basePath.endsWith("/") ? basePath : `${basePath}/`}${resolvedPath}${hashPart ? `#${hashPart}` : ""}`}"`;
1556
+ });
1014
1557
  }
1015
-
1016
- // src/styleGuide.ts
1017
- var STYLE_GUIDE = `## Markdown style guide (for Vantage viewer)
1558
+ //#endregion
1559
+ //#region src/styleGuide.ts
1560
+ /**
1561
+ * The canonical Vantage Markdown style guide.
1562
+ *
1563
+ * This string is the single source of truth for the conventions Vantage's
1564
+ * renderer expects. Two consumers read it:
1565
+ *
1566
+ * - the in-app "Style Guide for Agents" modal, which shows it with a copy
1567
+ * button, and
1568
+ * - the `vantage-check style-guide` command, which prints it so an agent can
1569
+ * fetch it without a human in the loop.
1570
+ *
1571
+ * Every rule stated here should be one a checker can enforce or a renderer
1572
+ * actually cares about — if a line is neither, it does not belong.
1573
+ */
1574
+ const STYLE_GUIDE = `## Markdown style guide (for Vantage viewer)
1018
1575
 
1019
1576
  When writing or updating markdown documents that will be viewed in Vantage, follow these conventions:
1020
1577
 
1021
1578
  ### Structure
1022
- - Use headings (## and ###) to organize content \u2014 they become navigable outline anchors.
1579
+ - Use headings (## and ###) to organize content — they become navigable outline anchors.
1023
1580
  - Keep paragraphs focused and concise. Break up dense text with subheadings, lists, or tables.
1024
1581
 
1025
1582
  ### Links and cross-references
@@ -1028,11 +1585,11 @@ When writing or updating markdown documents that will be viewed in Vantage, foll
1028
1585
  - Subdirectory: \`[Design Doc](./design/auth.md)\`
1029
1586
  - Parent / sibling folder: \`[Overview](../overview.md)\` or \`[Spec](../specs/api.md)\`
1030
1587
  - **Never use leading slashes**:
1031
- - \u274C \`[Doc](/docs/guide.md)\` (breaks web routing and multi-repo scoping)
1032
- - \u2705 \`[Doc](../docs/guide.md)\` or \`[Doc](./guide.md)\`
1588
+ - ❌ \`[Doc](/docs/guide.md)\` (breaks web routing and multi-repo scoping)
1589
+ - ✅ \`[Doc](../docs/guide.md)\` or \`[Doc](./guide.md)\`
1033
1590
  - **Never use absolute filesystem paths or URI schemes**:
1034
- - \u274C \`file:///workspace/docs/guide.md\`, \`/workspace/docs/guide.md\`, \`C:\\...\`
1035
- - \u2705 \`[Doc](./guide.md)\` or \`[Doc](../guide.md)\`
1591
+ - ❌ \`file:///workspace/docs/guide.md\`, \`/workspace/docs/guide.md\`, \`C:\\...\`
1592
+ - ✅ \`[Doc](./guide.md)\` or \`[Doc](../guide.md)\`
1036
1593
  - **Always include the file extension**: Use \`.md\`, \`.ts\`, \`.go\`, etc. (e.g. \`[Model](model.go)\`).
1037
1594
  - **Line anchors and ranges**:
1038
1595
  - Link to specific lines: \`[Handler](../server/api.go#L42)\` or \`[Range](../server/api.go#L42-L58)\`
@@ -1043,8 +1600,8 @@ When writing or updating markdown documents that will be viewed in Vantage, foll
1043
1600
  - Cross-doc: \`[Architecture](../overview.md#system-architecture)\`
1044
1601
  - Anchor slugs are lowercase, hyphenated, and punctuation-stripped.
1045
1602
  - **Backticks in links**: Place backticks inside the link label, not around the markdown link syntax:
1046
- - \u2705 \`[\`config.json\`](./config.json)\` or \`[config.json](./config.json)\`
1047
- - \u274C \`\`[config.json](./config.json)\`\`
1603
+ - ✅ \`[\`config.json\`](./config.json)\` or \`[config.json](./config.json)\`
1604
+ - ❌ \`\`[config.json](./config.json)\`\`
1048
1605
 
1049
1606
  ### Frontmatter (Metadata)
1050
1607
  - Include structured metadata at the very top of docs delimited by \`---\` (YAML) or \`+++\` (TOML). Vantage renders this as a metadata card:
@@ -1060,9 +1617,9 @@ vantage:
1060
1617
  status-chip: true # show \`status\` as a chip above the metadata card
1061
1618
  ---
1062
1619
  \`\`\`
1063
- - **Nothing may sit above the opening delimiter** \u2014 not a blank line, not an editorial comment, not a \`<!-- vantage: \u2026 -->\` directive. Frontmatter is recognised only at the very first byte of the file (in Vantage, on GitHub, and in every other reader), so one line above it turns the whole block into body text: a horizontal rule followed by a heading made of the raw keys, with every field lost. \`vantage-check\` reports it as \`frontmatter/not-at-top\`.
1620
+ - **Nothing may sit above the opening delimiter** — not a blank line, not an editorial comment, not a \`<!-- vantage: … -->\` directive. Frontmatter is recognised only at the very first byte of the file (in Vantage, on GitHub, and in every other reader), so one line above it turns the whole block into body text: a horizontal rule followed by a heading made of the raw keys, with every field lost. \`vantage-check\` reports it as \`frontmatter/not-at-top\`.
1064
1621
  - **\`vantage:\` is Vantage's own reserved key.** It holds chrome that belongs to the file rather than to a section, it never shows up in the metadata card, and every other renderer ignores it. One key today: \`status-chip\`.
1065
- - **Prefer \`status-chip: true\`**, which shows the document's own \`status:\` and therefore cannot disagree with it. A literal \`status-chip: accepted\` is accepted too, but it is a second value that goes stale on its own \u2014 \`vantage-check\` reports the disagreement.
1622
+ - **Prefer \`status-chip: true\`**, which shows the document's own \`status:\` and therefore cannot disagree with it. A literal \`status-chip: accepted\` is accepted too, but it is a second value that goes stale on its own — \`vantage-check\` reports the disagreement.
1066
1623
  - The chip's vocabulary is \`status\`'s, exactly: \`draft | in-review | accepted | deprecated\`, lowercase. \`Draft\` renders no chip at all, silently.
1067
1624
 
1068
1625
  ### Mermaid diagrams
@@ -1101,7 +1658,7 @@ flowchart TD
1101
1658
 
1102
1659
  ### Vantage directives (optional, and Vantage-only)
1103
1660
 
1104
- Vantage reads a few styling hints from ordinary HTML comments. Every other renderer \u2014 GitHub included \u2014 drops them, so a document has to read exactly the same without them: directives decorate, they never carry meaning. One goes on a line of its own, with a blank line after it, and applies to the block that follows:
1661
+ Vantage reads a few styling hints from ordinary HTML comments. Every other renderer — GitHub included — drops them, so a document has to read exactly the same without them: directives decorate, they never carry meaning. One goes on a line of its own, with a blank line after it, and applies to the block that follows:
1105
1662
 
1106
1663
  \`\`\`markdown
1107
1664
  <!-- vantage: section tone=warning badge=stale -->
@@ -1112,17 +1669,18 @@ The steps below predate the rewrite.
1112
1669
  \`\`\`
1113
1670
 
1114
1671
  - **Three names**: \`section\` (the heading and everything under it), \`block\` (the one block after it), \`oq\` (one answerable Open Question).
1115
- - **The keys and values are a closed set**: \`tone\` = \`note | tip | important | warning | caution | muted\`; \`emphasis\` = \`strong | normal | quiet\`; \`badge\` = \`draft | stale | blocked | done | wip\`; \`collapsed\` = \`true | false\`. Name a *tone*, never a colour \u2014 the theme decides what a warning looks like, in light mode, in dark mode, and in print.
1672
+ - **The keys and values are a closed set**: \`tone\` = \`note | tip | important | warning | caution | muted\`; \`emphasis\` = \`strong | normal | quiet\`; \`badge\` = \`draft | stale | blocked | done | wip\`; \`collapsed\` = \`true | false\`. Name a *tone*, never a colour — the theme decides what a warning looks like, in light mode, in dark mode, and in print.
1116
1673
  - **Use them sparingly.** One or two per document, on the sections that genuinely differ. A document where everything is toned says nothing, and a rainbow one is harder to read than a plain one.
1117
- - **Anything outside those sets is silently ignored** \u2014 nothing breaks, and nothing styles either. Run \`vantage-check\` on the document: the \`vantage/*\` rules are the only thing that will ever tell you a directive did nothing.
1118
- - **Always close the comment with \`-->\`.** Never \`--!>\`, and never leave it open: Markdown reads every line below an unclosed \`<!--\` as part of the comment, and the whole rest of the document vanishes from the page. For the same reason \`-->\` cannot appear *inside* a value \u2014 it ends the comment early and spills the remainder into the page as literal text.
1119
- - **In a list, indent the directive inside the item**, with blank lines around it (below). At the start of a line between two items it ends the list and starts a second one, which changes the numbering and the spacing in every renderer \u2014 the one thing a directive must never do.
1120
- - **A \`leaning\` restates the leaning; it is never "yes".** The one-click button in review mode files that text as a review comment, and the comment is all the agent reading it has \u2014 nobody remembers which button was clicked. \`leaning="Yes"\` beside a two-branch question is a support ticket.
1674
+ - **Anything outside those sets is silently ignored** — nothing breaks, and nothing styles either. Run \`vantage-check\` on the document: the \`vantage/*\` rules are the only thing that will ever tell you a directive did nothing.
1675
+ - **Always close the comment with \`-->\`.** Never \`--!>\`, and never leave it open: Markdown reads every line below an unclosed \`<!--\` as part of the comment, and the whole rest of the document vanishes from the page. For the same reason \`-->\` cannot appear *inside* a value — it ends the comment early and spills the remainder into the page as literal text.
1676
+ - **In a list, indent the directive inside the item**, with blank lines around it (below). At the start of a line between two items it ends the list and starts a second one, which changes the numbering and the spacing in every renderer — the one thing a directive must never do.
1677
+ - **Every open question (\u{1F4AC}) with a stated leaning gets an \`oq\` directive.** The convention's prose — the emoji, the \`OQ-N\` id, the \`_Leaning:_\` line, the fill-in \`**Answer:**\` — produces no button on its own. Writing the convention and stopping there is the most common way this feature goes missing: the questions look complete, review mode is on, and there is nothing to click. **\`vantage-check\` reports it as an error** (\`vantage/oq-missing\`), because a question awaiting a ruling that the reviewer cannot file is not a style preference. Mark it \u{1F512} if it is blocked on something upstream and cannot be answered yet, or \u2705 once it is decided; either state needs no directive.
1678
+ - **A \`leaning\` restates the leaning; it is never "yes".** The one-click button in review mode files that text as a review comment, and the comment is all the agent reading it has — nobody remembers which button was clicked. \`leaning="Yes"\` beside a two-branch question is a support ticket.
1121
1679
 
1122
1680
  \`\`\`markdown
1123
1681
  1. **OQ-9: Queue position on re-entry.**
1124
1682
 
1125
- <!-- vantage: oq id=OQ-9 leaning="Back of the queue \u2014 the fix might interact with what merged while it was out." -->
1683
+ <!-- vantage: oq id=OQ-9 leaning="Back of the queue — the fix might interact with what merged while it was out." -->
1126
1684
 
1127
1685
  _Leaning:_ Back of the queue.
1128
1686
  \`\`\`
@@ -1130,10 +1688,10 @@ The steps below predate the rewrite.
1130
1688
  ### Tables, task lists, and math
1131
1689
  - **Tables**: Use standard markdown tables for structured comparisons and schemas.
1132
1690
  - **Task lists**: Use \`- [ ]\` and \`- [x]\` for actionable checklists and status tracking.
1133
- - **LaTeX Math**: Use \`$$...$$\` for *all* KaTeX math \u2014 display blocks (\`$$\` alone on its own lines) and inline alike (\`$$E = mc^2$$\` mid-sentence).
1691
+ - **LaTeX Math**: Use \`$$...$$\` for *all* KaTeX math — display blocks (\`$$\` alone on its own lines) and inline alike (\`$$E = mc^2$$\` mid-sentence).
1134
1692
  - Single dollars are **not** math delimiters: \`$HOME\` and \`$100\` stay literal, so prose and shell snippets are safe to write as-is.
1135
1693
  `;
1694
+ //#endregion
1695
+ export { ALERT_TITLES, DIRECTIVE_NAMES, DIRECTIVE_VOCABULARY, DOC_STATUSES, DOC_STATUS_TONES, SAFE_STYLE, STYLE_GUIDE, VANTAGE_ALERTS, VANTAGE_BADGES, VANTAGE_COLLAPSED, VANTAGE_EMPHASIS, VANTAGE_FRONTMATTER_KEYS, VANTAGE_OQ_HOST_TARGETS, VANTAGE_RUNS, VANTAGE_SENTINEL, VANTAGE_TONES, buildPipeline, buildRemarkPlugins, clearLineAnchorHighlights, hasVantageSentinel, isDocStatus, parseFrontmatter, parseLineAnchor, parseVantageDirective, readVantageFrontmatter, rehypeSourceLines, rehypeVantageAlerts, rehypeVantageDirectives, renderMarkdown, renderMermaidBlocks, resolveLinks, sanitizeSchema, scrollToLineAnchor };
1136
1696
 
1137
- export { DIRECTIVE_NAMES, DIRECTIVE_VOCABULARY, DOC_STATUSES, DOC_STATUS_TONES, SAFE_STYLE, STYLE_GUIDE, VANTAGE_BADGES, VANTAGE_COLLAPSED, VANTAGE_EMPHASIS, VANTAGE_FRONTMATTER_KEYS, VANTAGE_OQ_HOST_TARGETS, VANTAGE_RUNS, VANTAGE_SENTINEL, VANTAGE_TONES, buildPipeline, buildRemarkPlugins, clearLineAnchorHighlights, hasVantageSentinel, isDocStatus, parseFrontmatter, parseLineAnchor, parseVantageDirective, readVantageFrontmatter, rehypeSourceLines_default as rehypeSourceLines, rehypeVantageDirectives_default as rehypeVantageDirectives, renderMarkdown, renderMermaidBlocks, resolveLinks, sanitizeSchema, scrollToLineAnchor };
1138
- //# sourceMappingURL=index.js.map
1139
1697
  //# sourceMappingURL=index.js.map