vantage-md 0.7.1 → 0.8.1
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/CHANGELOG.md +184 -157
- package/README.md +26 -0
- package/dist/directives.css +671 -0
- package/dist/index.cjs +968 -134
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +130 -34
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +130 -34
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +957 -130
- package/dist/index.js.map +1 -1
- package/dist/inline-svg.css +55 -0
- package/dist/line-anchor.css +22 -0
- package/dist/react.cjs +915 -168
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +87 -42
- package/dist/react.d.cts.map +1 -1
- package/dist/react.d.ts +87 -42
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +909 -164
- package/dist/react.js.map +1 -1
- package/dist/styles.css +3 -1
- package/dist/task-list.css +157 -0
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -28,10 +28,6 @@ let remark_rehype = require("remark-rehype");
|
|
|
28
28
|
remark_rehype = __toESM(remark_rehype, 1);
|
|
29
29
|
let rehype_stringify = require("rehype-stringify");
|
|
30
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
31
|
let rehype_raw = require("rehype-raw");
|
|
36
32
|
rehype_raw = __toESM(rehype_raw, 1);
|
|
37
33
|
let rehype_sanitize = require("rehype-sanitize");
|
|
@@ -43,6 +39,10 @@ rehype_katex = __toESM(rehype_katex, 1);
|
|
|
43
39
|
let rehype_slug = require("rehype-slug");
|
|
44
40
|
rehype_slug = __toESM(rehype_slug, 1);
|
|
45
41
|
let unist_util_visit = require("unist-util-visit");
|
|
42
|
+
let remark_gfm = require("remark-gfm");
|
|
43
|
+
remark_gfm = __toESM(remark_gfm, 1);
|
|
44
|
+
let remark_math = require("remark-math");
|
|
45
|
+
remark_math = __toESM(remark_math, 1);
|
|
46
46
|
let yaml = require("yaml");
|
|
47
47
|
yaml = __toESM(yaml, 1);
|
|
48
48
|
let smol_toml = require("smol-toml");
|
|
@@ -91,21 +91,21 @@ const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
|
91
91
|
"hr",
|
|
92
92
|
"div"
|
|
93
93
|
]);
|
|
94
|
-
function visit$
|
|
94
|
+
function visit$2(node, offset) {
|
|
95
95
|
if ("children" in node) {
|
|
96
96
|
for (const child of node.children) if (child.type === "element") {
|
|
97
97
|
if (BLOCK_TAGS.has(child.tagName) && child.position?.start?.line) {
|
|
98
98
|
child.properties = child.properties || {};
|
|
99
99
|
child.properties["dataSourceLine"] = child.position.start.line + offset;
|
|
100
100
|
}
|
|
101
|
-
visit$
|
|
101
|
+
visit$2(child, offset);
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
const rehypeSourceLines = (options) => {
|
|
106
106
|
const offset = options?.offset ?? 0;
|
|
107
107
|
return (tree) => {
|
|
108
|
-
visit$
|
|
108
|
+
visit$2(tree, offset);
|
|
109
109
|
};
|
|
110
110
|
};
|
|
111
111
|
//#endregion
|
|
@@ -158,6 +158,12 @@ const VANTAGE_ALERTS = [
|
|
|
158
158
|
"warning",
|
|
159
159
|
"caution"
|
|
160
160
|
];
|
|
161
|
+
/**
|
|
162
|
+
* The class on the title element this plugin injects, which
|
|
163
|
+
* `styles/directives.css` styles. Exported because the sanitizer runs after
|
|
164
|
+
* this plugin and admits a `div`'s class only when it is this one.
|
|
165
|
+
*/
|
|
166
|
+
const ALERT_TITLE_CLASS = "vantage-alert-title";
|
|
161
167
|
/** The visible label per kind. Title case, as GitHub renders it. */
|
|
162
168
|
const ALERT_TITLES = {
|
|
163
169
|
note: "Note",
|
|
@@ -181,7 +187,7 @@ const ALERT_TITLES = {
|
|
|
181
187
|
* anchored test on the first text node is enough, and the plugin does not have
|
|
182
188
|
* to reassemble the marker across siblings.
|
|
183
189
|
*/
|
|
184
|
-
const
|
|
190
|
+
const ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/;
|
|
185
191
|
/** The first child, if it is an element. */
|
|
186
192
|
function firstElement(node) {
|
|
187
193
|
const child = node.children.find((c) => c.type === "element" || c.type === "text" && c.value.trim() !== "");
|
|
@@ -208,7 +214,7 @@ function rehypeVantageAlerts() {
|
|
|
208
214
|
if (paragraph === void 0 || paragraph.tagName !== "p") return;
|
|
209
215
|
const lead = paragraph.children[0];
|
|
210
216
|
if (lead === void 0 || lead.type !== "text") return;
|
|
211
|
-
const match =
|
|
217
|
+
const match = ALERT_MARKER.exec(lead.value);
|
|
212
218
|
if (match === null) return;
|
|
213
219
|
const kind = match[1].toLowerCase();
|
|
214
220
|
lead.value = lead.value.slice(match[0].length);
|
|
@@ -220,7 +226,7 @@ function rehypeVantageAlerts() {
|
|
|
220
226
|
node.children.unshift({
|
|
221
227
|
type: "element",
|
|
222
228
|
tagName: "div",
|
|
223
|
-
properties: { className: [
|
|
229
|
+
properties: { className: [ALERT_TITLE_CLASS] },
|
|
224
230
|
children: [{
|
|
225
231
|
type: "text",
|
|
226
232
|
value: ALERT_TITLES[kind]
|
|
@@ -267,16 +273,78 @@ const VANTAGE_SENTINEL = "vantage:";
|
|
|
267
273
|
*
|
|
268
274
|
* Position picks the target; the name picks the extent. `section` before a
|
|
269
275
|
* heading reaches the heading's whole section, `block` reaches one block, and
|
|
270
|
-
* `
|
|
271
|
-
*
|
|
272
|
-
*
|
|
276
|
+
* `question` declares one question in any state, as `oq`, the name it
|
|
277
|
+
* replaces, still does (`VANTAGE_QUESTION_NAMES`). The name cannot disagree
|
|
278
|
+
* with position — it only says how far the stamp reaches — so §4.2's refusal
|
|
279
|
+
* of a `scope=` key stands.
|
|
280
|
+
*
|
|
281
|
+
* `fallback` is the one name that stamps nothing: it withholds the block after
|
|
282
|
+
* it, which is written for the renderers that cannot show something Vantage
|
|
283
|
+
* shows — an inline `<svg>`, today. Every renderer that does not know the name
|
|
284
|
+
* drops the comment and shows the block, a 0.7.x Vantage included, which is
|
|
285
|
+
* the whole mechanism: the name is new, so no older reader gives it a meaning
|
|
286
|
+
* (`docs/reference/inline-markup.md`, "Fallback blocks").
|
|
273
287
|
*/
|
|
274
288
|
const DIRECTIVE_NAMES = [
|
|
275
289
|
"section",
|
|
276
290
|
"block",
|
|
277
|
-
"oq"
|
|
291
|
+
"oq",
|
|
292
|
+
"question",
|
|
293
|
+
"fallback"
|
|
278
294
|
];
|
|
279
295
|
/**
|
|
296
|
+
* The two names that declare a question: `question`, the one to write, and
|
|
297
|
+
* `oq`, which it replaces and which every Vantage keeps reading.
|
|
298
|
+
*
|
|
299
|
+
* - **`question`** declares a question in any state: open (💬, 💬 🤷 or no
|
|
300
|
+
* marker at all), 🔒 blocked or ✅ answered. The state comes from the marker
|
|
301
|
+
* and never from the name, so a question that changes state changes its
|
|
302
|
+
* marker and nothing else. A viewer from 0.8 on offers Take this leaning on
|
|
303
|
+
* an open one, and never on a 🔒 or ✅ one.
|
|
304
|
+
* - **`oq`** is deprecated, never removed. It keeps the meaning it shipped
|
|
305
|
+
* with in 0.7, an open question to answer in one click, because documents
|
|
306
|
+
* outlive releases and a release never gives existing notation a new
|
|
307
|
+
* meaning (`docs/design/checker-version-skew.md`, P0). Every viewer before
|
|
308
|
+
* 0.8 offers the button on every `oq` it meets, whatever its marker says,
|
|
309
|
+
* so `vantage-check` reports one on a 🔒 or ✅ question as an error and any
|
|
310
|
+
* other as a warning that names the `question` to write.
|
|
311
|
+
*
|
|
312
|
+
* The two take the same keys, `id` and `leaning`, with one grammar. A viewer
|
|
313
|
+
* that predates `question` drops it whole, as it drops every name it does not
|
|
314
|
+
* know, so on a question written with it that viewer offers no button and no
|
|
315
|
+
* anchor: it does less, and misreads nothing. A run holding both names is one
|
|
316
|
+
* question, read as such a viewer reads it: where both set a key, the `oq`'s
|
|
317
|
+
* value wins.
|
|
318
|
+
*/
|
|
319
|
+
const VANTAGE_QUESTION_NAMES = ["oq", "question"];
|
|
320
|
+
/** Whether `name` declares a question, `question` or the deprecated `oq`. */
|
|
321
|
+
function isQuestionDirective(name) {
|
|
322
|
+
return name === "oq" || name === "question";
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* The attribute every question carries in the rendered page, whichever name
|
|
326
|
+
* declared it, with the value `"true"`. It is how a reader of this package's
|
|
327
|
+
* markup finds every question: `[data-vantage-question]`
|
|
328
|
+
* (`VANTAGE_QUESTION_SELECTOR`).
|
|
329
|
+
*/
|
|
330
|
+
const VANTAGE_QUESTION_ATTRIBUTE = "data-vantage-question";
|
|
331
|
+
/** `[data-vantage-question]`: every question in a rendered page. */
|
|
332
|
+
const VANTAGE_QUESTION_SELECTOR = `[${VANTAGE_QUESTION_ATTRIBUTE}]`;
|
|
333
|
+
/**
|
|
334
|
+
* The attribute a question declared with `oq` carries besides
|
|
335
|
+
* `VANTAGE_QUESTION_ATTRIBUTE`, with the value `"true"`. It means what it has
|
|
336
|
+
* meant since 0.7, "declared with `oq`", and nothing more: whether a question
|
|
337
|
+
* offers Take this leaning is read from its state (`questionOffersTake`),
|
|
338
|
+
* never from this attribute.
|
|
339
|
+
*/
|
|
340
|
+
const VANTAGE_OQ_ATTRIBUTE = "data-vantage-oq";
|
|
341
|
+
/**
|
|
342
|
+
* The attribute that carries a question's `leaning=`, normalized
|
|
343
|
+
* (`normalizeLeaning`), on the element `VANTAGE_QUESTION_ATTRIBUTE` is on,
|
|
344
|
+
* whichever name declared it. Absent when the directive states none.
|
|
345
|
+
*/
|
|
346
|
+
const VANTAGE_LEANING_ATTRIBUTE = "data-vantage-leaning";
|
|
347
|
+
/**
|
|
280
348
|
* The `tone` vocabulary: GitHub's alert words plus `muted`.
|
|
281
349
|
*
|
|
282
350
|
* Semantic, never chromatic (P2, Ledger OQ-3). A document says what a section
|
|
@@ -368,13 +436,32 @@ const VANTAGE_STYLE_TARGETS = [
|
|
|
368
436
|
"div"
|
|
369
437
|
];
|
|
370
438
|
/**
|
|
371
|
-
* The tags
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
* neither, so a button on one of them would build an anchor no review pass can
|
|
375
|
-
* find — the "mis-wired button" D6 forbids.
|
|
439
|
+
* The tags a `fallback` directive may **withhold**: `VANTAGE_STYLE_TARGETS`
|
|
440
|
+
* minus the six headings, written as a subtraction so the narrowing stays
|
|
441
|
+
* visible.
|
|
376
442
|
*
|
|
377
|
-
*
|
|
443
|
+
* A heading is refused because it is the document's structure rather than a
|
|
444
|
+
* stand-in for anything: the contents column lists it, other documents link to
|
|
445
|
+
* its slug, and the planning index reads sections under it. A fallback heading
|
|
446
|
+
* would be an anchor that exists on GitHub and nowhere in Vantage. What a
|
|
447
|
+
* fallback holds — a sentence naming the release a drawing needs, a link to
|
|
448
|
+
* the drawing as a file, an ASCII sketch in a fence — is a paragraph, a list,
|
|
449
|
+
* a quote, a code block, a table or a `<div>`, and those are all here.
|
|
450
|
+
*
|
|
451
|
+
* Three callers read it, and they must agree: `rehypeVantageDirectives`, which
|
|
452
|
+
* withholds the block; the planning index's scan, which must not read a
|
|
453
|
+
* question or a link from a block the page never shows; and the checker's
|
|
454
|
+
* `vantage/orphan`, which reports a fallback that withholds nothing.
|
|
455
|
+
*/
|
|
456
|
+
const VANTAGE_FALLBACK_TARGETS = VANTAGE_STYLE_TARGETS.filter((tag) => !/^h[1-6]$/.test(tag));
|
|
457
|
+
/**
|
|
458
|
+
* The tags a question directive, `oq` or `question`, may stamp — strictly the
|
|
459
|
+
* tags the review system can resolve an anchor on (`ANCHOR_TAGS` in the app's
|
|
460
|
+
* `MarkdownViewer`, and the block map in `useReviewHighlights`). `ul`, `ol`,
|
|
461
|
+
* `tr`, `hr` and `div` are in neither, so a button on one of them would build
|
|
462
|
+
* an anchor no review pass can find — the "mis-wired button" D6 forbids.
|
|
463
|
+
*
|
|
464
|
+
* The gap between this list and `VANTAGE_STYLE_TARGETS` is why a question
|
|
378
465
|
* directive at column 0 above a list silently does nothing: the target is the
|
|
379
466
|
* `<ul>`, not the `<li>`. The checker says so.
|
|
380
467
|
*/
|
|
@@ -392,9 +479,11 @@ const VANTAGE_ANCHOR_TARGETS = [
|
|
|
392
479
|
"table"
|
|
393
480
|
];
|
|
394
481
|
/**
|
|
395
|
-
* The tags a
|
|
396
|
-
* `VANTAGE_ANCHOR_TARGETS` minus `pre`
|
|
397
|
-
* subtraction so the narrowing stays
|
|
482
|
+
* The tags a question directive declares a question on, and so the only ones
|
|
483
|
+
* an open question's *button* can sit on — `VANTAGE_ANCHOR_TARGETS` minus `pre`
|
|
484
|
+
* and `table`, written as an explicit subtraction so the narrowing stays
|
|
485
|
+
* visible. One list for both names and every state, so a question that changes
|
|
486
|
+
* state is declared in exactly the same place.
|
|
398
487
|
*
|
|
399
488
|
* Anchorable and button-hosting are different questions, and this is the second
|
|
400
489
|
* one. A comment *can* be anchored on a `<pre>` or a `<table>` — both are in
|
|
@@ -402,17 +491,18 @@ const VANTAGE_ANCHOR_TARGETS = [
|
|
|
402
491
|
* button renders as part of the code, and a `<button>` child of `<table>` is not
|
|
403
492
|
* valid HTML at all, so the parser hoists it out.
|
|
404
493
|
*
|
|
405
|
-
*
|
|
406
|
-
* `useOpenQuestionButtons`,
|
|
407
|
-
* `vantage/orphan`. They were two hand-written lists that
|
|
408
|
-
* checker called an `oq` above a fence fine while the app
|
|
409
|
-
* and said nothing, which is the D5 break this module
|
|
494
|
+
* Every consumer reads it from here: `OQ_HOST_TAGS` in the app's
|
|
495
|
+
* `useOpenQuestionButtons`, the planning index's scan, and the question branch
|
|
496
|
+
* of the checker's `vantage/orphan`. They were two hand-written lists that
|
|
497
|
+
* disagreed — the checker called an `oq` above a fence fine while the app
|
|
498
|
+
* rendered no button and said nothing, which is the D5 break this module
|
|
499
|
+
* exists to prevent.
|
|
410
500
|
*/
|
|
411
501
|
const VANTAGE_OQ_HOST_TARGETS = VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
|
|
412
502
|
/**
|
|
413
|
-
* The shape of
|
|
414
|
-
*
|
|
415
|
-
* and a bare `OQ6` are not.
|
|
503
|
+
* The shape of a question directive's `id`, on `oq` and `question` alike:
|
|
504
|
+
* `OQ-` then an optional short uppercase prefix then digits. `OQ-9`, `OQ-TP6`
|
|
505
|
+
* and `OQ-A03` are ids; `OQ-foo`, `OQ-tp6` and a bare `OQ6` are not.
|
|
416
506
|
*
|
|
417
507
|
* The prefix is what keeps ids distinct once one document references another's
|
|
418
508
|
* questions — `trust-paths.md`'s `OQ-4` and a design sketch's `OQ-4` are
|
|
@@ -420,10 +510,15 @@ const VANTAGE_OQ_HOST_TARGETS = VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "
|
|
|
420
510
|
* reference means. It is optional because most documents never leave their own
|
|
421
511
|
* file, and requiring it everywhere would fire on every single-doc sketch.
|
|
422
512
|
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
* the checker
|
|
426
|
-
*
|
|
513
|
+
* Read from here, never re-spelled, by the sanitizer that allowlists the value,
|
|
514
|
+
* by the checker's `collectOqIds` (which feeds `vantage/oq-id-format` and the
|
|
515
|
+
* anchors the checker accepts) and by the planning index. The directive plugin
|
|
516
|
+
* that stamps the anchor deliberately does not test it: it stamps any non-empty
|
|
517
|
+
* id and leaves refusing a malformed one to the sanitizer. A re-spelled copy is
|
|
518
|
+
* how the checker starts calling a working anchor malformed — and the patterns
|
|
519
|
+
* that look for an id in text (`OQ_REFERENCE` and `OQ_EXACT` in the checker's
|
|
520
|
+
* `rules/references.ts`, `OQ_TOKEN` in `planning/scan.ts` and in
|
|
521
|
+
* `FrontmatterDisplay.tsx`) restate this shape, so they change with it.
|
|
427
522
|
*/
|
|
428
523
|
const VANTAGE_OQ_ID = /^OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+$/;
|
|
429
524
|
/**
|
|
@@ -439,11 +534,12 @@ const VANTAGE_OQ_ID = /^OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+$/;
|
|
|
439
534
|
* that a screen reader can say. They were private constants in the checker until
|
|
440
535
|
* the second consumer arrived.
|
|
441
536
|
*
|
|
442
|
-
* `open` is the only state that wants a one-click answer
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
537
|
+
* `open` is the only state that wants a one-click answer, so it is the only one
|
|
538
|
+
* a viewer offers Take this leaning on (`questionOffersTake`). A `settled` or
|
|
539
|
+
* `blocked` question carries no control — a control offering to answer a
|
|
540
|
+
* question that is already decided, or that cannot be answered yet, is a lie.
|
|
541
|
+
* The checker keys on the distinction rather than on the word "Leaning:"
|
|
542
|
+
* alone. Either non-open marker wins when both appear on one item.
|
|
447
543
|
*/
|
|
448
544
|
const VANTAGE_OQ_STATUS = {
|
|
449
545
|
/** 💬 — an active decision awaiting a ruling. */
|
|
@@ -478,6 +574,20 @@ function vantageOqStatus(text) {
|
|
|
478
574
|
if (text.includes(VANTAGE_OQ_STATUS.open)) return "open";
|
|
479
575
|
return null;
|
|
480
576
|
}
|
|
577
|
+
/**
|
|
578
|
+
* Whether a question in this state offers Take this leaning in review mode: an
|
|
579
|
+
* open one, marked 💬 or 💬 🤷, or one carrying no marker, which counts as
|
|
580
|
+
* open. A 🔒 question cannot be answered yet and a ✅ one has been ruled, so
|
|
581
|
+
* neither has a leaning left to take, whatever its directive's name.
|
|
582
|
+
*
|
|
583
|
+
* One home for the rule, because every surface that answers it must agree:
|
|
584
|
+
* the page's button and the planning card, the counts of questions waiting on
|
|
585
|
+
* a human, and `vantage-check`, which reports an `oq` on a question this says
|
|
586
|
+
* no to, since every viewer before 0.8 offers the button there anyway.
|
|
587
|
+
*/
|
|
588
|
+
function questionOffersTake(status) {
|
|
589
|
+
return status === null || status === "open";
|
|
590
|
+
}
|
|
481
591
|
const STYLE_KEYS = {
|
|
482
592
|
tone: VANTAGE_TONES,
|
|
483
593
|
emphasis: VANTAGE_EMPHASIS,
|
|
@@ -488,10 +598,21 @@ const STYLE_KEYS = {
|
|
|
488
598
|
* Name → key → the closed value set for that key.
|
|
489
599
|
*
|
|
490
600
|
* `section` and `block` share their keys: they differ in *extent*, not in what
|
|
491
|
-
* they can say.
|
|
492
|
-
*
|
|
493
|
-
* neither can be value-allowlisted,
|
|
494
|
-
* than left to a caller to guess.
|
|
601
|
+
* they can say. The two question names share theirs too, and they are the
|
|
602
|
+
* design's only values with no closed set — `id` is a token an author chose
|
|
603
|
+
* and `leaning` is a sentence (§8.3) — so neither can be value-allowlisted,
|
|
604
|
+
* which is recorded here as `null` rather than left to a caller to guess.
|
|
605
|
+
*
|
|
606
|
+
* `question` takes exactly `oq`'s keys and nothing more. Starting narrow is
|
|
607
|
+
* the forward-compatible choice: a key added later is one older viewers drop
|
|
608
|
+
* pair by pair, while a key accepted now could never be given a meaning (P0).
|
|
609
|
+
* A `leaning` on a 🔒 or ✅ question is allowed and simply not offered
|
|
610
|
+
* (`questionOffersTake`), so changing a question's state changes only its
|
|
611
|
+
* marker.
|
|
612
|
+
*
|
|
613
|
+
* `fallback` takes no keys: whether a block is withheld is the whole of what
|
|
614
|
+
* it says, and a key added later is dropped pair by pair by this release, as
|
|
615
|
+
* D2 drops any other.
|
|
495
616
|
*/
|
|
496
617
|
const DIRECTIVE_VOCABULARY = {
|
|
497
618
|
section: STYLE_KEYS,
|
|
@@ -499,9 +620,59 @@ const DIRECTIVE_VOCABULARY = {
|
|
|
499
620
|
oq: {
|
|
500
621
|
id: null,
|
|
501
622
|
leaning: null
|
|
502
|
-
}
|
|
623
|
+
},
|
|
624
|
+
question: {
|
|
625
|
+
id: null,
|
|
626
|
+
leaning: null
|
|
627
|
+
},
|
|
628
|
+
fallback: {}
|
|
503
629
|
};
|
|
504
630
|
/**
|
|
631
|
+
* Merge the question directives of one run — the comments up to the block they
|
|
632
|
+
* land on — into the one question they declare, or `undefined` when the run
|
|
633
|
+
* holds none. Directives of any other name are skipped.
|
|
634
|
+
*
|
|
635
|
+
* A run holding an `oq` is read exactly as a viewer that predates `question`
|
|
636
|
+
* reads it: that viewer drops every `question` and reads the `oq` alone, so
|
|
637
|
+
* the `oq` wins wherever it appears in the run, and only its keys apply — a
|
|
638
|
+
* key only a `question` in the same run sets applies nowhere, in no release.
|
|
639
|
+
* That is the one reading every viewer agrees on (`VANTAGE_QUESTION_NAMES`),
|
|
640
|
+
* so the same bytes file the same Take in 0.7 and in 0.8. Within one name, the
|
|
641
|
+
* last value of a key wins, as `section` and `block` resolve theirs. A pair
|
|
642
|
+
* its name does not accept, or a value outside a closed set, is dropped (D2).
|
|
643
|
+
*
|
|
644
|
+
* One function for every reader of a run: the plugin that stamps the question
|
|
645
|
+
* and the planning index that reads it, which the checker reads a question
|
|
646
|
+
* through, so none can disagree about which leaning or which id a run carries
|
|
647
|
+
* (D5).
|
|
648
|
+
*/
|
|
649
|
+
function mergeQuestionRun(run) {
|
|
650
|
+
let name;
|
|
651
|
+
for (const directive of run) {
|
|
652
|
+
if (!isQuestionDirective(directive.name)) continue;
|
|
653
|
+
if (name !== "oq") name = directive.name;
|
|
654
|
+
}
|
|
655
|
+
if (name === void 0) return void 0;
|
|
656
|
+
const accepted = DIRECTIVE_VOCABULARY[name] ?? {};
|
|
657
|
+
const keys = /* @__PURE__ */ new Map();
|
|
658
|
+
const sources = /* @__PURE__ */ new Map();
|
|
659
|
+
run.forEach((directive, index) => {
|
|
660
|
+
if (directive.name !== name) return;
|
|
661
|
+
for (const pair of directive.pairs) {
|
|
662
|
+
const values = accepted[pair.key];
|
|
663
|
+
if (values === void 0) continue;
|
|
664
|
+
if (values !== null && !values.includes(pair.value)) continue;
|
|
665
|
+
keys.set(pair.key, pair.value);
|
|
666
|
+
sources.set(pair.key, index);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
return {
|
|
670
|
+
name,
|
|
671
|
+
keys,
|
|
672
|
+
sources
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
505
676
|
* `ws` is `[ \t\r\n]` — the design's grammar leaves it undefined, and `\n` has
|
|
506
677
|
* to be in the set because a directive may legally wrap: a multi-line comment
|
|
507
678
|
* is one node whose value contains the newlines.
|
|
@@ -617,10 +788,24 @@ function parseVantageDirective(comment) {
|
|
|
617
788
|
pairs
|
|
618
789
|
};
|
|
619
790
|
}
|
|
791
|
+
/**
|
|
792
|
+
* A question directive's `leaning=` value as the page carries it: whitespace
|
|
793
|
+
* runs collapsed to one space, trimmed, and capped at `MAX_LEANING`.
|
|
794
|
+
*
|
|
795
|
+
* A wrapped directive puts newlines and indentation into the value. Two readers
|
|
796
|
+
* need the result and must agree on it: `rehypeVantageDirectives`, which stamps
|
|
797
|
+
* it as `data-vantage-leaning` for the one-click answer, and the planning index,
|
|
798
|
+
* which reports the leaning a question carries. An empty result means the
|
|
799
|
+
* question states no leaning.
|
|
800
|
+
*/
|
|
801
|
+
function normalizeLeaning(raw) {
|
|
802
|
+
return raw.replace(/\s+/g, " ").trim().slice(0, 500);
|
|
803
|
+
}
|
|
620
804
|
//#endregion
|
|
621
805
|
//#region src/rehypeVantageDirectives.ts
|
|
622
806
|
/**
|
|
623
|
-
* What a `section`/`block` and
|
|
807
|
+
* What a `section`/`block` and a question directive (`oq`, `question`) may
|
|
808
|
+
* **target**.
|
|
624
809
|
*
|
|
625
810
|
* Both lists live in `vantageDirectives.ts`, with the reasoning for each tag,
|
|
626
811
|
* because the CLI checker resolves the same question over mdast and must reach
|
|
@@ -630,6 +815,8 @@ function parseVantageDirective(comment) {
|
|
|
630
815
|
*/
|
|
631
816
|
const STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
|
|
632
817
|
const ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
|
|
818
|
+
/** What a `fallback` may withhold: every style target but a heading. */
|
|
819
|
+
const FALLBACK_TARGET_TAGS = new Set(VANTAGE_FALLBACK_TARGETS);
|
|
633
820
|
const HEADING_DEPTHS = /* @__PURE__ */ new Map([
|
|
634
821
|
["h1", 1],
|
|
635
822
|
["h2", 2],
|
|
@@ -670,6 +857,19 @@ const RANGE_PROPERTIES = /* @__PURE__ */ new Map([["tone", "dataVantageTone"], [
|
|
|
670
857
|
*/
|
|
671
858
|
const POINT_PROPERTIES = /* @__PURE__ */ new Map([["badge", "dataVantageBadge"]]);
|
|
672
859
|
const RUN_PROPERTY = "dataVantageRun";
|
|
860
|
+
/**
|
|
861
|
+
* What a question stamps, whichever name declared it: `data-vantage-question`
|
|
862
|
+
* on every question, and `data-vantage-oq` besides on one an `oq` declared
|
|
863
|
+
* (`VANTAGE_QUESTION_ATTRIBUTE`, `VANTAGE_OQ_ATTRIBUTE`).
|
|
864
|
+
*
|
|
865
|
+
* Two attributes, and neither one ever means more than it says. Every release
|
|
866
|
+
* since 0.7 stamps `data-vantage-oq` on an `oq` and on nothing else, so a
|
|
867
|
+
* reader of this package's markup that has ever read it keeps reading exactly
|
|
868
|
+
* what it read; `data-vantage-question` is the one to read for "a question",
|
|
869
|
+
* and the question's state, from its marker, is what decides whether anything
|
|
870
|
+
* offers to answer it (`questionOffersTake`).
|
|
871
|
+
*/
|
|
872
|
+
const QUESTION_PROPERTY = "dataVantageQuestion";
|
|
673
873
|
const OQ_PROPERTY = "dataVantageOq";
|
|
674
874
|
const LEANING_PROPERTY = "dataVantageLeaning";
|
|
675
875
|
/**
|
|
@@ -704,8 +904,6 @@ const OQ_ID_PROPERTY = "dataVantageOqId";
|
|
|
704
904
|
const COLLAPSED_PROPERTY = "dataVantageCollapsed";
|
|
705
905
|
const COLLAPSE_GROUP_PROPERTY = "dataVantageCollapseGroup";
|
|
706
906
|
const COLLAPSE_TOGGLE_PROPERTY = "dataVantageCollapseToggle";
|
|
707
|
-
/** A review-comment body, not prose. Bounds the attribute; 500 is generous. */
|
|
708
|
-
const MAX_LEANING = 500;
|
|
709
907
|
/**
|
|
710
908
|
* Nodes that may sit between a directive and its target.
|
|
711
909
|
*
|
|
@@ -839,13 +1037,14 @@ function stampStyle(children, targetIndex, name, pairs, state) {
|
|
|
839
1037
|
}
|
|
840
1038
|
}
|
|
841
1039
|
}
|
|
842
|
-
function
|
|
843
|
-
setProperty(target,
|
|
1040
|
+
function stampQuestion(target, name, pairs) {
|
|
1041
|
+
setProperty(target, QUESTION_PROPERTY, "true");
|
|
1042
|
+
if (name === "oq") setProperty(target, OQ_PROPERTY, "true");
|
|
844
1043
|
const id = pairs.get("id");
|
|
845
1044
|
if (id !== void 0 && id !== "") setProperty(target, OQ_ID_PROPERTY, id);
|
|
846
1045
|
const leaning = pairs.get("leaning");
|
|
847
1046
|
if (leaning === void 0) return;
|
|
848
|
-
const text = leaning
|
|
1047
|
+
const text = normalizeLeaning(leaning);
|
|
849
1048
|
if (text !== "") setProperty(target, LEANING_PROPERTY, text);
|
|
850
1049
|
}
|
|
851
1050
|
/**
|
|
@@ -860,18 +1059,14 @@ function stampOq(target, pairs) {
|
|
|
860
1059
|
function stampRun(children, targetIndex, run, state) {
|
|
861
1060
|
const target = children[targetIndex];
|
|
862
1061
|
const style = /* @__PURE__ */ new Map();
|
|
863
|
-
const oq = /* @__PURE__ */ new Map();
|
|
864
1062
|
let styleName;
|
|
865
|
-
let hasOq = false;
|
|
866
1063
|
for (const directive of run) if (directive.name === "section" || directive.name === "block") {
|
|
867
1064
|
styleName = directive.name;
|
|
868
1065
|
for (const pair of directive.pairs) style.set(pair.key, pair.value);
|
|
869
|
-
} else if (directive.name === "oq") {
|
|
870
|
-
hasOq = true;
|
|
871
|
-
for (const pair of directive.pairs) oq.set(pair.key, pair.value);
|
|
872
1066
|
}
|
|
873
1067
|
if (styleName !== void 0) stampStyle(children, targetIndex, styleName, style, state);
|
|
874
|
-
|
|
1068
|
+
const question = mergeQuestionRun(run);
|
|
1069
|
+
if (question !== void 0 && ANCHOR_TARGET_TAGS.has(target.tagName)) stampQuestion(target, question.name, question.keys);
|
|
875
1070
|
}
|
|
876
1071
|
/** A directive comment, or `undefined` for anything else — malformed included. */
|
|
877
1072
|
function directiveOf(node) {
|
|
@@ -884,7 +1079,7 @@ function directiveOf(node) {
|
|
|
884
1079
|
*
|
|
885
1080
|
* The whole tree, not just the root: `rehype-raw` leaves comment nodes inside
|
|
886
1081
|
* `blockquote`, inside `li`, inside `td` and inline inside `p`, and the real
|
|
887
|
-
* Open Questions layout puts the
|
|
1082
|
+
* Open Questions layout puts the question directive inside a list item — so a
|
|
888
1083
|
* root-only walk finds none of them.
|
|
889
1084
|
*
|
|
890
1085
|
* Pass order is also what resolves a nested section: an inner heading's
|
|
@@ -923,8 +1118,66 @@ function processChildren(parent, state) {
|
|
|
923
1118
|
i = j;
|
|
924
1119
|
}
|
|
925
1120
|
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Remove every block a `fallback` run lands on, with the run itself.
|
|
1123
|
+
*
|
|
1124
|
+
* A fallback block is written for the renderers that cannot show something
|
|
1125
|
+
* Vantage shows — "This drawing needs Vantage 0.8 or later", beside an inline
|
|
1126
|
+
* `<svg>`. Every renderer that does not know the name drops the comment and
|
|
1127
|
+
* shows the block; this one never renders it, in any mode, in print, or
|
|
1128
|
+
* through any of the renderers that share this plugin (D5).
|
|
1129
|
+
*
|
|
1130
|
+
* The run is found exactly as `processChildren` finds one — the same skip
|
|
1131
|
+
* rules, the same first-element target, the same whole-tree walk — and it
|
|
1132
|
+
* withholds when any directive in it is a `fallback` and the target is on
|
|
1133
|
+
* `VANTAGE_FALLBACK_TARGETS`. Anything else is left for the stamping pass,
|
|
1134
|
+
* where a `fallback` is inert.
|
|
1135
|
+
*
|
|
1136
|
+
* A pass of its own, and first, because a `section` reaches *forward* over its
|
|
1137
|
+
* siblings: stamped in one pass, a toned section would count a fallback block
|
|
1138
|
+
* after it among its members, give it the run's last `end` marker, and leave
|
|
1139
|
+
* the last block anyone sees marked `middle`. Withheld first, the block was
|
|
1140
|
+
* never there for any later decision — the section's extent, its run markers,
|
|
1141
|
+
* a collapse group's members, a review anchor or an outline entry.
|
|
1142
|
+
*/
|
|
1143
|
+
function withholdFallbacks(parent) {
|
|
1144
|
+
const children = parent.children;
|
|
1145
|
+
let i = 0;
|
|
1146
|
+
while (i < children.length) {
|
|
1147
|
+
const node = children[i];
|
|
1148
|
+
if (node.type === "element") {
|
|
1149
|
+
withholdFallbacks(node);
|
|
1150
|
+
i++;
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
const first = directiveOf(node);
|
|
1154
|
+
if (first === void 0) {
|
|
1155
|
+
i++;
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
let withholds = first.name === "fallback";
|
|
1159
|
+
let j = i + 1;
|
|
1160
|
+
let targetIndex = -1;
|
|
1161
|
+
for (; j < children.length; j++) {
|
|
1162
|
+
const next = children[j];
|
|
1163
|
+
if (next.type === "element") {
|
|
1164
|
+
targetIndex = j;
|
|
1165
|
+
break;
|
|
1166
|
+
}
|
|
1167
|
+
if (!isSkippable(next)) break;
|
|
1168
|
+
if (directiveOf(next)?.name === "fallback") withholds = true;
|
|
1169
|
+
}
|
|
1170
|
+
const target = targetIndex >= 0 ? children[targetIndex] : void 0;
|
|
1171
|
+
if (withholds && target?.type === "element" && FALLBACK_TARGET_TAGS.has(target.tagName)) {
|
|
1172
|
+
children.splice(i, targetIndex - i + 1);
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
i = j;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
926
1178
|
const rehypeVantageDirectives = () => {
|
|
927
1179
|
return (tree) => {
|
|
1180
|
+
withholdFallbacks(tree);
|
|
928
1181
|
processChildren(tree, { nextGroup: 1 });
|
|
929
1182
|
};
|
|
930
1183
|
};
|
|
@@ -1017,12 +1270,144 @@ const rehypeRestoreMathStamps = () => {
|
|
|
1017
1270
|
};
|
|
1018
1271
|
//#endregion
|
|
1019
1272
|
//#region src/sanitize.ts
|
|
1273
|
+
/** Whether an attribute definition is for `className`. */
|
|
1274
|
+
const isClassName = (definition) => (typeof definition === "string" ? definition : definition[0]) === "className";
|
|
1275
|
+
/**
|
|
1276
|
+
* The value list the default schema gives `className` on `tag`, or an empty
|
|
1277
|
+
* list if it gives none.
|
|
1278
|
+
*/
|
|
1279
|
+
function defaultClasses(tag) {
|
|
1280
|
+
const entry = rehype_sanitize.defaultSchema.attributes?.[tag]?.find(isClassName);
|
|
1281
|
+
return Array.isArray(entry) ? entry.slice(1) : [];
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* The only classes an element keeps: the ones the pipeline itself emits on it
|
|
1285
|
+
* before `rehypeSanitize` runs. Every other element keeps none.
|
|
1286
|
+
*
|
|
1287
|
+
* **A class is a style by another name.** The app ships Tailwind's utility CSS
|
|
1288
|
+
* for its own markup, so `<div class="fixed inset-0 z-50 bg-white">` in a
|
|
1289
|
+
* document laid a white sheet over the whole Vantage window, header and
|
|
1290
|
+
* sidebar included. That is the overlay `SAFE_STYLE` bans `position` to
|
|
1291
|
+
* prevent, spelled as a class instead of a declaration, and `className` used
|
|
1292
|
+
* to be admitted with any value on every element. A document could also
|
|
1293
|
+
* borrow a class the app's own code looks for: `.overflow-y-auto` is how an
|
|
1294
|
+
* anchor link finds the element to scroll when no `data-content-scroll` is in
|
|
1295
|
+
* reach, and `.heading-anchor` is what the outline strips from a heading's
|
|
1296
|
+
* text. The comment-body sanitizer (`frontend/src/lib/commentMarkdown.ts`)
|
|
1297
|
+
* already refused `class` for the same reason.
|
|
1298
|
+
*
|
|
1299
|
+
* What each entry is for, and who emits it:
|
|
1300
|
+
*
|
|
1301
|
+
* - `code`: `language-*`, from a fence's info string (`mdast-util-to-hast`).
|
|
1302
|
+
* `rehype-highlight` reads it, `rehype-katex` keys on `language-math`, and
|
|
1303
|
+
* the viewers render `language-mermaid` as a diagram.
|
|
1304
|
+
* - `ul`, `ol` and `li`: `contains-task-list` and `task-list-item`, from GFM
|
|
1305
|
+
* task lists. `styles/task-list.css` draws the checkbox from them.
|
|
1306
|
+
* - `section`, `h2` and `a`: GFM footnotes (`mdast-util-to-hast`), on the
|
|
1307
|
+
* section, its label and each back-reference.
|
|
1308
|
+
* - `div`: `ALERT_TITLE_CLASS`, on the title `rehypeVantageAlerts` injects.
|
|
1309
|
+
*
|
|
1310
|
+
* `remark-math` also emits `math-display` and `math-inline` on its `code`.
|
|
1311
|
+
* They were never kept, because the default schema's `code` entry takes only
|
|
1312
|
+
* `language-*`, and nothing needs them: `rehype-katex` keys on `language-math`
|
|
1313
|
+
* and tells display from inline by whether the `code` sits in a `pre`.
|
|
1314
|
+
*
|
|
1315
|
+
* **A class added after the sanitizer needs no entry**, and that is most of
|
|
1316
|
+
* the classes the page styles: `rehype-highlight`'s `hljs-*`, KaTeX's output,
|
|
1317
|
+
* Mermaid's diagrams, and everything the app's components and hooks add. A
|
|
1318
|
+
* document that writes one of those on an element keeps nothing.
|
|
1319
|
+
*
|
|
1320
|
+
* **Mermaid source is the route this list cannot close.** Mermaid renders
|
|
1321
|
+
* after the sanitizer and copies the names a diagram's source gives a node
|
|
1322
|
+
* (`:::name`, `class A name`, `classDef`) onto that node, so a diagram can
|
|
1323
|
+
* carry any of the app's utilities on its own nodes. The diagram bounds it:
|
|
1324
|
+
* `position` does nothing on an SVG group, and the diagram's `svg` clips what
|
|
1325
|
+
* is inside it. `frontend/e2e/mermaid.spec.ts` measures that bound. The
|
|
1326
|
+
* stylesheet a diagram's source could write did reach past the diagram, and
|
|
1327
|
+
* `MERMAID_SECURE_KEYS` in `mermaidLoader.ts` refuses the settings that write
|
|
1328
|
+
* it.
|
|
1329
|
+
*
|
|
1330
|
+
* A document may still write these names on these elements. By the time the
|
|
1331
|
+
* sanitizer runs, a hand-written `<li class="task-list-item">` and the one GFM
|
|
1332
|
+
* emitted are the same node, and none of the names can lay an element over
|
|
1333
|
+
* anything outside the document's scroll container. `styles/task-list.css`
|
|
1334
|
+
* floats the checkbox of a `task-list-item` instead of positioning it
|
|
1335
|
+
* absolutely, so it stays in the document's flow whatever the item's own
|
|
1336
|
+
* `style` does, and the checkbox takes no `style` itself (see
|
|
1337
|
+
* `UNSTYLED_TAGS`). The footnote label's class, where a stylesheet defines it,
|
|
1338
|
+
* clips its element to a single pixel.
|
|
1339
|
+
*
|
|
1340
|
+
* **The footnote label's class is read from the default schema, never
|
|
1341
|
+
* spelled here, and the tests spell it in halves.** It is a Tailwind utility,
|
|
1342
|
+
* and Tailwind generates a utility for every class name it finds in the files
|
|
1343
|
+
* it scans, comments included — this directory and all of `frontend/`. The app
|
|
1344
|
+
* renders the label as a visible heading only because nothing it scans spells
|
|
1345
|
+
* that name. Spelled whole in a test, it generated the rule, and the label
|
|
1346
|
+
* would have been hidden from sight as it is on GitHub: a change of rendering,
|
|
1347
|
+
* not a sanitizer fix.
|
|
1348
|
+
*
|
|
1349
|
+
* The list is measured, not remembered. The test "takes no class from
|
|
1350
|
+
* anything the pipeline emits" in `frontend/src/lib/sanitize.test.ts` records
|
|
1351
|
+
* every class on the tree on both sides of `rehypeSanitize`, over a document
|
|
1352
|
+
* that uses every feature that emits one, so a plugin that starts emitting a
|
|
1353
|
+
* new class fails there instead of losing it in every renderer.
|
|
1354
|
+
*/
|
|
1355
|
+
const PIPELINE_CLASSES = {
|
|
1356
|
+
code: [/^language-./],
|
|
1357
|
+
ul: ["contains-task-list"],
|
|
1358
|
+
ol: ["contains-task-list"],
|
|
1359
|
+
li: ["task-list-item"],
|
|
1360
|
+
section: ["footnotes"],
|
|
1361
|
+
h2: defaultClasses("h2"),
|
|
1362
|
+
a: ["data-footnote-backref"],
|
|
1363
|
+
div: [ALERT_TITLE_CLASS]
|
|
1364
|
+
};
|
|
1020
1365
|
/**
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1366
|
+
* `tag`'s attributes: the default schema's, less any `className`, then its
|
|
1367
|
+
* `PIPELINE_CLASSES` entry, then `extra`.
|
|
1368
|
+
*
|
|
1369
|
+
* **Always a value list, never a bare `"className"`.** `hast-util-sanitize`
|
|
1370
|
+
* reads a bare name, or a tuple holding no values, as "any value", and an
|
|
1371
|
+
* element's own entry is consulted before `*`'s — the first match wins, and
|
|
1372
|
+
* `*` is not consulted when that element's own entry filters a class list down
|
|
1373
|
+
* to nothing. An element with no entry has none added.
|
|
1024
1374
|
*/
|
|
1025
|
-
|
|
1375
|
+
function withClasses(tag, ...extra) {
|
|
1376
|
+
const classes = PIPELINE_CLASSES[tag] ?? [];
|
|
1377
|
+
const own = (rehype_sanitize.defaultSchema.attributes?.[tag] ?? []).filter((definition) => !isClassName(definition));
|
|
1378
|
+
if (classes.length > 0) own.push(["className", ...classes]);
|
|
1379
|
+
return [...own, ...extra];
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* CSS properties an inline `style` may set.
|
|
1383
|
+
*
|
|
1384
|
+
* **The only `style` this list ever filters is one a document wrote by hand.**
|
|
1385
|
+
* Nothing the pipeline generates reaches it: `rehypeKatex` and `rehypeHighlight`
|
|
1386
|
+
* both run *after* `rehypeSanitize` (`pipeline.ts`), so their output is trusted
|
|
1387
|
+
* rather than filtered, and `remark-gfm` emits table alignment as an `align`
|
|
1388
|
+
* attribute rather than as CSS. So this is a filter on untrusted author HTML and
|
|
1389
|
+
* nothing else — which is exactly the hole that made it necessary: `<div
|
|
1390
|
+
* style="position:fixed;inset:0">` covered the viewport and
|
|
1391
|
+
* `style="background:url(https://…)"` called home on render, both verbatim,
|
|
1392
|
+
* because `rehype-sanitize` does not parse CSS. Scripts were never the risk
|
|
1393
|
+
* here; layout and network were.
|
|
1394
|
+
*
|
|
1395
|
+
* The list is therefore deliberately typographic: the styling a prose document
|
|
1396
|
+
* has any business asking for. It is *not* sized to KaTeX, and a KaTeX release
|
|
1397
|
+
* that starts using a new property is a non-event here — `\pmb` already emits
|
|
1398
|
+
* `text-shadow`, which is not on this list and renders anyway.
|
|
1399
|
+
*
|
|
1400
|
+
* **The design doc used to argue the opposite — that `style` had to be allowed
|
|
1401
|
+
* and `position` enumerated because KaTeX needs them — and it was wrong.** The
|
|
1402
|
+
* measurement behind it was real (KaTeX does emit `position:relative` on every
|
|
1403
|
+
* integral) but the inference was not, because the sanitizer has finished before
|
|
1404
|
+
* the first KaTeX span exists. Rebuilding the shipped rehype order with a filter
|
|
1405
|
+
* that rejects *every* value leaves all ten of the integral's style attributes
|
|
1406
|
+
* untouched. The "Security" section of `docs/reference/inline-markup.md`
|
|
1407
|
+
* records the correction; the test that would catch a reordering is in
|
|
1408
|
+
* `frontend/src/lib/sanitize.test.ts`.
|
|
1409
|
+
*/
|
|
1410
|
+
const SAFE_STYLE_PROPERTIES = [
|
|
1026
1411
|
"height",
|
|
1027
1412
|
"min-height",
|
|
1028
1413
|
"max-height",
|
|
@@ -1077,12 +1462,89 @@ const DECLARATION = `(?:(?:${[
|
|
|
1077
1462
|
"white-space",
|
|
1078
1463
|
"vertical-align",
|
|
1079
1464
|
"list-style-type",
|
|
1080
|
-
"display",
|
|
1081
1465
|
"float",
|
|
1082
1466
|
"clear",
|
|
1083
1467
|
"opacity",
|
|
1084
1468
|
"overflow"
|
|
1085
|
-
]
|
|
1469
|
+
];
|
|
1470
|
+
/**
|
|
1471
|
+
* A `style` value we will keep, as a whole.
|
|
1472
|
+
*
|
|
1473
|
+
* One rule beyond the property list does the work: **no parentheses anywhere**,
|
|
1474
|
+
* which closes `url(…)` and `expression(…)` in one stroke — the network and
|
|
1475
|
+
* legacy-script vectors. Its cost is borne entirely by authors, who lose
|
|
1476
|
+
* `calc()`, `rgb()` and `var()` along with them; that is the trade, and it is
|
|
1477
|
+
* worth it for a filter this small.
|
|
1478
|
+
*
|
|
1479
|
+
* **`position` is not on the property list at all**, so every value of it is
|
|
1480
|
+
* refused — `static` and `relative` along with `fixed` and `sticky`. It used to
|
|
1481
|
+
* be enumerated, on the belief that KaTeX needed `relative`; KaTeX renders after
|
|
1482
|
+
* the sanitizer and never meets this regex, so the enumeration was buying
|
|
1483
|
+
* nothing but the residual it conceded. Banning the property closes that
|
|
1484
|
+
* residual, and it is bigger than "overlaps its neighbors" made it sound:
|
|
1485
|
+
* measured in Chrome against the viewer's real ancestor chain, an author's
|
|
1486
|
+
* `position:absolute;top:0;left:0;width:100%;height:100%` is sized to the whole
|
|
1487
|
+
* content pane (the nearest positioned ancestor is outside the scroll
|
|
1488
|
+
* container), is not clipped by the scroller, and survives scrolling to the end
|
|
1489
|
+
* of the document. It was `position:fixed` in all but the keyword.
|
|
1490
|
+
*
|
|
1491
|
+
* Matching is all-or-nothing: one unrecognized declaration drops the whole
|
|
1492
|
+
* attribute, and the element renders unstyled rather than partly styled. That
|
|
1493
|
+
* is the safe direction to fail, and it degrades to plain text rather than to a
|
|
1494
|
+
* broken page.
|
|
1495
|
+
*
|
|
1496
|
+
* **The grammar must stay unambiguous, and `;` is what keeps it so.** The value
|
|
1497
|
+
* class is "anything but the delimiters", which includes whitespace — a value
|
|
1498
|
+
* legitimately contains it (`margin: 0 auto`). So if whitespace could *also*
|
|
1499
|
+
* end a declaration, both constructs would compete for the same characters and
|
|
1500
|
+
* the match would fork at every declaration; on a value that ultimately fails,
|
|
1501
|
+
* the engine explores every fork. An earlier form of this regex separated
|
|
1502
|
+
* declarations with `\s*;?\s*`, and 200 document-controlled characters took the
|
|
1503
|
+
* renderer — and the CLI checker, and therefore CI — 94 seconds. Requiring `;`
|
|
1504
|
+
* pins each declaration's extent to the delimiter positions, so there is exactly
|
|
1505
|
+
* one way to parse any input and rejection is linear. `VALUE` absorbs the
|
|
1506
|
+
* padding on both sides for the same reason: a separate `\\s*` next to it would
|
|
1507
|
+
* put the ambiguity straight back. Pinned by the flat-time test in
|
|
1508
|
+
* `frontend/src/lib/sanitize.test.ts` — do not loosen the separator.
|
|
1509
|
+
*
|
|
1510
|
+
* Residual, stated plainly and now genuinely small: negative `margin` still lets
|
|
1511
|
+
* an element overlap its neighbors *inside the flow*. That one scrolls with the
|
|
1512
|
+
* content and is clipped by the scroll container, and closing it means giving up
|
|
1513
|
+
* margins, which prose actually uses. Containment in the stylesheet, not another
|
|
1514
|
+
* rule here, is what would close it. `overflow: visible` on an inline `<svg>` is
|
|
1515
|
+
* the same residual by another route — see `SVG_ROOT_ATTRIBUTES`.
|
|
1516
|
+
*
|
|
1517
|
+
* **The app's table of contents is inside that scroll container too**, beside
|
|
1518
|
+
* the document column, so a block carried over it took its clicks whenever the
|
|
1519
|
+
* block painted in the positioned layer: one with `opacity`, a toned block, a
|
|
1520
|
+
* heading, or a link to anywhere. The contents sit at `z-10` for that
|
|
1521
|
+
* (`frontend/src/components/TableOfContents.tsx`), and a document cannot climb
|
|
1522
|
+
* back over them only because `z-index` is not on the property list.
|
|
1523
|
+
*/
|
|
1524
|
+
const VALUE = `[^;:()"'\\\\]*`;
|
|
1525
|
+
const DISPLAY_DECLARATION = `display\\s*:\\s*(?:${[
|
|
1526
|
+
"none",
|
|
1527
|
+
"block",
|
|
1528
|
+
"inline-block",
|
|
1529
|
+
"inline-flex",
|
|
1530
|
+
"inline-grid",
|
|
1531
|
+
"inline-table",
|
|
1532
|
+
"inline",
|
|
1533
|
+
"flow-root",
|
|
1534
|
+
"flex",
|
|
1535
|
+
"grid",
|
|
1536
|
+
"table-row-group",
|
|
1537
|
+
"table-row",
|
|
1538
|
+
"table-header-group",
|
|
1539
|
+
"table-footer-group",
|
|
1540
|
+
"table-cell",
|
|
1541
|
+
"table-column-group",
|
|
1542
|
+
"table-column",
|
|
1543
|
+
"table-caption",
|
|
1544
|
+
"table",
|
|
1545
|
+
"list-item"
|
|
1546
|
+
].join("|")})\\s*(?:!\\s*important\\s*)?`;
|
|
1547
|
+
const DECLARATION = `(?:(?:${SAFE_STYLE_PROPERTIES.join("|")})\\s*:${VALUE}|${DISPLAY_DECLARATION})`;
|
|
1086
1548
|
const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "i");
|
|
1087
1549
|
/**
|
|
1088
1550
|
* A collapse group id: one or more digits, anchored.
|
|
@@ -1094,6 +1556,281 @@ const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "
|
|
|
1094
1556
|
*/
|
|
1095
1557
|
const COLLAPSE_GROUP_ID = /^[0-9]+$/;
|
|
1096
1558
|
/**
|
|
1559
|
+
* Inline SVG, admitted as static drawing and nothing else.
|
|
1560
|
+
*
|
|
1561
|
+
* The allowlist is shapes, text and grouping. Everything in SVG that can run
|
|
1562
|
+
* code, fetch, or reach outside the element is refused: `script`,
|
|
1563
|
+
* `foreignObject`, `use` and `image` (both take an `href`), `xlink:href` on
|
|
1564
|
+
* `a`, the `animate`/`set` family (which can rewrite an `href` after the
|
|
1565
|
+
* sanitizer has looked), `style` elements, and every attribute that takes a
|
|
1566
|
+
* `url(…)` reference — `filter`, `mask`, `clip-path`, `marker-*`, `cursor`.
|
|
1567
|
+
*
|
|
1568
|
+
* Gradients, patterns and `<defs>` are out for a second reason: they are only
|
|
1569
|
+
* reachable through `url(#id)`, and the sanitizer prefixes every `id` with
|
|
1570
|
+
* `user-content-`, so the reference would dangle even if it were allowed.
|
|
1571
|
+
*
|
|
1572
|
+
* **Refusing an element is not the same as removing it.** `hast-util-sanitize`
|
|
1573
|
+
* *unwraps* an element it does not allow — drops the tag and keeps its
|
|
1574
|
+
* children — unless the tag is on `strip`. See `SVG_STRIPPED` for why that
|
|
1575
|
+
* matters here, and for the pass that removes those containers instead.
|
|
1576
|
+
*
|
|
1577
|
+
* `switch` is admitted, as a plain container, because draw.io wraps every label
|
|
1578
|
+
* in one: `<switch><foreignObject>…</foreignObject><text>…</text></switch>`. A
|
|
1579
|
+
* `switch` renders only its first child whose conditions hold, and with the
|
|
1580
|
+
* `foreignObject` removed and the conditional attributes (`requiredFeatures`,
|
|
1581
|
+
* `systemLanguage`) refused, that is the `<text>` fallback. Unwrapped instead,
|
|
1582
|
+
* it would render *every* branch — including the "Text is not SVG - cannot
|
|
1583
|
+
* display" notice draw.io appends for viewers without `foreignObject`.
|
|
1584
|
+
*
|
|
1585
|
+
* Every child requires an `svg` ancestor, so no shape or text element is
|
|
1586
|
+
* admitted into HTML flow on its own.
|
|
1587
|
+
*
|
|
1588
|
+
* `title` and `desc` are not children at all. They are admitted nowhere, inside
|
|
1589
|
+
* a drawing they are removed with their contents (see `SVG_STRIPPED`), and
|
|
1590
|
+
* `aria-label` on the root is the drawing's accessible name. Both are HTML
|
|
1591
|
+
* integration points — the parser reads what is inside them as HTML — and an
|
|
1592
|
+
* `svg` ancestor turned out not to be enough to keep a `<title>` in SVG
|
|
1593
|
+
* context. `<math><svg><title>` keeps MathML context, and React hoists any
|
|
1594
|
+
* `title` outside SVG context into the page's `<head>`, so the document set the
|
|
1595
|
+
* tab title. In `renderMarkdown`'s string output, `<svg><desc><title>`,
|
|
1596
|
+
* `<svg><title><title>` and a `title` inside a `foreignObject` all re-parse into
|
|
1597
|
+
* an HTML `title`, which is what `document.title` reads on a page that has none
|
|
1598
|
+
* of its own.
|
|
1599
|
+
*/
|
|
1600
|
+
const SVG_CHILD_TAGS = [
|
|
1601
|
+
"g",
|
|
1602
|
+
"path",
|
|
1603
|
+
"rect",
|
|
1604
|
+
"circle",
|
|
1605
|
+
"ellipse",
|
|
1606
|
+
"line",
|
|
1607
|
+
"polyline",
|
|
1608
|
+
"polygon",
|
|
1609
|
+
"text",
|
|
1610
|
+
"tspan",
|
|
1611
|
+
"switch"
|
|
1612
|
+
];
|
|
1613
|
+
/**
|
|
1614
|
+
* Refused SVG elements removed *with their contents* when they sit inside an
|
|
1615
|
+
* `<svg>`, rather than unwrapped.
|
|
1616
|
+
*
|
|
1617
|
+
* Each of these holds children that are never meant to be painted where they
|
|
1618
|
+
* stand: a clip region, a mask, a gradient's stops, a marker's arrowhead, a
|
|
1619
|
+
* symbol's body, a filter's primitives, an exporter's RDF, the text of a
|
|
1620
|
+
* `title` or `desc` (see `SVG_CHILD_TAGS`), or — for `foreignObject` — HTML.
|
|
1621
|
+
* Unwrapped, those children land in the drawing as ordinary shapes. A default
|
|
1622
|
+
* Figma export ends in `<defs><clipPath><rect fill="white"/>`, and unwrapping
|
|
1623
|
+
* it painted that white rect over the whole drawing; a `<marker>` arrowhead
|
|
1624
|
+
* became a stray triangle at the origin. The HTML inside a `foreignObject`
|
|
1625
|
+
* survived as descendants of `svg`, which React then created as invisible
|
|
1626
|
+
* SVG-namespace `div`s and `p`s — the anchor an Open Question button looks for
|
|
1627
|
+
* — and which a browser re-parsing `renderMarkdown`'s string output breaks out
|
|
1628
|
+
* of the `svg` at, spilling the rest of the drawing into the page.
|
|
1629
|
+
*
|
|
1630
|
+
* **Only inside an `<svg>`, which is why this is a pass and not the schema's
|
|
1631
|
+
* `strip`.** `strip` removes a tag wherever it stands, and most of these names
|
|
1632
|
+
* are lowercase in prose too. There a bare `<pattern>` on a line of its own is
|
|
1633
|
+
* an HTML element nothing closes, so every Markdown block after it was parsed
|
|
1634
|
+
* into it, and stripping it removed the rest of the document — in every
|
|
1635
|
+
* renderer, with `vantage-check` silent. Inside a drawing the same container is
|
|
1636
|
+
* bounded by the `svg`, because a Markdown paragraph or heading breaks out of
|
|
1637
|
+
* SVG. Outside one the sanitizer unwraps these like any tag it does not know:
|
|
1638
|
+
* `title` and `desc` are not admitted anywhere, so no `title` element survives
|
|
1639
|
+
* to be hoisted, and what they held is text or ordinary sanitized HTML.
|
|
1640
|
+
*
|
|
1641
|
+
* Names compare case-insensitively. The camel-cased ones are how the HTML
|
|
1642
|
+
* parser spells these elements in SVG, but under `<math><svg>` the parser stays
|
|
1643
|
+
* in MathML and leaves them lowercase.
|
|
1644
|
+
*/
|
|
1645
|
+
const SVG_STRIPPED = [
|
|
1646
|
+
"foreignObject",
|
|
1647
|
+
"defs",
|
|
1648
|
+
"clipPath",
|
|
1649
|
+
"mask",
|
|
1650
|
+
"pattern",
|
|
1651
|
+
"marker",
|
|
1652
|
+
"symbol",
|
|
1653
|
+
"linearGradient",
|
|
1654
|
+
"radialGradient",
|
|
1655
|
+
"filter",
|
|
1656
|
+
"metadata",
|
|
1657
|
+
"title",
|
|
1658
|
+
"desc"
|
|
1659
|
+
];
|
|
1660
|
+
const SVG_STRIPPED_NAMES = new Set(SVG_STRIPPED.map((name) => name.toLowerCase()));
|
|
1661
|
+
/**
|
|
1662
|
+
* The camel-cased `SVG_STRIPPED` names, which the schema strips on its own.
|
|
1663
|
+
*
|
|
1664
|
+
* Only an SVG-namespace element carries one of these spellings, so stripping
|
|
1665
|
+
* them everywhere costs prose nothing. It keeps the worst of the list — the
|
|
1666
|
+
* HTML inside a `foreignObject` — out of a drawing for a consumer who hands
|
|
1667
|
+
* `sanitizeSchema` to `rehype-sanitize` without the rest of `buildPipeline`.
|
|
1668
|
+
*/
|
|
1669
|
+
const SVG_STRIPPED_EVERYWHERE = SVG_STRIPPED.filter((name) => name !== name.toLowerCase());
|
|
1670
|
+
function stripSvgContainers(parent, inSvg) {
|
|
1671
|
+
if (inSvg) parent.children = parent.children.filter((child) => child.type !== "element" || !SVG_STRIPPED_NAMES.has(child.tagName.toLowerCase()));
|
|
1672
|
+
for (const child of parent.children) if (child.type === "element") stripSvgContainers(child, inSvg || child.tagName.toLowerCase() === "svg");
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Remove every `SVG_STRIPPED` element below an `svg`, with its contents.
|
|
1676
|
+
*
|
|
1677
|
+
* Runs immediately before `rehypeSanitize` (`pipeline.ts`), and only when the
|
|
1678
|
+
* pipeline sanitizes: with the sanitizer off these elements are left to work
|
|
1679
|
+
* as SVG defines them.
|
|
1680
|
+
*/
|
|
1681
|
+
function rehypeStripSvgContainers() {
|
|
1682
|
+
return (tree) => {
|
|
1683
|
+
stripSvgContainers(tree, false);
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* A paint value: a keyword, a named color, or a hex color. No parentheses, for
|
|
1688
|
+
* the same reason `SAFE_STYLE` refuses them — `fill="url(https://…)"` is a
|
|
1689
|
+
* render-time fetch, and `rgb()` is the price of closing it.
|
|
1690
|
+
*/
|
|
1691
|
+
const SVG_PAINT = /^(?:#[0-9a-f]{3,8}|[a-z]+)$/i;
|
|
1692
|
+
/**
|
|
1693
|
+
* Attributes any SVG element may carry, drawing and typography alike.
|
|
1694
|
+
*
|
|
1695
|
+
* **Each entry is a hast property name, which is not always the camel case of
|
|
1696
|
+
* the attribute.** `property-information` decides the spelling, and it has
|
|
1697
|
+
* `strokeLineCap` for `stroke-linecap` — a naive `strokeLinecap` matches
|
|
1698
|
+
* nothing, and the attribute is dropped in every renderer without a word. The
|
|
1699
|
+
* table-driven test in `frontend/src/lib/sanitize.test.ts` renders every entry
|
|
1700
|
+
* in its written spelling, so a misspelled one fails there.
|
|
1701
|
+
*
|
|
1702
|
+
* `stroke-dasharray` (and with it `stroke-dashoffset`, which does nothing
|
|
1703
|
+
* alone) is refused, because it lets a few bytes buy an unbounded paint. The
|
|
1704
|
+
* number of dashes is the path's length over the dash period, both in user
|
|
1705
|
+
* units the document chooses. Measured in headless Chromium, fifty 90-byte
|
|
1706
|
+
* paths with a 0.0011 dash took 21 s to paint and a hundred took 37 s. A value
|
|
1707
|
+
* grammar cannot bound it: refusing sub-unit dashes is undone by scaling the
|
|
1708
|
+
* user units, and the same fifty paths with `stroke-dasharray="1.1"` under a
|
|
1709
|
+
* `viewBox` a thousand times larger still took 15 s.
|
|
1710
|
+
*/
|
|
1711
|
+
const SVG_ATTRIBUTES = [
|
|
1712
|
+
["fill", SVG_PAINT],
|
|
1713
|
+
["stroke", SVG_PAINT],
|
|
1714
|
+
"fillOpacity",
|
|
1715
|
+
"fillRule",
|
|
1716
|
+
"strokeOpacity",
|
|
1717
|
+
"strokeWidth",
|
|
1718
|
+
"strokeLineCap",
|
|
1719
|
+
"strokeLineJoin",
|
|
1720
|
+
"opacity",
|
|
1721
|
+
"transform",
|
|
1722
|
+
"fontFamily",
|
|
1723
|
+
"fontSize",
|
|
1724
|
+
"fontWeight",
|
|
1725
|
+
"fontStyle",
|
|
1726
|
+
"letterSpacing",
|
|
1727
|
+
"textAnchor",
|
|
1728
|
+
"dominantBaseline",
|
|
1729
|
+
"x",
|
|
1730
|
+
"y",
|
|
1731
|
+
"x1",
|
|
1732
|
+
"y1",
|
|
1733
|
+
"x2",
|
|
1734
|
+
"y2",
|
|
1735
|
+
"cx",
|
|
1736
|
+
"cy",
|
|
1737
|
+
"r",
|
|
1738
|
+
"rx",
|
|
1739
|
+
"ry",
|
|
1740
|
+
"dx",
|
|
1741
|
+
"dy",
|
|
1742
|
+
"d",
|
|
1743
|
+
"points"
|
|
1744
|
+
];
|
|
1745
|
+
/**
|
|
1746
|
+
* The root `<svg>`'s attributes: `SVG_ATTRIBUTES` less `transform`, plus the
|
|
1747
|
+
* viewport, a `role` from a closed list, and the accessible name.
|
|
1748
|
+
*
|
|
1749
|
+
* `transform` on the root is not an SVG transform at all. The root is an
|
|
1750
|
+
* in-flow CSS box, and the attribute becomes a CSS transform of that box, so
|
|
1751
|
+
* `<svg width="10" height="10" transform="translate(-300 -300) scale(80)">`
|
|
1752
|
+
* laid out as a 10px box and painted as an 800px one over the paragraphs
|
|
1753
|
+
* around it. On a child it moves shapes inside the viewport, which clips them.
|
|
1754
|
+
*
|
|
1755
|
+
* Residual: that clip is the root's `overflow`, which `SAFE_STYLE` lets a
|
|
1756
|
+
* document set. `style="overflow:visible"` on the root lets a child with a
|
|
1757
|
+
* large `transform` or large coordinates paint over its neighbors — measured in
|
|
1758
|
+
* Chromium, the same overlap. It is the negative-`margin` residual described at
|
|
1759
|
+
* `SAFE_STYLE` by another route: it stays in the flow, scrolls with the content,
|
|
1760
|
+
* and the scroll container clips it.
|
|
1761
|
+
*/
|
|
1762
|
+
const SVG_ROOT_ATTRIBUTES = [
|
|
1763
|
+
...SVG_ATTRIBUTES.filter((attribute) => attribute !== "transform"),
|
|
1764
|
+
"xmlns",
|
|
1765
|
+
"viewBox",
|
|
1766
|
+
"preserveAspectRatio",
|
|
1767
|
+
[
|
|
1768
|
+
"role",
|
|
1769
|
+
"img",
|
|
1770
|
+
"presentation",
|
|
1771
|
+
"none"
|
|
1772
|
+
],
|
|
1773
|
+
"ariaLabel"
|
|
1774
|
+
];
|
|
1775
|
+
/** Every element the sanitizer admits. */
|
|
1776
|
+
const TAG_NAMES = [
|
|
1777
|
+
...rehype_sanitize.defaultSchema.tagNames || [],
|
|
1778
|
+
"math",
|
|
1779
|
+
"semantics",
|
|
1780
|
+
"mrow",
|
|
1781
|
+
"mi",
|
|
1782
|
+
"mo",
|
|
1783
|
+
"mn",
|
|
1784
|
+
"msup",
|
|
1785
|
+
"msub",
|
|
1786
|
+
"mfrac",
|
|
1787
|
+
"mover",
|
|
1788
|
+
"munder",
|
|
1789
|
+
"msqrt",
|
|
1790
|
+
"mroot",
|
|
1791
|
+
"mtable",
|
|
1792
|
+
"mtr",
|
|
1793
|
+
"mtd",
|
|
1794
|
+
"mtext",
|
|
1795
|
+
"mspace",
|
|
1796
|
+
"annotation",
|
|
1797
|
+
"figure",
|
|
1798
|
+
"figcaption",
|
|
1799
|
+
"summary",
|
|
1800
|
+
"details",
|
|
1801
|
+
"svg",
|
|
1802
|
+
...SVG_CHILD_TAGS
|
|
1803
|
+
];
|
|
1804
|
+
/**
|
|
1805
|
+
* The admitted elements that take no `style`: only `input`, which GFM emits as
|
|
1806
|
+
* a task list's checkbox, and never with a style.
|
|
1807
|
+
*
|
|
1808
|
+
* `styles/task-list.css` draws that checkbox and positions it, so on it `top`
|
|
1809
|
+
* and `left` are not the no-ops they are on an element nothing positions, and
|
|
1810
|
+
* with `width`, `height` and `background-color` beside them a document laid a
|
|
1811
|
+
* box of its own over the page. Measured in Chromium at 1280x800 while the
|
|
1812
|
+
* checkbox was positioned absolutely,
|
|
1813
|
+
* `top:-3000px;left:-3000px;width:9000px;height:9000px;background-color:white`
|
|
1814
|
+
* on it covered the whole content pane. It needed no class written:
|
|
1815
|
+
* GFM puts `task-list-item` on the item, and a checkbox typed into the item's
|
|
1816
|
+
* text is a child of that item too.
|
|
1817
|
+
*
|
|
1818
|
+
* **This is why `style` is on each element's own entry, never on `*`.**
|
|
1819
|
+
* `hast-util-sanitize` consults `*` whenever an element's own entry yields
|
|
1820
|
+
* nothing, so no entry on `input` could refuse a value that `*` admits. An
|
|
1821
|
+
* element takes `style` only by being in `TAG_NAMES` and not in this set.
|
|
1822
|
+
*/
|
|
1823
|
+
const UNSTYLED_TAGS = /* @__PURE__ */ new Set(["input"]);
|
|
1824
|
+
/**
|
|
1825
|
+
* `attributes`, with `["style", SAFE_STYLE]` added to the entry of every
|
|
1826
|
+
* element in `TAG_NAMES` but the `UNSTYLED_TAGS`.
|
|
1827
|
+
*/
|
|
1828
|
+
function withStyle(attributes) {
|
|
1829
|
+
const styled = { ...attributes };
|
|
1830
|
+
for (const tag of new Set(TAG_NAMES)) if (!UNSTYLED_TAGS.has(tag)) styled[tag] = [...styled[tag] ?? [], ["style", SAFE_STYLE]];
|
|
1831
|
+
return styled;
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1097
1834
|
* Never set `allowComments` here.
|
|
1098
1835
|
*
|
|
1099
1836
|
* `hast-util-sanitize` drops comment nodes because that boolean defaults to
|
|
@@ -1107,38 +1844,16 @@ const COLLAPSE_GROUP_ID = /^[0-9]+$/;
|
|
|
1107
1844
|
*/
|
|
1108
1845
|
const sanitizeSchema = {
|
|
1109
1846
|
...rehype_sanitize.defaultSchema,
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
"
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
"mn",
|
|
1118
|
-
"msup",
|
|
1119
|
-
"msub",
|
|
1120
|
-
"mfrac",
|
|
1121
|
-
"mover",
|
|
1122
|
-
"munder",
|
|
1123
|
-
"msqrt",
|
|
1124
|
-
"mroot",
|
|
1125
|
-
"mtable",
|
|
1126
|
-
"mtr",
|
|
1127
|
-
"mtd",
|
|
1128
|
-
"mtext",
|
|
1129
|
-
"mspace",
|
|
1130
|
-
"annotation",
|
|
1131
|
-
"figure",
|
|
1132
|
-
"figcaption",
|
|
1133
|
-
"summary",
|
|
1134
|
-
"details"
|
|
1135
|
-
],
|
|
1136
|
-
attributes: {
|
|
1847
|
+
strip: [...rehype_sanitize.defaultSchema.strip || [], ...SVG_STRIPPED_EVERYWHERE],
|
|
1848
|
+
tagNames: TAG_NAMES,
|
|
1849
|
+
ancestors: {
|
|
1850
|
+
...rehype_sanitize.defaultSchema.ancestors,
|
|
1851
|
+
...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, ["svg"]]))
|
|
1852
|
+
},
|
|
1853
|
+
attributes: withStyle({
|
|
1137
1854
|
...rehype_sanitize.defaultSchema.attributes,
|
|
1138
1855
|
"*": [
|
|
1139
1856
|
...rehype_sanitize.defaultSchema.attributes?.["*"] || [],
|
|
1140
|
-
"className",
|
|
1141
|
-
["style", SAFE_STYLE],
|
|
1142
1857
|
"dataSourceLine",
|
|
1143
1858
|
["dataVantageTone", ...VANTAGE_TONES],
|
|
1144
1859
|
["dataVantageEmphasis", ...VANTAGE_EMPHASIS],
|
|
@@ -1148,35 +1863,22 @@ const sanitizeSchema = {
|
|
|
1148
1863
|
["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
|
|
1149
1864
|
["dataVantageRun", ...VANTAGE_RUNS],
|
|
1150
1865
|
["dataVantageOq", "true"],
|
|
1866
|
+
["dataVantageQuestion", "true"],
|
|
1151
1867
|
["dataVantageOqId", VANTAGE_OQ_ID],
|
|
1152
1868
|
["dataVantageAlert", ...VANTAGE_ALERTS],
|
|
1153
1869
|
"dataVantageLeaning"
|
|
1154
1870
|
],
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
...rehype_sanitize.defaultSchema.attributes?.span || [],
|
|
1158
|
-
"className",
|
|
1159
|
-
["style", SAFE_STYLE]
|
|
1160
|
-
],
|
|
1161
|
-
div: [
|
|
1162
|
-
...rehype_sanitize.defaultSchema.attributes?.div || [],
|
|
1163
|
-
"className",
|
|
1164
|
-
["style", SAFE_STYLE]
|
|
1165
|
-
],
|
|
1166
|
-
a: [
|
|
1167
|
-
...rehype_sanitize.defaultSchema.attributes?.a || [],
|
|
1168
|
-
"id",
|
|
1169
|
-
"className"
|
|
1170
|
-
],
|
|
1871
|
+
...Object.fromEntries(Object.keys(PIPELINE_CLASSES).map((tag) => [tag, withClasses(tag)])),
|
|
1872
|
+
a: withClasses("a", "id"),
|
|
1171
1873
|
math: ["xmlns"],
|
|
1172
1874
|
annotation: ["encoding"],
|
|
1173
1875
|
img: [...rehype_sanitize.defaultSchema.attributes?.img || [], "loading"],
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
}
|
|
1876
|
+
svg: SVG_ROOT_ATTRIBUTES,
|
|
1877
|
+
...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, SVG_ATTRIBUTES]))
|
|
1878
|
+
})
|
|
1177
1879
|
};
|
|
1178
1880
|
//#endregion
|
|
1179
|
-
//#region src/
|
|
1881
|
+
//#region src/remarkPlugins.ts
|
|
1180
1882
|
/**
|
|
1181
1883
|
* The mdast half of the chain. Exported on its own because there is a real
|
|
1182
1884
|
* mdast-only consumer: the CLI checker parses documents without ever running
|
|
@@ -1190,6 +1892,8 @@ function buildRemarkPlugins(options = {}) {
|
|
|
1190
1892
|
if (math) plugins.push([remark_math.default, { singleDollarTextMath: false }]);
|
|
1191
1893
|
return plugins;
|
|
1192
1894
|
}
|
|
1895
|
+
//#endregion
|
|
1896
|
+
//#region src/pipeline.ts
|
|
1193
1897
|
/** The hast half. Deliberately not exported: see `buildPipeline`. */
|
|
1194
1898
|
function buildRehypePlugins(options = {}) {
|
|
1195
1899
|
const { math = true, highlight = true, sourceLines = true, sanitize = true, bodyLineOffset = 0 } = options;
|
|
@@ -1197,7 +1901,7 @@ function buildRehypePlugins(options = {}) {
|
|
|
1197
1901
|
if (sourceLines) plugins.push([rehypeSourceLines, { offset: bodyLineOffset }]);
|
|
1198
1902
|
plugins.push(rehypeVantageAlerts);
|
|
1199
1903
|
plugins.push(rehypeVantageDirectives);
|
|
1200
|
-
if (sanitize) plugins.push([rehype_sanitize.default, sanitizeSchema]);
|
|
1904
|
+
if (sanitize) plugins.push(rehypeStripSvgContainers, [rehype_sanitize.default, sanitizeSchema]);
|
|
1201
1905
|
plugins.push(rehypeVantageAnchors);
|
|
1202
1906
|
plugins.push(rehype_slug.default);
|
|
1203
1907
|
if (highlight) plugins.push(rehype_highlight.default);
|
|
@@ -1761,6 +2465,43 @@ let mermaidLoading = null;
|
|
|
1761
2465
|
* configured for, `null` until loaded.
|
|
1762
2466
|
*/
|
|
1763
2467
|
let configuredTheme = null;
|
|
2468
|
+
/**
|
|
2469
|
+
* The configuration keys a diagram's own source may not set, from an `init`
|
|
2470
|
+
* directive or from frontmatter: Mermaid's default list, then the three
|
|
2471
|
+
* that write a diagram's stylesheet.
|
|
2472
|
+
*
|
|
2473
|
+
* **A diagram's stylesheet reaches past the diagram.** Mermaid puts every
|
|
2474
|
+
* selector of it under the diagram's id, but leaves the names of its
|
|
2475
|
+
* `@keyframes` global, so a diagram that defined `flash-update` redefined the
|
|
2476
|
+
* animation the viewer plays on a block that just changed. Given
|
|
2477
|
+
* `position: fixed` at the size of the window, the next flash laid that block,
|
|
2478
|
+
* and the link it held, over the header and the sidebar. The same stylesheet
|
|
2479
|
+
* fetched images from any host, which is why `SAFE_STYLE` refuses parentheses.
|
|
2480
|
+
*
|
|
2481
|
+
* - `themeCSS` is a stylesheet, and Mermaid checks only that its braces pair.
|
|
2482
|
+
* - `fontFamily` is copied into `themeVariables` *after* Mermaid's check on
|
|
2483
|
+
* theme variables has run, and lands in the stylesheet as a declaration's
|
|
2484
|
+
* value, where a nested block becomes a rule of its own.
|
|
2485
|
+
* - `altFontFamily` lands in a rule the browser parses first, which drops
|
|
2486
|
+
* what does not belong. It is refused anyway: nothing needs it, and that
|
|
2487
|
+
* parse is all that stands in the way.
|
|
2488
|
+
*
|
|
2489
|
+
* Mermaid applies the list at every depth, so `themeVariables.fontFamily` in
|
|
2490
|
+
* a diagram is refused too. The list is spelled whole rather than relying on
|
|
2491
|
+
* Mermaid merging it with its default. `frontend/e2e/mermaid.spec.ts` measures
|
|
2492
|
+
* all four routes; see "Mermaid" in `docs/reference/inline-markup.md`.
|
|
2493
|
+
*/
|
|
2494
|
+
const MERMAID_SECURE_KEYS = [
|
|
2495
|
+
"secure",
|
|
2496
|
+
"securityLevel",
|
|
2497
|
+
"startOnLoad",
|
|
2498
|
+
"maxTextSize",
|
|
2499
|
+
"suppressErrorRendering",
|
|
2500
|
+
"maxEdges",
|
|
2501
|
+
"themeCSS",
|
|
2502
|
+
"fontFamily",
|
|
2503
|
+
"altFontFamily"
|
|
2504
|
+
];
|
|
1764
2505
|
function configure(m, palette) {
|
|
1765
2506
|
const theme = mermaidThemeOf(palette);
|
|
1766
2507
|
m.initialize({
|
|
@@ -1768,7 +2509,8 @@ function configure(m, palette) {
|
|
|
1768
2509
|
theme,
|
|
1769
2510
|
themeVariables: mermaidThemeVariables(theme),
|
|
1770
2511
|
securityLevel: "strict",
|
|
1771
|
-
suppressErrorRendering: true
|
|
2512
|
+
suppressErrorRendering: true,
|
|
2513
|
+
secure: MERMAID_SECURE_KEYS
|
|
1772
2514
|
});
|
|
1773
2515
|
configuredTheme = palette;
|
|
1774
2516
|
}
|
|
@@ -1803,15 +2545,6 @@ async function getMermaid() {
|
|
|
1803
2545
|
//#endregion
|
|
1804
2546
|
//#region src/renderMermaidBlocks.ts
|
|
1805
2547
|
/**
|
|
1806
|
-
* Client-side utility to find and render mermaid code blocks in a container.
|
|
1807
|
-
*
|
|
1808
|
-
* After calling `renderMarkdown()`, mermaid blocks come through as
|
|
1809
|
-
* `<pre><code class="language-mermaid">...</code></pre>`. This function
|
|
1810
|
-
* finds those blocks and replaces them with rendered SVG diagrams.
|
|
1811
|
-
*
|
|
1812
|
-
* Framework-agnostic — works in any browser environment.
|
|
1813
|
-
*/
|
|
1814
|
-
/**
|
|
1815
2548
|
* Find all `<pre><code class="language-mermaid">` blocks in a container
|
|
1816
2549
|
* and replace them with rendered SVG diagrams.
|
|
1817
2550
|
*
|
|
@@ -1892,6 +2625,7 @@ function replaceWithSvg(preEl, svg, className) {
|
|
|
1892
2625
|
wrapper.innerHTML = svg;
|
|
1893
2626
|
preEl.replaceWith(wrapper);
|
|
1894
2627
|
}
|
|
2628
|
+
(0, unified.unified)().use(remark_parse.default).use(buildRemarkPlugins());
|
|
1895
2629
|
//#endregion
|
|
1896
2630
|
//#region src/resolveLinks.ts
|
|
1897
2631
|
/**
|
|
@@ -1942,12 +2676,15 @@ function resolveLinks(html, options = {}) {
|
|
|
1942
2676
|
* The canonical Vantage Markdown style guide.
|
|
1943
2677
|
*
|
|
1944
2678
|
* This string is the single source of truth for the conventions Vantage's
|
|
1945
|
-
* renderer expects.
|
|
2679
|
+
* renderer expects. It reaches readers three ways:
|
|
1946
2680
|
*
|
|
1947
2681
|
* - the in-app "Style Guide for Agents" modal, which shows it with a copy
|
|
1948
|
-
* button,
|
|
2682
|
+
* button,
|
|
1949
2683
|
* - the `vantage-check style-guide` command, which prints it so an agent can
|
|
1950
|
-
* fetch it without a human in the loop
|
|
2684
|
+
* fetch it without a human in the loop, and
|
|
2685
|
+
* - this package's own export, for anyone who renders with `vantage-md`.
|
|
2686
|
+
*
|
|
2687
|
+
* How the CLI serves it, and why there is one copy: docs/reference/agent-cli.md
|
|
1951
2688
|
*
|
|
1952
2689
|
* Every rule stated here should be one a checker can enforce or a renderer
|
|
1953
2690
|
* actually cares about — if a line is neither, it does not belong.
|
|
@@ -2003,6 +2740,41 @@ vantage:
|
|
|
2003
2740
|
- **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.
|
|
2004
2741
|
- The chip's vocabulary is \`status\`'s, exactly: \`draft | in-review | accepted | deprecated\`, lowercase. \`Draft\` renders no chip at all, silently.
|
|
2005
2742
|
|
|
2743
|
+
### Planning documents: \`stage\`, \`next\`, \`depends-on\`
|
|
2744
|
+
- **Vantage reads a repository's plans as a set.** A document whose frontmatter has \`status\` or \`stage\`, or that carries a \`question\` directive (or an \`oq\`, the name it replaces), is a *planning document*, and Vantage shows its state (status, stage, open questions) in a badge beside every link to it. So write each fact once, in the document it belongs to, and link to it everywhere else: a copied status or count is the one that goes stale.
|
|
2745
|
+
- Three top-level keys beside \`status\` hold the rest of a document's planning state. They are facts about the file, so they sit at the top level, never under \`vantage:\`:
|
|
2746
|
+
\`\`\`yaml
|
|
2747
|
+
---
|
|
2748
|
+
title: "Payload bootstrap"
|
|
2749
|
+
status: in-review
|
|
2750
|
+
stage: DESIGN
|
|
2751
|
+
next: "Rule OQ-B2 \u2014 the install step waits on it"
|
|
2752
|
+
depends-on:
|
|
2753
|
+
- pypi-distribution.md
|
|
2754
|
+
- ../plans/rollout.md#OQ-R1
|
|
2755
|
+
---
|
|
2756
|
+
\`\`\`
|
|
2757
|
+
- **\`stage\` is one word from the repository's own vocabulary**, the words \`[planning.stages]\` declares (below), spelled exactly as declared: matching is case-sensitive, so \`Decided\` is not \`DECIDED\`. \`vantage-check\` reports a word outside them as \`planning/stage-vocabulary\`.
|
|
2758
|
+
- **Frontmatter is the stage's one home.** Do not repeat the word in a prose \`**Status:**\` line: that is a second copy, and no tool can read or check it. Keep the prose line, where a document has one, for the date and the why.
|
|
2759
|
+
- **\`next\` is the next step, on one line of plain text.** A bare \`OQ-\` id in it links to this document's question of that id, so name the question rather than paraphrasing it.
|
|
2760
|
+
- **\`depends-on\` lists what the document waits on**: relative paths, resolved like links, each optionally ending in \`#OQ-\u2026\` to name one question. A single path may stand on its own. A target that does not exist, lies outside the repository, or does not contain the id is an error (\`planning/depends-on-missing\`).
|
|
2761
|
+
- **Never a \`priority\` key.** A priority only means something relative to the others, so it lives in an ordered list, a roadmap, and not in each document.
|
|
2762
|
+
- **A roadmap is ordered links.** Every \`roadmap.md\` Vantage reads is a roadmap, in any directory, unless \`[planning]\` lists the roadmaps instead; one in a hidden directory, matched by \`.vantageignore\`, or ruled out by \`include\` or \`exclude\` is not read, and so is not a roadmap. Each entry is a link to a document or a question, then a one-clause reason for its place; the badge beside the link carries the rest, so never copy a status, a stage or a count into it. Prose beneath an entry holds only what has no other home, such as what would unblock it. A bare link to a document *routes* every question in it into the roadmap's order, and a link to one question's \`#OQ-\u2026\` anchor routes that question; a link to any other heading routes nothing. A question is routed when any roadmap routes it, and with \`planning/unrouted\` turned on, \`vantage-check\` reports an open question no roadmap routes.
|
|
2763
|
+
- **\`[planning]\` in \`.vantage.toml\`** says which files are read, which of them are roadmaps, and what each stage word means. Every key is optional:
|
|
2764
|
+
\`\`\`toml
|
|
2765
|
+
[planning]
|
|
2766
|
+
# roadmap = ["roadmap.md", "docs/plans/roadmap.md"] # absent: every roadmap.md
|
|
2767
|
+
exclude = ["docs/gallery/**"] # gitignore syntax; every .md is included by default
|
|
2768
|
+
|
|
2769
|
+
[planning.stages] # each word maps to open | ready | built | done
|
|
2770
|
+
DESIGN = "open"
|
|
2771
|
+
DECIDED = "ready"
|
|
2772
|
+
BUILT = "built"
|
|
2773
|
+
SUPERSEDED = "done"
|
|
2774
|
+
\`\`\`
|
|
2775
|
+
- **Each stage word maps to one of four roles**, which is what the word means to Vantage: \`open\` is still being decided, \`ready\` is decided and not built, \`built\` is built, and \`done\` is no longer a live proposal, so its questions leave every list of what needs a ruling. \`vantage-check index\` prints those lists.
|
|
2776
|
+
- **Never change \`target\`.** A \`target = "X.Y"\` line at the top of \`.vantage.toml\` names the oldest Vantage release the repository's readers use, and only a human edits it. A \`vantage-check\` older than the target refuses to run and names the release it needs: run that release, and leave the target as it is.
|
|
2777
|
+
|
|
2006
2778
|
### Mermaid diagrams
|
|
2007
2779
|
- Use \`\`\`mermaid code blocks for flowcharts, sequence diagrams, and architecture diagrams. Vantage provides interactive zoom, pan, dark/light theme adaptation, and SVG export.
|
|
2008
2780
|
- **Quote labels with special characters**: Always quote node labels containing parentheses, brackets, or colons to prevent syntax errors:
|
|
@@ -2012,6 +2784,26 @@ flowchart TD
|
|
|
2012
2784
|
srv --> git["Git CLI (git diff)"]
|
|
2013
2785
|
\`\`\`
|
|
2014
2786
|
|
|
2787
|
+
### Inline SVG (needs Vantage 0.8 or later)
|
|
2788
|
+
- **Inline \`<svg>\` is a capability, not notation: it needs Vantage 0.8 or later.** GitHub, every other renderer and Vantage before 0.8 drop the drawing and leave the words of its \`<text>\` elements behind as loose text, so keep \`<text>\` to short labels a reader can bear to see as loose words.
|
|
2789
|
+
- **Name the drawing with \`aria-label\` on the \`<svg>\`, never with \`<title>\` or \`<desc>\`.** Vantage 0.8 removes both, so they name nothing there, and every other reader prints them: Vantage before 0.8 as loose words, GitHub with the tags.
|
|
2790
|
+
- **Pair every inline drawing with a fallback block**: a \`<!-- vantage: fallback -->\` directive above the block that stands in for the drawing. Vantage 0.8 and later never show that block; every other reader drops the comment and shows it. Say what the drawing shows and that it needs Vantage 0.8 or later, and link a copy of it committed as a file where there is one (\`[the drawing](diagram.svg)\`). The block is a paragraph, a list, a quote, a code block, a table or a \`<div>\`; Vantage shows a raw \`<img>\`, \`<figure>\` or \`<details>\` there, so wrap one in a \`<div>\`.
|
|
2791
|
+
- **Wrap the drawing in a \`<div>\` on a line of its own, with no blank line anywhere inside it**, and give it a \`width\`, a \`height\` and a \`viewBox\`. Paint with \`fill\`/\`stroke\` set to \`currentColor\` or a hex color, so it reads in light and dark mode.
|
|
2792
|
+
\`\`\`markdown
|
|
2793
|
+
<div>
|
|
2794
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40" role="img" aria-label="Two boxes joined by an arrow">
|
|
2795
|
+
<rect x="1" y="1" width="40" height="38" fill="none" stroke="currentColor"/>
|
|
2796
|
+
<path d="M45 20 H75 M68 14 L75 20 L68 26" fill="none" stroke="currentColor"/>
|
|
2797
|
+
<rect x="79" y="1" width="40" height="38" fill="none" stroke="currentColor"/>
|
|
2798
|
+
</svg>
|
|
2799
|
+
</div>
|
|
2800
|
+
|
|
2801
|
+
<!-- vantage: fallback -->
|
|
2802
|
+
|
|
2803
|
+
Two boxes joined by an arrow. This drawing needs Vantage 0.8 or later.
|
|
2804
|
+
\`\`\`
|
|
2805
|
+
- **For a document read mostly on GitHub**, commit the drawing as a file and embed it with \`\` instead, which every renderer shows.
|
|
2806
|
+
|
|
2015
2807
|
### Code blocks and diffs
|
|
2016
2808
|
- Always tag fenced code blocks with language identifiers (\`ts\`, \`go\`, \`python\`, \`bash\`, \`json\`, \`yaml\`, \`diff\`, \`sql\`, etc.) for syntax highlighting.
|
|
2017
2809
|
- For proposed code modifications, use \`\`\`diff blocks with \`+\` and \`-\` prefixes:
|
|
@@ -2049,25 +2841,60 @@ Vantage reads a few styling hints from ordinary HTML comments. Every other rende
|
|
|
2049
2841
|
The steps below predate the rewrite.
|
|
2050
2842
|
\`\`\`
|
|
2051
2843
|
|
|
2052
|
-
- **
|
|
2844
|
+
- **Four names to write**: \`section\` (the heading and everything under it), \`block\` (the one block after it), \`question\` (one Open Question, in any state; see Open questions below), \`fallback\` (the one block after it, which Vantage 0.8 and later never show; see Inline SVG above). \`fallback\` takes no keys, and withholds only a paragraph, a list, a quote, a code block, a table, a rule or a \`<div>\`, never a heading. A fifth, \`oq\`, is the name \`question\` replaces (below).
|
|
2053
2845
|
- **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 color — the theme decides what a warning looks like, in light mode, in dark mode, and in print.
|
|
2054
2846
|
- **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.
|
|
2055
2847
|
- **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.
|
|
2056
2848
|
- **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.
|
|
2057
2849
|
- **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.
|
|
2058
|
-
- **
|
|
2850
|
+
- **A question's id is \`OQ-\` then an optional short uppercase prefix then digits** — \`OQ-9\`, \`OQ-TP6\`, \`OQ-A03\`. The prefix is what keeps ids distinct once one document references another's questions, so use one in both whenever they cross-reference. \`vantage-check\` reports anything outside that shape as \`vantage/oq-id-format\`, and the same id twice in one document as \`vantage/oq-id-duplicate\` — both are silent otherwise, because the id becomes the block's anchor and a refused or duplicated one simply goes nowhere.
|
|
2059
2851
|
- **A reference is a link, or it is a lie.** An \`OQ-\` id, a \`\u00a7N\` section number and a filename all read like pointers, and written as bare prose none of them can be followed or checked — which is exactly why a stale one is never caught. Link the question to its anchor (\`[OQ-4](#OQ-4)\`, or the Decision Ledger once it is compacted), the section to its heading, the filename to the file. \`vantage-check\` reports all three (\`ref/*\`) as errors, and checks that the link points at the thing the reference names rather than merely at something. Writing a specimen rather than a reference? Put it in a fenced block, which the rules never read.
|
|
2060
|
-
|
|
2061
|
-
|
|
2852
|
+
|
|
2853
|
+
### Open questions
|
|
2854
|
+
A question a document leaves for a human to rule on is an *Open Question*, written in a numbered list under a heading of its own.
|
|
2855
|
+
|
|
2856
|
+
- **Write each question in its parts, never as one run-together paragraph.** Vantage lays a question out by these parts: the contents column and the planning page's card show its title as the question, and the card shows the leaning as the leaning and the Answer as the answer, so a part run into another one is read as neither. Each part is a block of its own, with a blank line before it:
|
|
2857
|
+
1. **The title line**: the status marker, the id and the question itself in bold, saying what is being decided: \`\u{1F4AC} **OQ-9: Where does a job go when it re-enters the queue?**\`.
|
|
2858
|
+
2. **The context**, in short paragraphs: only what a ruling needs. Background, history and cross-references belong in the document's own sections, linked from the question.
|
|
2859
|
+
3. **The options as a list**, one item each, when there are options to choose between.
|
|
2860
|
+
4. **\`_Leaning:_\` as a paragraph of its own**: which option, and why, in a sentence or two.
|
|
2861
|
+
5. **\`**Answer:**\` as a paragraph of its own**, with the ruling below it once there is one.
|
|
2062
2862
|
|
|
2063
2863
|
\`\`\`markdown
|
|
2064
|
-
|
|
2864
|
+
## Open Questions
|
|
2865
|
+
|
|
2866
|
+
1. \u{1F4AC} **OQ-9: Where does a job go when it re-enters the queue?**
|
|
2065
2867
|
|
|
2066
|
-
|
|
2868
|
+
A job that fails its checks leaves the queue and comes back once it is fixed. What merged while it was out may change what it does.
|
|
2067
2869
|
|
|
2068
|
-
|
|
2870
|
+
- **A — The back of the queue.** It waits its turn again.
|
|
2871
|
+
- **B — Its old place.** It skips ahead of what joined since.
|
|
2872
|
+
|
|
2873
|
+
<!-- vantage: question id=OQ-9 leaning="A — the back of the queue: the fix might interact with what merged while it was out." -->
|
|
2874
|
+
|
|
2875
|
+
_Leaning:_ A. The fix might interact with what merged while it was out.
|
|
2876
|
+
|
|
2877
|
+
**Answer:**
|
|
2878
|
+
|
|
2879
|
+
> _(empty — fill in when decided)_
|
|
2880
|
+
|
|
2881
|
+
2. \u{1F512} **OQ-10: How large is the retry budget?**
|
|
2882
|
+
|
|
2883
|
+
<!-- vantage: question id=OQ-10 -->
|
|
2884
|
+
|
|
2885
|
+
Waits on the load test, so there is no leaning to state yet.
|
|
2069
2886
|
\`\`\`
|
|
2070
2887
|
|
|
2888
|
+
- **The marker is the question's state**: \u{1F4AC} open (\u{1F4AC} \u{1F937} when it is a matter of preference), \u{1F512} blocked on something upstream that has to happen first, \u2705 answered. Review mode offers a one-click **Take this leaning** on an open question, and on nothing else.
|
|
2889
|
+
- **Every question gets a \`question\` directive**, \`<!-- vantage: question id=OQ-9 leaning="…" -->\`, in every state, indented into its list item on a line of its own directly above the \`_Leaning:_\` paragraph, or above another of its paragraphs when it states no leaning. The directive is what makes it a question to Vantage: it gives the question its \`#OQ-9\` anchor, and Vantage's planning index (its model of a repository's plans) reads a question from nothing else, so without one nothing counts it, badges it or lists it as waiting. It goes when the question is compacted into the Decision Ledger, and not before.
|
|
2890
|
+
- **Changing a question's state changes its marker and nothing else.** Mark it \u{1F512} when it cannot be answered yet, and \u2705 once it is decided; the directive stays as it is, its \`leaning\` included, which nothing offers to take once the question is not open.
|
|
2891
|
+
- **Every open question with a stated leaning restates it as the directive's \`leaning\`.** The convention's prose — the emoji, the \`OQ-N\` id, the \`_Leaning:_\` line, the fill-in \`**Answer:**\` — produces no button on its own, and writing it and stopping there is the most common way the button goes missing: the questions look complete, review mode is on, and there is nothing to click. \`vantage-check\` reports an open question with a \`_Leaning:_\` and no directive as an error (\`vantage/oq-missing\`). An open question with no leaning yet takes the directive without \`leaning\`, so the planning index still counts it; until it states one, its button files the literal text "Take the stated leaning."
|
|
2892
|
+
- **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.
|
|
2893
|
+
- **Keep the text below the title short.** The planning page shows each question as a card that leads with the title and shows only the first few lines of the rest, so a question buried in a paragraph of background is one the reviewer has to dig for. \`vantage-check\` warns when a question's text, not counting its \`_Leaning:_\` paragraph and its \`**Answer:**\`, runs past 120 words (\`planning/question-length\`), and when a leaning shares a paragraph with other text (\`vantage/question-layout\`); a repository can raise the limit with \`max-words\`, or turn either rule off, under \`[check.rules]\`.
|
|
2894
|
+
- **Enumerate with a list, never inline.** A question's options, or any three steps or parts, go in a Markdown list, one item per line, rather than run together in one sentence as \`(a) \u2026 (b) \u2026 (c)\`, where a reader has to find where each one starts. \`vantage-check\` warns on a paragraph that runs three or more together (\`prose/inline-list\`).
|
|
2895
|
+
- **Write a question as a list item.** Outside a list, a question runs from the block its directive lands on through the blocks after it, up to the next heading (for a question written as a heading, the next one of its level or higher), the next rule or the next question, so whatever prose follows it before then is read as part of it. There the directive goes above the question's title, since every viewer reads the question's marker from the block it lands on: a directive below the bold title lands on the block after it, which carries no marker, so every viewer reads the question as open. \`vantage-check\` reports that as \`vantage/question-name\`.
|
|
2896
|
+
- **\`oq\` is the name \`question\` replaces.** Vantage keeps reading \`<!-- vantage: oq id=OQ-9 leaning="…" -->\` as it always has, and \`vantage-check\` warns on it (\`vantage/oq-deprecated\`) with the \`question\` to write instead, keys unchanged. Vantage before 0.8 drops \`question\` whole, so a reader still on 0.7 gets no one-click answer and no anchor on a question written with it, and misreads nothing. So keep an \`oq\` on an open question unless you know every reader of the repository is on 0.8 or later. A repository whose readers are still on 0.7 says so with a \`target\` before 0.8, such as \`target = "0.7"\`: there an open question takes \`oq\`, new ones included, and \`vantage-check\` asks for \`oq\` rather than \`question\` on one. **Never put \`oq\` on a \u{1F512} or \u2705 question**: every Vantage before 0.8 offers the one-click button on every \`oq\`, whatever its marker says, and on a question with no leaning it files the literal text "Take the stated leaning." \`vantage-check\` reports that as an error (\`vantage/question-name\`).
|
|
2897
|
+
|
|
2071
2898
|
### Tables, task lists, and math
|
|
2072
2899
|
- **Tables**: Use standard markdown tables for structured comparisons and schemas.
|
|
2073
2900
|
- **Task lists**: Use \`- [ ]\` and \`- [x]\` for actionable checklists and status tracking.
|
|
@@ -2089,9 +2916,14 @@ exports.VANTAGE_BADGES = VANTAGE_BADGES;
|
|
|
2089
2916
|
exports.VANTAGE_COLLAPSED = VANTAGE_COLLAPSED;
|
|
2090
2917
|
exports.VANTAGE_EMPHASIS = VANTAGE_EMPHASIS;
|
|
2091
2918
|
exports.VANTAGE_FRONTMATTER_KEYS = VANTAGE_FRONTMATTER_KEYS;
|
|
2919
|
+
exports.VANTAGE_LEANING_ATTRIBUTE = VANTAGE_LEANING_ATTRIBUTE;
|
|
2920
|
+
exports.VANTAGE_OQ_ATTRIBUTE = VANTAGE_OQ_ATTRIBUTE;
|
|
2092
2921
|
exports.VANTAGE_OQ_HOST_TARGETS = VANTAGE_OQ_HOST_TARGETS;
|
|
2093
2922
|
exports.VANTAGE_OQ_STATUS = VANTAGE_OQ_STATUS;
|
|
2094
2923
|
exports.VANTAGE_OQ_STATUS_LABEL = VANTAGE_OQ_STATUS_LABEL;
|
|
2924
|
+
exports.VANTAGE_QUESTION_ATTRIBUTE = VANTAGE_QUESTION_ATTRIBUTE;
|
|
2925
|
+
exports.VANTAGE_QUESTION_NAMES = VANTAGE_QUESTION_NAMES;
|
|
2926
|
+
exports.VANTAGE_QUESTION_SELECTOR = VANTAGE_QUESTION_SELECTOR;
|
|
2095
2927
|
exports.VANTAGE_RUNS = VANTAGE_RUNS;
|
|
2096
2928
|
exports.VANTAGE_SENTINEL = VANTAGE_SENTINEL;
|
|
2097
2929
|
exports.VANTAGE_TONES = VANTAGE_TONES;
|
|
@@ -2101,9 +2933,11 @@ exports.clearLineAnchorHighlights = clearLineAnchorHighlights;
|
|
|
2101
2933
|
exports.currentColorTheme = currentColorTheme;
|
|
2102
2934
|
exports.hasVantageSentinel = hasVantageSentinel;
|
|
2103
2935
|
exports.isDocStatus = isDocStatus;
|
|
2936
|
+
exports.isQuestionDirective = isQuestionDirective;
|
|
2104
2937
|
exports.parseFrontmatter = parseFrontmatter;
|
|
2105
2938
|
exports.parseLineAnchor = parseLineAnchor;
|
|
2106
2939
|
exports.parseVantageDirective = parseVantageDirective;
|
|
2940
|
+
exports.questionOffersTake = questionOffersTake;
|
|
2107
2941
|
exports.readVantageFrontmatter = readVantageFrontmatter;
|
|
2108
2942
|
exports.rehypeSourceLines = rehypeSourceLines;
|
|
2109
2943
|
exports.rehypeVantageAlerts = rehypeVantageAlerts;
|