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