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.js
CHANGED
|
@@ -2,14 +2,14 @@ import { unified } from "unified";
|
|
|
2
2
|
import remarkParse from "remark-parse";
|
|
3
3
|
import remarkRehype from "remark-rehype";
|
|
4
4
|
import rehypeStringify from "rehype-stringify";
|
|
5
|
-
import remarkGfm from "remark-gfm";
|
|
6
|
-
import remarkMath from "remark-math";
|
|
7
5
|
import rehypeRaw from "rehype-raw";
|
|
8
6
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
9
7
|
import rehypeHighlight from "rehype-highlight";
|
|
10
8
|
import rehypeKatex from "rehype-katex";
|
|
11
9
|
import rehypeSlug from "rehype-slug";
|
|
12
10
|
import { visit } from "unist-util-visit";
|
|
11
|
+
import remarkGfm from "remark-gfm";
|
|
12
|
+
import remarkMath from "remark-math";
|
|
13
13
|
import YAML from "yaml";
|
|
14
14
|
import { parse } from "smol-toml";
|
|
15
15
|
//#region src/rehypeVantageAnchors.ts
|
|
@@ -124,6 +124,12 @@ const VANTAGE_ALERTS = [
|
|
|
124
124
|
"warning",
|
|
125
125
|
"caution"
|
|
126
126
|
];
|
|
127
|
+
/**
|
|
128
|
+
* The class on the title element this plugin injects, which
|
|
129
|
+
* `styles/directives.css` styles. Exported because the sanitizer runs after
|
|
130
|
+
* this plugin and admits a `div`'s class only when it is this one.
|
|
131
|
+
*/
|
|
132
|
+
const ALERT_TITLE_CLASS = "vantage-alert-title";
|
|
127
133
|
/** The visible label per kind. Title case, as GitHub renders it. */
|
|
128
134
|
const ALERT_TITLES = {
|
|
129
135
|
note: "Note",
|
|
@@ -147,7 +153,7 @@ const ALERT_TITLES = {
|
|
|
147
153
|
* anchored test on the first text node is enough, and the plugin does not have
|
|
148
154
|
* to reassemble the marker across siblings.
|
|
149
155
|
*/
|
|
150
|
-
const
|
|
156
|
+
const ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/;
|
|
151
157
|
/** The first child, if it is an element. */
|
|
152
158
|
function firstElement(node) {
|
|
153
159
|
const child = node.children.find((c) => c.type === "element" || c.type === "text" && c.value.trim() !== "");
|
|
@@ -174,7 +180,7 @@ function rehypeVantageAlerts() {
|
|
|
174
180
|
if (paragraph === void 0 || paragraph.tagName !== "p") return;
|
|
175
181
|
const lead = paragraph.children[0];
|
|
176
182
|
if (lead === void 0 || lead.type !== "text") return;
|
|
177
|
-
const match =
|
|
183
|
+
const match = ALERT_MARKER.exec(lead.value);
|
|
178
184
|
if (match === null) return;
|
|
179
185
|
const kind = match[1].toLowerCase();
|
|
180
186
|
lead.value = lead.value.slice(match[0].length);
|
|
@@ -186,7 +192,7 @@ function rehypeVantageAlerts() {
|
|
|
186
192
|
node.children.unshift({
|
|
187
193
|
type: "element",
|
|
188
194
|
tagName: "div",
|
|
189
|
-
properties: { className: [
|
|
195
|
+
properties: { className: [ALERT_TITLE_CLASS] },
|
|
190
196
|
children: [{
|
|
191
197
|
type: "text",
|
|
192
198
|
value: ALERT_TITLES[kind]
|
|
@@ -233,16 +239,78 @@ const VANTAGE_SENTINEL = "vantage:";
|
|
|
233
239
|
*
|
|
234
240
|
* Position picks the target; the name picks the extent. `section` before a
|
|
235
241
|
* heading reaches the heading's whole section, `block` reaches one block, and
|
|
236
|
-
* `
|
|
237
|
-
*
|
|
238
|
-
*
|
|
242
|
+
* `question` declares one question in any state, as `oq`, the name it
|
|
243
|
+
* replaces, still does (`VANTAGE_QUESTION_NAMES`). The name cannot disagree
|
|
244
|
+
* with position — it only says how far the stamp reaches — so §4.2's refusal
|
|
245
|
+
* of a `scope=` key stands.
|
|
246
|
+
*
|
|
247
|
+
* `fallback` is the one name that stamps nothing: it withholds the block after
|
|
248
|
+
* it, which is written for the renderers that cannot show something Vantage
|
|
249
|
+
* shows — an inline `<svg>`, today. Every renderer that does not know the name
|
|
250
|
+
* drops the comment and shows the block, a 0.7.x Vantage included, which is
|
|
251
|
+
* the whole mechanism: the name is new, so no older reader gives it a meaning
|
|
252
|
+
* (`docs/reference/inline-markup.md`, "Fallback blocks").
|
|
239
253
|
*/
|
|
240
254
|
const DIRECTIVE_NAMES = [
|
|
241
255
|
"section",
|
|
242
256
|
"block",
|
|
243
|
-
"oq"
|
|
257
|
+
"oq",
|
|
258
|
+
"question",
|
|
259
|
+
"fallback"
|
|
244
260
|
];
|
|
245
261
|
/**
|
|
262
|
+
* The two names that declare a question: `question`, the one to write, and
|
|
263
|
+
* `oq`, which it replaces and which every Vantage keeps reading.
|
|
264
|
+
*
|
|
265
|
+
* - **`question`** declares a question in any state: open (💬, 💬 🤷 or no
|
|
266
|
+
* marker at all), 🔒 blocked or ✅ answered. The state comes from the marker
|
|
267
|
+
* and never from the name, so a question that changes state changes its
|
|
268
|
+
* marker and nothing else. A viewer from 0.8 on offers Take this leaning on
|
|
269
|
+
* an open one, and never on a 🔒 or ✅ one.
|
|
270
|
+
* - **`oq`** is deprecated, never removed. It keeps the meaning it shipped
|
|
271
|
+
* with in 0.7, an open question to answer in one click, because documents
|
|
272
|
+
* outlive releases and a release never gives existing notation a new
|
|
273
|
+
* meaning (`docs/design/checker-version-skew.md`, P0). Every viewer before
|
|
274
|
+
* 0.8 offers the button on every `oq` it meets, whatever its marker says,
|
|
275
|
+
* so `vantage-check` reports one on a 🔒 or ✅ question as an error and any
|
|
276
|
+
* other as a warning that names the `question` to write.
|
|
277
|
+
*
|
|
278
|
+
* The two take the same keys, `id` and `leaning`, with one grammar. A viewer
|
|
279
|
+
* that predates `question` drops it whole, as it drops every name it does not
|
|
280
|
+
* know, so on a question written with it that viewer offers no button and no
|
|
281
|
+
* anchor: it does less, and misreads nothing. A run holding both names is one
|
|
282
|
+
* question, read as such a viewer reads it: where both set a key, the `oq`'s
|
|
283
|
+
* value wins.
|
|
284
|
+
*/
|
|
285
|
+
const VANTAGE_QUESTION_NAMES = ["oq", "question"];
|
|
286
|
+
/** Whether `name` declares a question, `question` or the deprecated `oq`. */
|
|
287
|
+
function isQuestionDirective(name) {
|
|
288
|
+
return name === "oq" || name === "question";
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The attribute every question carries in the rendered page, whichever name
|
|
292
|
+
* declared it, with the value `"true"`. It is how a reader of this package's
|
|
293
|
+
* markup finds every question: `[data-vantage-question]`
|
|
294
|
+
* (`VANTAGE_QUESTION_SELECTOR`).
|
|
295
|
+
*/
|
|
296
|
+
const VANTAGE_QUESTION_ATTRIBUTE = "data-vantage-question";
|
|
297
|
+
/** `[data-vantage-question]`: every question in a rendered page. */
|
|
298
|
+
const VANTAGE_QUESTION_SELECTOR = `[${VANTAGE_QUESTION_ATTRIBUTE}]`;
|
|
299
|
+
/**
|
|
300
|
+
* The attribute a question declared with `oq` carries besides
|
|
301
|
+
* `VANTAGE_QUESTION_ATTRIBUTE`, with the value `"true"`. It means what it has
|
|
302
|
+
* meant since 0.7, "declared with `oq`", and nothing more: whether a question
|
|
303
|
+
* offers Take this leaning is read from its state (`questionOffersTake`),
|
|
304
|
+
* never from this attribute.
|
|
305
|
+
*/
|
|
306
|
+
const VANTAGE_OQ_ATTRIBUTE = "data-vantage-oq";
|
|
307
|
+
/**
|
|
308
|
+
* The attribute that carries a question's `leaning=`, normalized
|
|
309
|
+
* (`normalizeLeaning`), on the element `VANTAGE_QUESTION_ATTRIBUTE` is on,
|
|
310
|
+
* whichever name declared it. Absent when the directive states none.
|
|
311
|
+
*/
|
|
312
|
+
const VANTAGE_LEANING_ATTRIBUTE = "data-vantage-leaning";
|
|
313
|
+
/**
|
|
246
314
|
* The `tone` vocabulary: GitHub's alert words plus `muted`.
|
|
247
315
|
*
|
|
248
316
|
* Semantic, never chromatic (P2, Ledger OQ-3). A document says what a section
|
|
@@ -334,13 +402,32 @@ const VANTAGE_STYLE_TARGETS = [
|
|
|
334
402
|
"div"
|
|
335
403
|
];
|
|
336
404
|
/**
|
|
337
|
-
* The tags
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
405
|
+
* The tags a `fallback` directive may **withhold**: `VANTAGE_STYLE_TARGETS`
|
|
406
|
+
* minus the six headings, written as a subtraction so the narrowing stays
|
|
407
|
+
* visible.
|
|
408
|
+
*
|
|
409
|
+
* A heading is refused because it is the document's structure rather than a
|
|
410
|
+
* stand-in for anything: the contents column lists it, other documents link to
|
|
411
|
+
* its slug, and the planning index reads sections under it. A fallback heading
|
|
412
|
+
* would be an anchor that exists on GitHub and nowhere in Vantage. What a
|
|
413
|
+
* fallback holds — a sentence naming the release a drawing needs, a link to
|
|
414
|
+
* the drawing as a file, an ASCII sketch in a fence — is a paragraph, a list,
|
|
415
|
+
* a quote, a code block, a table or a `<div>`, and those are all here.
|
|
342
416
|
*
|
|
343
|
-
*
|
|
417
|
+
* Three callers read it, and they must agree: `rehypeVantageDirectives`, which
|
|
418
|
+
* withholds the block; the planning index's scan, which must not read a
|
|
419
|
+
* question or a link from a block the page never shows; and the checker's
|
|
420
|
+
* `vantage/orphan`, which reports a fallback that withholds nothing.
|
|
421
|
+
*/
|
|
422
|
+
const VANTAGE_FALLBACK_TARGETS = VANTAGE_STYLE_TARGETS.filter((tag) => !/^h[1-6]$/.test(tag));
|
|
423
|
+
/**
|
|
424
|
+
* The tags a question directive, `oq` or `question`, may stamp — strictly the
|
|
425
|
+
* tags the review system can resolve an anchor on (`ANCHOR_TAGS` in the app's
|
|
426
|
+
* `MarkdownViewer`, and the block map in `useReviewHighlights`). `ul`, `ol`,
|
|
427
|
+
* `tr`, `hr` and `div` are in neither, so a button on one of them would build
|
|
428
|
+
* an anchor no review pass can find — the "mis-wired button" D6 forbids.
|
|
429
|
+
*
|
|
430
|
+
* The gap between this list and `VANTAGE_STYLE_TARGETS` is why a question
|
|
344
431
|
* directive at column 0 above a list silently does nothing: the target is the
|
|
345
432
|
* `<ul>`, not the `<li>`. The checker says so.
|
|
346
433
|
*/
|
|
@@ -358,9 +445,11 @@ const VANTAGE_ANCHOR_TARGETS = [
|
|
|
358
445
|
"table"
|
|
359
446
|
];
|
|
360
447
|
/**
|
|
361
|
-
* The tags a
|
|
362
|
-
* `VANTAGE_ANCHOR_TARGETS` minus `pre`
|
|
363
|
-
* subtraction so the narrowing stays
|
|
448
|
+
* The tags a question directive declares a question on, and so the only ones
|
|
449
|
+
* an open question's *button* can sit on — `VANTAGE_ANCHOR_TARGETS` minus `pre`
|
|
450
|
+
* and `table`, written as an explicit subtraction so the narrowing stays
|
|
451
|
+
* visible. One list for both names and every state, so a question that changes
|
|
452
|
+
* state is declared in exactly the same place.
|
|
364
453
|
*
|
|
365
454
|
* Anchorable and button-hosting are different questions, and this is the second
|
|
366
455
|
* one. A comment *can* be anchored on a `<pre>` or a `<table>` — both are in
|
|
@@ -368,17 +457,18 @@ const VANTAGE_ANCHOR_TARGETS = [
|
|
|
368
457
|
* button renders as part of the code, and a `<button>` child of `<table>` is not
|
|
369
458
|
* valid HTML at all, so the parser hoists it out.
|
|
370
459
|
*
|
|
371
|
-
*
|
|
372
|
-
* `useOpenQuestionButtons`,
|
|
373
|
-
* `vantage/orphan`. They were two hand-written lists that
|
|
374
|
-
* checker called an `oq` above a fence fine while the app
|
|
375
|
-
* and said nothing, which is the D5 break this module
|
|
460
|
+
* Every consumer reads it from here: `OQ_HOST_TAGS` in the app's
|
|
461
|
+
* `useOpenQuestionButtons`, the planning index's scan, and the question branch
|
|
462
|
+
* of the checker's `vantage/orphan`. They were two hand-written lists that
|
|
463
|
+
* disagreed — the checker called an `oq` above a fence fine while the app
|
|
464
|
+
* rendered no button and said nothing, which is the D5 break this module
|
|
465
|
+
* exists to prevent.
|
|
376
466
|
*/
|
|
377
467
|
const VANTAGE_OQ_HOST_TARGETS = VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
|
|
378
468
|
/**
|
|
379
|
-
* The shape of
|
|
380
|
-
*
|
|
381
|
-
* and a bare `OQ6` are not.
|
|
469
|
+
* The shape of a question directive's `id`, on `oq` and `question` alike:
|
|
470
|
+
* `OQ-` then an optional short uppercase prefix then digits. `OQ-9`, `OQ-TP6`
|
|
471
|
+
* and `OQ-A03` are ids; `OQ-foo`, `OQ-tp6` and a bare `OQ6` are not.
|
|
382
472
|
*
|
|
383
473
|
* The prefix is what keeps ids distinct once one document references another's
|
|
384
474
|
* questions — `trust-paths.md`'s `OQ-4` and a design sketch's `OQ-4` are
|
|
@@ -386,10 +476,15 @@ const VANTAGE_OQ_HOST_TARGETS = VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "
|
|
|
386
476
|
* reference means. It is optional because most documents never leave their own
|
|
387
477
|
* file, and requiring it everywhere would fire on every single-doc sketch.
|
|
388
478
|
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
* the checker
|
|
392
|
-
*
|
|
479
|
+
* Read from here, never re-spelled, by the sanitizer that allowlists the value,
|
|
480
|
+
* by the checker's `collectOqIds` (which feeds `vantage/oq-id-format` and the
|
|
481
|
+
* anchors the checker accepts) and by the planning index. The directive plugin
|
|
482
|
+
* that stamps the anchor deliberately does not test it: it stamps any non-empty
|
|
483
|
+
* id and leaves refusing a malformed one to the sanitizer. A re-spelled copy is
|
|
484
|
+
* how the checker starts calling a working anchor malformed — and the patterns
|
|
485
|
+
* that look for an id in text (`OQ_REFERENCE` and `OQ_EXACT` in the checker's
|
|
486
|
+
* `rules/references.ts`, `OQ_TOKEN` in `planning/scan.ts` and in
|
|
487
|
+
* `FrontmatterDisplay.tsx`) restate this shape, so they change with it.
|
|
393
488
|
*/
|
|
394
489
|
const VANTAGE_OQ_ID = /^OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+$/;
|
|
395
490
|
/**
|
|
@@ -405,11 +500,12 @@ const VANTAGE_OQ_ID = /^OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+$/;
|
|
|
405
500
|
* that a screen reader can say. They were private constants in the checker until
|
|
406
501
|
* the second consumer arrived.
|
|
407
502
|
*
|
|
408
|
-
* `open` is the only state that wants a one-click answer
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
503
|
+
* `open` is the only state that wants a one-click answer, so it is the only one
|
|
504
|
+
* a viewer offers Take this leaning on (`questionOffersTake`). A `settled` or
|
|
505
|
+
* `blocked` question carries no control — a control offering to answer a
|
|
506
|
+
* question that is already decided, or that cannot be answered yet, is a lie.
|
|
507
|
+
* The checker keys on the distinction rather than on the word "Leaning:"
|
|
508
|
+
* alone. Either non-open marker wins when both appear on one item.
|
|
413
509
|
*/
|
|
414
510
|
const VANTAGE_OQ_STATUS = {
|
|
415
511
|
/** 💬 — an active decision awaiting a ruling. */
|
|
@@ -444,6 +540,20 @@ function vantageOqStatus(text) {
|
|
|
444
540
|
if (text.includes(VANTAGE_OQ_STATUS.open)) return "open";
|
|
445
541
|
return null;
|
|
446
542
|
}
|
|
543
|
+
/**
|
|
544
|
+
* Whether a question in this state offers Take this leaning in review mode: an
|
|
545
|
+
* open one, marked 💬 or 💬 🤷, or one carrying no marker, which counts as
|
|
546
|
+
* open. A 🔒 question cannot be answered yet and a ✅ one has been ruled, so
|
|
547
|
+
* neither has a leaning left to take, whatever its directive's name.
|
|
548
|
+
*
|
|
549
|
+
* One home for the rule, because every surface that answers it must agree:
|
|
550
|
+
* the page's button and the planning card, the counts of questions waiting on
|
|
551
|
+
* a human, and `vantage-check`, which reports an `oq` on a question this says
|
|
552
|
+
* no to, since every viewer before 0.8 offers the button there anyway.
|
|
553
|
+
*/
|
|
554
|
+
function questionOffersTake(status) {
|
|
555
|
+
return status === null || status === "open";
|
|
556
|
+
}
|
|
447
557
|
const STYLE_KEYS = {
|
|
448
558
|
tone: VANTAGE_TONES,
|
|
449
559
|
emphasis: VANTAGE_EMPHASIS,
|
|
@@ -454,10 +564,21 @@ const STYLE_KEYS = {
|
|
|
454
564
|
* Name → key → the closed value set for that key.
|
|
455
565
|
*
|
|
456
566
|
* `section` and `block` share their keys: they differ in *extent*, not in what
|
|
457
|
-
* they can say.
|
|
458
|
-
*
|
|
459
|
-
* neither can be value-allowlisted,
|
|
460
|
-
* than left to a caller to guess.
|
|
567
|
+
* they can say. The two question names share theirs too, and they are the
|
|
568
|
+
* design's only values with no closed set — `id` is a token an author chose
|
|
569
|
+
* and `leaning` is a sentence (§8.3) — so neither can be value-allowlisted,
|
|
570
|
+
* which is recorded here as `null` rather than left to a caller to guess.
|
|
571
|
+
*
|
|
572
|
+
* `question` takes exactly `oq`'s keys and nothing more. Starting narrow is
|
|
573
|
+
* the forward-compatible choice: a key added later is one older viewers drop
|
|
574
|
+
* pair by pair, while a key accepted now could never be given a meaning (P0).
|
|
575
|
+
* A `leaning` on a 🔒 or ✅ question is allowed and simply not offered
|
|
576
|
+
* (`questionOffersTake`), so changing a question's state changes only its
|
|
577
|
+
* marker.
|
|
578
|
+
*
|
|
579
|
+
* `fallback` takes no keys: whether a block is withheld is the whole of what
|
|
580
|
+
* it says, and a key added later is dropped pair by pair by this release, as
|
|
581
|
+
* D2 drops any other.
|
|
461
582
|
*/
|
|
462
583
|
const DIRECTIVE_VOCABULARY = {
|
|
463
584
|
section: STYLE_KEYS,
|
|
@@ -465,9 +586,59 @@ const DIRECTIVE_VOCABULARY = {
|
|
|
465
586
|
oq: {
|
|
466
587
|
id: null,
|
|
467
588
|
leaning: null
|
|
468
|
-
}
|
|
589
|
+
},
|
|
590
|
+
question: {
|
|
591
|
+
id: null,
|
|
592
|
+
leaning: null
|
|
593
|
+
},
|
|
594
|
+
fallback: {}
|
|
469
595
|
};
|
|
470
596
|
/**
|
|
597
|
+
* Merge the question directives of one run — the comments up to the block they
|
|
598
|
+
* land on — into the one question they declare, or `undefined` when the run
|
|
599
|
+
* holds none. Directives of any other name are skipped.
|
|
600
|
+
*
|
|
601
|
+
* A run holding an `oq` is read exactly as a viewer that predates `question`
|
|
602
|
+
* reads it: that viewer drops every `question` and reads the `oq` alone, so
|
|
603
|
+
* the `oq` wins wherever it appears in the run, and only its keys apply — a
|
|
604
|
+
* key only a `question` in the same run sets applies nowhere, in no release.
|
|
605
|
+
* That is the one reading every viewer agrees on (`VANTAGE_QUESTION_NAMES`),
|
|
606
|
+
* so the same bytes file the same Take in 0.7 and in 0.8. Within one name, the
|
|
607
|
+
* last value of a key wins, as `section` and `block` resolve theirs. A pair
|
|
608
|
+
* its name does not accept, or a value outside a closed set, is dropped (D2).
|
|
609
|
+
*
|
|
610
|
+
* One function for every reader of a run: the plugin that stamps the question
|
|
611
|
+
* and the planning index that reads it, which the checker reads a question
|
|
612
|
+
* through, so none can disagree about which leaning or which id a run carries
|
|
613
|
+
* (D5).
|
|
614
|
+
*/
|
|
615
|
+
function mergeQuestionRun(run) {
|
|
616
|
+
let name;
|
|
617
|
+
for (const directive of run) {
|
|
618
|
+
if (!isQuestionDirective(directive.name)) continue;
|
|
619
|
+
if (name !== "oq") name = directive.name;
|
|
620
|
+
}
|
|
621
|
+
if (name === void 0) return void 0;
|
|
622
|
+
const accepted = DIRECTIVE_VOCABULARY[name] ?? {};
|
|
623
|
+
const keys = /* @__PURE__ */ new Map();
|
|
624
|
+
const sources = /* @__PURE__ */ new Map();
|
|
625
|
+
run.forEach((directive, index) => {
|
|
626
|
+
if (directive.name !== name) return;
|
|
627
|
+
for (const pair of directive.pairs) {
|
|
628
|
+
const values = accepted[pair.key];
|
|
629
|
+
if (values === void 0) continue;
|
|
630
|
+
if (values !== null && !values.includes(pair.value)) continue;
|
|
631
|
+
keys.set(pair.key, pair.value);
|
|
632
|
+
sources.set(pair.key, index);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
return {
|
|
636
|
+
name,
|
|
637
|
+
keys,
|
|
638
|
+
sources
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
471
642
|
* `ws` is `[ \t\r\n]` — the design's grammar leaves it undefined, and `\n` has
|
|
472
643
|
* to be in the set because a directive may legally wrap: a multi-line comment
|
|
473
644
|
* is one node whose value contains the newlines.
|
|
@@ -583,10 +754,24 @@ function parseVantageDirective(comment) {
|
|
|
583
754
|
pairs
|
|
584
755
|
};
|
|
585
756
|
}
|
|
757
|
+
/**
|
|
758
|
+
* A question directive's `leaning=` value as the page carries it: whitespace
|
|
759
|
+
* runs collapsed to one space, trimmed, and capped at `MAX_LEANING`.
|
|
760
|
+
*
|
|
761
|
+
* A wrapped directive puts newlines and indentation into the value. Two readers
|
|
762
|
+
* need the result and must agree on it: `rehypeVantageDirectives`, which stamps
|
|
763
|
+
* it as `data-vantage-leaning` for the one-click answer, and the planning index,
|
|
764
|
+
* which reports the leaning a question carries. An empty result means the
|
|
765
|
+
* question states no leaning.
|
|
766
|
+
*/
|
|
767
|
+
function normalizeLeaning(raw) {
|
|
768
|
+
return raw.replace(/\s+/g, " ").trim().slice(0, 500);
|
|
769
|
+
}
|
|
586
770
|
//#endregion
|
|
587
771
|
//#region src/rehypeVantageDirectives.ts
|
|
588
772
|
/**
|
|
589
|
-
* What a `section`/`block` and
|
|
773
|
+
* What a `section`/`block` and a question directive (`oq`, `question`) may
|
|
774
|
+
* **target**.
|
|
590
775
|
*
|
|
591
776
|
* Both lists live in `vantageDirectives.ts`, with the reasoning for each tag,
|
|
592
777
|
* because the CLI checker resolves the same question over mdast and must reach
|
|
@@ -596,6 +781,8 @@ function parseVantageDirective(comment) {
|
|
|
596
781
|
*/
|
|
597
782
|
const STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
|
|
598
783
|
const ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
|
|
784
|
+
/** What a `fallback` may withhold: every style target but a heading. */
|
|
785
|
+
const FALLBACK_TARGET_TAGS = new Set(VANTAGE_FALLBACK_TARGETS);
|
|
599
786
|
const HEADING_DEPTHS = /* @__PURE__ */ new Map([
|
|
600
787
|
["h1", 1],
|
|
601
788
|
["h2", 2],
|
|
@@ -636,6 +823,19 @@ const RANGE_PROPERTIES = /* @__PURE__ */ new Map([["tone", "dataVantageTone"], [
|
|
|
636
823
|
*/
|
|
637
824
|
const POINT_PROPERTIES = /* @__PURE__ */ new Map([["badge", "dataVantageBadge"]]);
|
|
638
825
|
const RUN_PROPERTY = "dataVantageRun";
|
|
826
|
+
/**
|
|
827
|
+
* What a question stamps, whichever name declared it: `data-vantage-question`
|
|
828
|
+
* on every question, and `data-vantage-oq` besides on one an `oq` declared
|
|
829
|
+
* (`VANTAGE_QUESTION_ATTRIBUTE`, `VANTAGE_OQ_ATTRIBUTE`).
|
|
830
|
+
*
|
|
831
|
+
* Two attributes, and neither one ever means more than it says. Every release
|
|
832
|
+
* since 0.7 stamps `data-vantage-oq` on an `oq` and on nothing else, so a
|
|
833
|
+
* reader of this package's markup that has ever read it keeps reading exactly
|
|
834
|
+
* what it read; `data-vantage-question` is the one to read for "a question",
|
|
835
|
+
* and the question's state, from its marker, is what decides whether anything
|
|
836
|
+
* offers to answer it (`questionOffersTake`).
|
|
837
|
+
*/
|
|
838
|
+
const QUESTION_PROPERTY = "dataVantageQuestion";
|
|
639
839
|
const OQ_PROPERTY = "dataVantageOq";
|
|
640
840
|
const LEANING_PROPERTY = "dataVantageLeaning";
|
|
641
841
|
/**
|
|
@@ -670,8 +870,6 @@ const OQ_ID_PROPERTY = "dataVantageOqId";
|
|
|
670
870
|
const COLLAPSED_PROPERTY = "dataVantageCollapsed";
|
|
671
871
|
const COLLAPSE_GROUP_PROPERTY = "dataVantageCollapseGroup";
|
|
672
872
|
const COLLAPSE_TOGGLE_PROPERTY = "dataVantageCollapseToggle";
|
|
673
|
-
/** A review-comment body, not prose. Bounds the attribute; 500 is generous. */
|
|
674
|
-
const MAX_LEANING = 500;
|
|
675
873
|
/**
|
|
676
874
|
* Nodes that may sit between a directive and its target.
|
|
677
875
|
*
|
|
@@ -805,13 +1003,14 @@ function stampStyle(children, targetIndex, name, pairs, state) {
|
|
|
805
1003
|
}
|
|
806
1004
|
}
|
|
807
1005
|
}
|
|
808
|
-
function
|
|
809
|
-
setProperty(target,
|
|
1006
|
+
function stampQuestion(target, name, pairs) {
|
|
1007
|
+
setProperty(target, QUESTION_PROPERTY, "true");
|
|
1008
|
+
if (name === "oq") setProperty(target, OQ_PROPERTY, "true");
|
|
810
1009
|
const id = pairs.get("id");
|
|
811
1010
|
if (id !== void 0 && id !== "") setProperty(target, OQ_ID_PROPERTY, id);
|
|
812
1011
|
const leaning = pairs.get("leaning");
|
|
813
1012
|
if (leaning === void 0) return;
|
|
814
|
-
const text = leaning
|
|
1013
|
+
const text = normalizeLeaning(leaning);
|
|
815
1014
|
if (text !== "") setProperty(target, LEANING_PROPERTY, text);
|
|
816
1015
|
}
|
|
817
1016
|
/**
|
|
@@ -826,18 +1025,14 @@ function stampOq(target, pairs) {
|
|
|
826
1025
|
function stampRun(children, targetIndex, run, state) {
|
|
827
1026
|
const target = children[targetIndex];
|
|
828
1027
|
const style = /* @__PURE__ */ new Map();
|
|
829
|
-
const oq = /* @__PURE__ */ new Map();
|
|
830
1028
|
let styleName;
|
|
831
|
-
let hasOq = false;
|
|
832
1029
|
for (const directive of run) if (directive.name === "section" || directive.name === "block") {
|
|
833
1030
|
styleName = directive.name;
|
|
834
1031
|
for (const pair of directive.pairs) style.set(pair.key, pair.value);
|
|
835
|
-
} else if (directive.name === "oq") {
|
|
836
|
-
hasOq = true;
|
|
837
|
-
for (const pair of directive.pairs) oq.set(pair.key, pair.value);
|
|
838
1032
|
}
|
|
839
1033
|
if (styleName !== void 0) stampStyle(children, targetIndex, styleName, style, state);
|
|
840
|
-
|
|
1034
|
+
const question = mergeQuestionRun(run);
|
|
1035
|
+
if (question !== void 0 && ANCHOR_TARGET_TAGS.has(target.tagName)) stampQuestion(target, question.name, question.keys);
|
|
841
1036
|
}
|
|
842
1037
|
/** A directive comment, or `undefined` for anything else — malformed included. */
|
|
843
1038
|
function directiveOf(node) {
|
|
@@ -850,7 +1045,7 @@ function directiveOf(node) {
|
|
|
850
1045
|
*
|
|
851
1046
|
* The whole tree, not just the root: `rehype-raw` leaves comment nodes inside
|
|
852
1047
|
* `blockquote`, inside `li`, inside `td` and inline inside `p`, and the real
|
|
853
|
-
* Open Questions layout puts the
|
|
1048
|
+
* Open Questions layout puts the question directive inside a list item — so a
|
|
854
1049
|
* root-only walk finds none of them.
|
|
855
1050
|
*
|
|
856
1051
|
* Pass order is also what resolves a nested section: an inner heading's
|
|
@@ -889,8 +1084,66 @@ function processChildren(parent, state) {
|
|
|
889
1084
|
i = j;
|
|
890
1085
|
}
|
|
891
1086
|
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Remove every block a `fallback` run lands on, with the run itself.
|
|
1089
|
+
*
|
|
1090
|
+
* A fallback block is written for the renderers that cannot show something
|
|
1091
|
+
* Vantage shows — "This drawing needs Vantage 0.8 or later", beside an inline
|
|
1092
|
+
* `<svg>`. Every renderer that does not know the name drops the comment and
|
|
1093
|
+
* shows the block; this one never renders it, in any mode, in print, or
|
|
1094
|
+
* through any of the renderers that share this plugin (D5).
|
|
1095
|
+
*
|
|
1096
|
+
* The run is found exactly as `processChildren` finds one — the same skip
|
|
1097
|
+
* rules, the same first-element target, the same whole-tree walk — and it
|
|
1098
|
+
* withholds when any directive in it is a `fallback` and the target is on
|
|
1099
|
+
* `VANTAGE_FALLBACK_TARGETS`. Anything else is left for the stamping pass,
|
|
1100
|
+
* where a `fallback` is inert.
|
|
1101
|
+
*
|
|
1102
|
+
* A pass of its own, and first, because a `section` reaches *forward* over its
|
|
1103
|
+
* siblings: stamped in one pass, a toned section would count a fallback block
|
|
1104
|
+
* after it among its members, give it the run's last `end` marker, and leave
|
|
1105
|
+
* the last block anyone sees marked `middle`. Withheld first, the block was
|
|
1106
|
+
* never there for any later decision — the section's extent, its run markers,
|
|
1107
|
+
* a collapse group's members, a review anchor or an outline entry.
|
|
1108
|
+
*/
|
|
1109
|
+
function withholdFallbacks(parent) {
|
|
1110
|
+
const children = parent.children;
|
|
1111
|
+
let i = 0;
|
|
1112
|
+
while (i < children.length) {
|
|
1113
|
+
const node = children[i];
|
|
1114
|
+
if (node.type === "element") {
|
|
1115
|
+
withholdFallbacks(node);
|
|
1116
|
+
i++;
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
const first = directiveOf(node);
|
|
1120
|
+
if (first === void 0) {
|
|
1121
|
+
i++;
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
let withholds = first.name === "fallback";
|
|
1125
|
+
let j = i + 1;
|
|
1126
|
+
let targetIndex = -1;
|
|
1127
|
+
for (; j < children.length; j++) {
|
|
1128
|
+
const next = children[j];
|
|
1129
|
+
if (next.type === "element") {
|
|
1130
|
+
targetIndex = j;
|
|
1131
|
+
break;
|
|
1132
|
+
}
|
|
1133
|
+
if (!isSkippable(next)) break;
|
|
1134
|
+
if (directiveOf(next)?.name === "fallback") withholds = true;
|
|
1135
|
+
}
|
|
1136
|
+
const target = targetIndex >= 0 ? children[targetIndex] : void 0;
|
|
1137
|
+
if (withholds && target?.type === "element" && FALLBACK_TARGET_TAGS.has(target.tagName)) {
|
|
1138
|
+
children.splice(i, targetIndex - i + 1);
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
i = j;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
892
1144
|
const rehypeVantageDirectives = () => {
|
|
893
1145
|
return (tree) => {
|
|
1146
|
+
withholdFallbacks(tree);
|
|
894
1147
|
processChildren(tree, { nextGroup: 1 });
|
|
895
1148
|
};
|
|
896
1149
|
};
|
|
@@ -983,12 +1236,144 @@ const rehypeRestoreMathStamps = () => {
|
|
|
983
1236
|
};
|
|
984
1237
|
//#endregion
|
|
985
1238
|
//#region src/sanitize.ts
|
|
1239
|
+
/** Whether an attribute definition is for `className`. */
|
|
1240
|
+
const isClassName = (definition) => (typeof definition === "string" ? definition : definition[0]) === "className";
|
|
986
1241
|
/**
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
* data-source-line attributes while blocking XSS vectors.
|
|
1242
|
+
* The value list the default schema gives `className` on `tag`, or an empty
|
|
1243
|
+
* list if it gives none.
|
|
990
1244
|
*/
|
|
991
|
-
|
|
1245
|
+
function defaultClasses(tag) {
|
|
1246
|
+
const entry = defaultSchema.attributes?.[tag]?.find(isClassName);
|
|
1247
|
+
return Array.isArray(entry) ? entry.slice(1) : [];
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* The only classes an element keeps: the ones the pipeline itself emits on it
|
|
1251
|
+
* before `rehypeSanitize` runs. Every other element keeps none.
|
|
1252
|
+
*
|
|
1253
|
+
* **A class is a style by another name.** The app ships Tailwind's utility CSS
|
|
1254
|
+
* for its own markup, so `<div class="fixed inset-0 z-50 bg-white">` in a
|
|
1255
|
+
* document laid a white sheet over the whole Vantage window, header and
|
|
1256
|
+
* sidebar included. That is the overlay `SAFE_STYLE` bans `position` to
|
|
1257
|
+
* prevent, spelled as a class instead of a declaration, and `className` used
|
|
1258
|
+
* to be admitted with any value on every element. A document could also
|
|
1259
|
+
* borrow a class the app's own code looks for: `.overflow-y-auto` is how an
|
|
1260
|
+
* anchor link finds the element to scroll when no `data-content-scroll` is in
|
|
1261
|
+
* reach, and `.heading-anchor` is what the outline strips from a heading's
|
|
1262
|
+
* text. The comment-body sanitizer (`frontend/src/lib/commentMarkdown.ts`)
|
|
1263
|
+
* already refused `class` for the same reason.
|
|
1264
|
+
*
|
|
1265
|
+
* What each entry is for, and who emits it:
|
|
1266
|
+
*
|
|
1267
|
+
* - `code`: `language-*`, from a fence's info string (`mdast-util-to-hast`).
|
|
1268
|
+
* `rehype-highlight` reads it, `rehype-katex` keys on `language-math`, and
|
|
1269
|
+
* the viewers render `language-mermaid` as a diagram.
|
|
1270
|
+
* - `ul`, `ol` and `li`: `contains-task-list` and `task-list-item`, from GFM
|
|
1271
|
+
* task lists. `styles/task-list.css` draws the checkbox from them.
|
|
1272
|
+
* - `section`, `h2` and `a`: GFM footnotes (`mdast-util-to-hast`), on the
|
|
1273
|
+
* section, its label and each back-reference.
|
|
1274
|
+
* - `div`: `ALERT_TITLE_CLASS`, on the title `rehypeVantageAlerts` injects.
|
|
1275
|
+
*
|
|
1276
|
+
* `remark-math` also emits `math-display` and `math-inline` on its `code`.
|
|
1277
|
+
* They were never kept, because the default schema's `code` entry takes only
|
|
1278
|
+
* `language-*`, and nothing needs them: `rehype-katex` keys on `language-math`
|
|
1279
|
+
* and tells display from inline by whether the `code` sits in a `pre`.
|
|
1280
|
+
*
|
|
1281
|
+
* **A class added after the sanitizer needs no entry**, and that is most of
|
|
1282
|
+
* the classes the page styles: `rehype-highlight`'s `hljs-*`, KaTeX's output,
|
|
1283
|
+
* Mermaid's diagrams, and everything the app's components and hooks add. A
|
|
1284
|
+
* document that writes one of those on an element keeps nothing.
|
|
1285
|
+
*
|
|
1286
|
+
* **Mermaid source is the route this list cannot close.** Mermaid renders
|
|
1287
|
+
* after the sanitizer and copies the names a diagram's source gives a node
|
|
1288
|
+
* (`:::name`, `class A name`, `classDef`) onto that node, so a diagram can
|
|
1289
|
+
* carry any of the app's utilities on its own nodes. The diagram bounds it:
|
|
1290
|
+
* `position` does nothing on an SVG group, and the diagram's `svg` clips what
|
|
1291
|
+
* is inside it. `frontend/e2e/mermaid.spec.ts` measures that bound. The
|
|
1292
|
+
* stylesheet a diagram's source could write did reach past the diagram, and
|
|
1293
|
+
* `MERMAID_SECURE_KEYS` in `mermaidLoader.ts` refuses the settings that write
|
|
1294
|
+
* it.
|
|
1295
|
+
*
|
|
1296
|
+
* A document may still write these names on these elements. By the time the
|
|
1297
|
+
* sanitizer runs, a hand-written `<li class="task-list-item">` and the one GFM
|
|
1298
|
+
* emitted are the same node, and none of the names can lay an element over
|
|
1299
|
+
* anything outside the document's scroll container. `styles/task-list.css`
|
|
1300
|
+
* floats the checkbox of a `task-list-item` instead of positioning it
|
|
1301
|
+
* absolutely, so it stays in the document's flow whatever the item's own
|
|
1302
|
+
* `style` does, and the checkbox takes no `style` itself (see
|
|
1303
|
+
* `UNSTYLED_TAGS`). The footnote label's class, where a stylesheet defines it,
|
|
1304
|
+
* clips its element to a single pixel.
|
|
1305
|
+
*
|
|
1306
|
+
* **The footnote label's class is read from the default schema, never
|
|
1307
|
+
* spelled here, and the tests spell it in halves.** It is a Tailwind utility,
|
|
1308
|
+
* and Tailwind generates a utility for every class name it finds in the files
|
|
1309
|
+
* it scans, comments included — this directory and all of `frontend/`. The app
|
|
1310
|
+
* renders the label as a visible heading only because nothing it scans spells
|
|
1311
|
+
* that name. Spelled whole in a test, it generated the rule, and the label
|
|
1312
|
+
* would have been hidden from sight as it is on GitHub: a change of rendering,
|
|
1313
|
+
* not a sanitizer fix.
|
|
1314
|
+
*
|
|
1315
|
+
* The list is measured, not remembered. The test "takes no class from
|
|
1316
|
+
* anything the pipeline emits" in `frontend/src/lib/sanitize.test.ts` records
|
|
1317
|
+
* every class on the tree on both sides of `rehypeSanitize`, over a document
|
|
1318
|
+
* that uses every feature that emits one, so a plugin that starts emitting a
|
|
1319
|
+
* new class fails there instead of losing it in every renderer.
|
|
1320
|
+
*/
|
|
1321
|
+
const PIPELINE_CLASSES = {
|
|
1322
|
+
code: [/^language-./],
|
|
1323
|
+
ul: ["contains-task-list"],
|
|
1324
|
+
ol: ["contains-task-list"],
|
|
1325
|
+
li: ["task-list-item"],
|
|
1326
|
+
section: ["footnotes"],
|
|
1327
|
+
h2: defaultClasses("h2"),
|
|
1328
|
+
a: ["data-footnote-backref"],
|
|
1329
|
+
div: [ALERT_TITLE_CLASS]
|
|
1330
|
+
};
|
|
1331
|
+
/**
|
|
1332
|
+
* `tag`'s attributes: the default schema's, less any `className`, then its
|
|
1333
|
+
* `PIPELINE_CLASSES` entry, then `extra`.
|
|
1334
|
+
*
|
|
1335
|
+
* **Always a value list, never a bare `"className"`.** `hast-util-sanitize`
|
|
1336
|
+
* reads a bare name, or a tuple holding no values, as "any value", and an
|
|
1337
|
+
* element's own entry is consulted before `*`'s — the first match wins, and
|
|
1338
|
+
* `*` is not consulted when that element's own entry filters a class list down
|
|
1339
|
+
* to nothing. An element with no entry has none added.
|
|
1340
|
+
*/
|
|
1341
|
+
function withClasses(tag, ...extra) {
|
|
1342
|
+
const classes = PIPELINE_CLASSES[tag] ?? [];
|
|
1343
|
+
const own = (defaultSchema.attributes?.[tag] ?? []).filter((definition) => !isClassName(definition));
|
|
1344
|
+
if (classes.length > 0) own.push(["className", ...classes]);
|
|
1345
|
+
return [...own, ...extra];
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* CSS properties an inline `style` may set.
|
|
1349
|
+
*
|
|
1350
|
+
* **The only `style` this list ever filters is one a document wrote by hand.**
|
|
1351
|
+
* Nothing the pipeline generates reaches it: `rehypeKatex` and `rehypeHighlight`
|
|
1352
|
+
* both run *after* `rehypeSanitize` (`pipeline.ts`), so their output is trusted
|
|
1353
|
+
* rather than filtered, and `remark-gfm` emits table alignment as an `align`
|
|
1354
|
+
* attribute rather than as CSS. So this is a filter on untrusted author HTML and
|
|
1355
|
+
* nothing else — which is exactly the hole that made it necessary: `<div
|
|
1356
|
+
* style="position:fixed;inset:0">` covered the viewport and
|
|
1357
|
+
* `style="background:url(https://…)"` called home on render, both verbatim,
|
|
1358
|
+
* because `rehype-sanitize` does not parse CSS. Scripts were never the risk
|
|
1359
|
+
* here; layout and network were.
|
|
1360
|
+
*
|
|
1361
|
+
* The list is therefore deliberately typographic: the styling a prose document
|
|
1362
|
+
* has any business asking for. It is *not* sized to KaTeX, and a KaTeX release
|
|
1363
|
+
* that starts using a new property is a non-event here — `\pmb` already emits
|
|
1364
|
+
* `text-shadow`, which is not on this list and renders anyway.
|
|
1365
|
+
*
|
|
1366
|
+
* **The design doc used to argue the opposite — that `style` had to be allowed
|
|
1367
|
+
* and `position` enumerated because KaTeX needs them — and it was wrong.** The
|
|
1368
|
+
* measurement behind it was real (KaTeX does emit `position:relative` on every
|
|
1369
|
+
* integral) but the inference was not, because the sanitizer has finished before
|
|
1370
|
+
* the first KaTeX span exists. Rebuilding the shipped rehype order with a filter
|
|
1371
|
+
* that rejects *every* value leaves all ten of the integral's style attributes
|
|
1372
|
+
* untouched. The "Security" section of `docs/reference/inline-markup.md`
|
|
1373
|
+
* records the correction; the test that would catch a reordering is in
|
|
1374
|
+
* `frontend/src/lib/sanitize.test.ts`.
|
|
1375
|
+
*/
|
|
1376
|
+
const SAFE_STYLE_PROPERTIES = [
|
|
992
1377
|
"height",
|
|
993
1378
|
"min-height",
|
|
994
1379
|
"max-height",
|
|
@@ -1043,12 +1428,89 @@ const DECLARATION = `(?:(?:${[
|
|
|
1043
1428
|
"white-space",
|
|
1044
1429
|
"vertical-align",
|
|
1045
1430
|
"list-style-type",
|
|
1046
|
-
"display",
|
|
1047
1431
|
"float",
|
|
1048
1432
|
"clear",
|
|
1049
1433
|
"opacity",
|
|
1050
1434
|
"overflow"
|
|
1051
|
-
]
|
|
1435
|
+
];
|
|
1436
|
+
/**
|
|
1437
|
+
* A `style` value we will keep, as a whole.
|
|
1438
|
+
*
|
|
1439
|
+
* One rule beyond the property list does the work: **no parentheses anywhere**,
|
|
1440
|
+
* which closes `url(…)` and `expression(…)` in one stroke — the network and
|
|
1441
|
+
* legacy-script vectors. Its cost is borne entirely by authors, who lose
|
|
1442
|
+
* `calc()`, `rgb()` and `var()` along with them; that is the trade, and it is
|
|
1443
|
+
* worth it for a filter this small.
|
|
1444
|
+
*
|
|
1445
|
+
* **`position` is not on the property list at all**, so every value of it is
|
|
1446
|
+
* refused — `static` and `relative` along with `fixed` and `sticky`. It used to
|
|
1447
|
+
* be enumerated, on the belief that KaTeX needed `relative`; KaTeX renders after
|
|
1448
|
+
* the sanitizer and never meets this regex, so the enumeration was buying
|
|
1449
|
+
* nothing but the residual it conceded. Banning the property closes that
|
|
1450
|
+
* residual, and it is bigger than "overlaps its neighbors" made it sound:
|
|
1451
|
+
* measured in Chrome against the viewer's real ancestor chain, an author's
|
|
1452
|
+
* `position:absolute;top:0;left:0;width:100%;height:100%` is sized to the whole
|
|
1453
|
+
* content pane (the nearest positioned ancestor is outside the scroll
|
|
1454
|
+
* container), is not clipped by the scroller, and survives scrolling to the end
|
|
1455
|
+
* of the document. It was `position:fixed` in all but the keyword.
|
|
1456
|
+
*
|
|
1457
|
+
* Matching is all-or-nothing: one unrecognized declaration drops the whole
|
|
1458
|
+
* attribute, and the element renders unstyled rather than partly styled. That
|
|
1459
|
+
* is the safe direction to fail, and it degrades to plain text rather than to a
|
|
1460
|
+
* broken page.
|
|
1461
|
+
*
|
|
1462
|
+
* **The grammar must stay unambiguous, and `;` is what keeps it so.** The value
|
|
1463
|
+
* class is "anything but the delimiters", which includes whitespace — a value
|
|
1464
|
+
* legitimately contains it (`margin: 0 auto`). So if whitespace could *also*
|
|
1465
|
+
* end a declaration, both constructs would compete for the same characters and
|
|
1466
|
+
* the match would fork at every declaration; on a value that ultimately fails,
|
|
1467
|
+
* the engine explores every fork. An earlier form of this regex separated
|
|
1468
|
+
* declarations with `\s*;?\s*`, and 200 document-controlled characters took the
|
|
1469
|
+
* renderer — and the CLI checker, and therefore CI — 94 seconds. Requiring `;`
|
|
1470
|
+
* pins each declaration's extent to the delimiter positions, so there is exactly
|
|
1471
|
+
* one way to parse any input and rejection is linear. `VALUE` absorbs the
|
|
1472
|
+
* padding on both sides for the same reason: a separate `\\s*` next to it would
|
|
1473
|
+
* put the ambiguity straight back. Pinned by the flat-time test in
|
|
1474
|
+
* `frontend/src/lib/sanitize.test.ts` — do not loosen the separator.
|
|
1475
|
+
*
|
|
1476
|
+
* Residual, stated plainly and now genuinely small: negative `margin` still lets
|
|
1477
|
+
* an element overlap its neighbors *inside the flow*. That one scrolls with the
|
|
1478
|
+
* content and is clipped by the scroll container, and closing it means giving up
|
|
1479
|
+
* margins, which prose actually uses. Containment in the stylesheet, not another
|
|
1480
|
+
* rule here, is what would close it. `overflow: visible` on an inline `<svg>` is
|
|
1481
|
+
* the same residual by another route — see `SVG_ROOT_ATTRIBUTES`.
|
|
1482
|
+
*
|
|
1483
|
+
* **The app's table of contents is inside that scroll container too**, beside
|
|
1484
|
+
* the document column, so a block carried over it took its clicks whenever the
|
|
1485
|
+
* block painted in the positioned layer: one with `opacity`, a toned block, a
|
|
1486
|
+
* heading, or a link to anywhere. The contents sit at `z-10` for that
|
|
1487
|
+
* (`frontend/src/components/TableOfContents.tsx`), and a document cannot climb
|
|
1488
|
+
* back over them only because `z-index` is not on the property list.
|
|
1489
|
+
*/
|
|
1490
|
+
const VALUE = `[^;:()"'\\\\]*`;
|
|
1491
|
+
const DISPLAY_DECLARATION = `display\\s*:\\s*(?:${[
|
|
1492
|
+
"none",
|
|
1493
|
+
"block",
|
|
1494
|
+
"inline-block",
|
|
1495
|
+
"inline-flex",
|
|
1496
|
+
"inline-grid",
|
|
1497
|
+
"inline-table",
|
|
1498
|
+
"inline",
|
|
1499
|
+
"flow-root",
|
|
1500
|
+
"flex",
|
|
1501
|
+
"grid",
|
|
1502
|
+
"table-row-group",
|
|
1503
|
+
"table-row",
|
|
1504
|
+
"table-header-group",
|
|
1505
|
+
"table-footer-group",
|
|
1506
|
+
"table-cell",
|
|
1507
|
+
"table-column-group",
|
|
1508
|
+
"table-column",
|
|
1509
|
+
"table-caption",
|
|
1510
|
+
"table",
|
|
1511
|
+
"list-item"
|
|
1512
|
+
].join("|")})\\s*(?:!\\s*important\\s*)?`;
|
|
1513
|
+
const DECLARATION = `(?:(?:${SAFE_STYLE_PROPERTIES.join("|")})\\s*:${VALUE}|${DISPLAY_DECLARATION})`;
|
|
1052
1514
|
const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "i");
|
|
1053
1515
|
/**
|
|
1054
1516
|
* A collapse group id: one or more digits, anchored.
|
|
@@ -1060,6 +1522,281 @@ const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "
|
|
|
1060
1522
|
*/
|
|
1061
1523
|
const COLLAPSE_GROUP_ID = /^[0-9]+$/;
|
|
1062
1524
|
/**
|
|
1525
|
+
* Inline SVG, admitted as static drawing and nothing else.
|
|
1526
|
+
*
|
|
1527
|
+
* The allowlist is shapes, text and grouping. Everything in SVG that can run
|
|
1528
|
+
* code, fetch, or reach outside the element is refused: `script`,
|
|
1529
|
+
* `foreignObject`, `use` and `image` (both take an `href`), `xlink:href` on
|
|
1530
|
+
* `a`, the `animate`/`set` family (which can rewrite an `href` after the
|
|
1531
|
+
* sanitizer has looked), `style` elements, and every attribute that takes a
|
|
1532
|
+
* `url(…)` reference — `filter`, `mask`, `clip-path`, `marker-*`, `cursor`.
|
|
1533
|
+
*
|
|
1534
|
+
* Gradients, patterns and `<defs>` are out for a second reason: they are only
|
|
1535
|
+
* reachable through `url(#id)`, and the sanitizer prefixes every `id` with
|
|
1536
|
+
* `user-content-`, so the reference would dangle even if it were allowed.
|
|
1537
|
+
*
|
|
1538
|
+
* **Refusing an element is not the same as removing it.** `hast-util-sanitize`
|
|
1539
|
+
* *unwraps* an element it does not allow — drops the tag and keeps its
|
|
1540
|
+
* children — unless the tag is on `strip`. See `SVG_STRIPPED` for why that
|
|
1541
|
+
* matters here, and for the pass that removes those containers instead.
|
|
1542
|
+
*
|
|
1543
|
+
* `switch` is admitted, as a plain container, because draw.io wraps every label
|
|
1544
|
+
* in one: `<switch><foreignObject>…</foreignObject><text>…</text></switch>`. A
|
|
1545
|
+
* `switch` renders only its first child whose conditions hold, and with the
|
|
1546
|
+
* `foreignObject` removed and the conditional attributes (`requiredFeatures`,
|
|
1547
|
+
* `systemLanguage`) refused, that is the `<text>` fallback. Unwrapped instead,
|
|
1548
|
+
* it would render *every* branch — including the "Text is not SVG - cannot
|
|
1549
|
+
* display" notice draw.io appends for viewers without `foreignObject`.
|
|
1550
|
+
*
|
|
1551
|
+
* Every child requires an `svg` ancestor, so no shape or text element is
|
|
1552
|
+
* admitted into HTML flow on its own.
|
|
1553
|
+
*
|
|
1554
|
+
* `title` and `desc` are not children at all. They are admitted nowhere, inside
|
|
1555
|
+
* a drawing they are removed with their contents (see `SVG_STRIPPED`), and
|
|
1556
|
+
* `aria-label` on the root is the drawing's accessible name. Both are HTML
|
|
1557
|
+
* integration points — the parser reads what is inside them as HTML — and an
|
|
1558
|
+
* `svg` ancestor turned out not to be enough to keep a `<title>` in SVG
|
|
1559
|
+
* context. `<math><svg><title>` keeps MathML context, and React hoists any
|
|
1560
|
+
* `title` outside SVG context into the page's `<head>`, so the document set the
|
|
1561
|
+
* tab title. In `renderMarkdown`'s string output, `<svg><desc><title>`,
|
|
1562
|
+
* `<svg><title><title>` and a `title` inside a `foreignObject` all re-parse into
|
|
1563
|
+
* an HTML `title`, which is what `document.title` reads on a page that has none
|
|
1564
|
+
* of its own.
|
|
1565
|
+
*/
|
|
1566
|
+
const SVG_CHILD_TAGS = [
|
|
1567
|
+
"g",
|
|
1568
|
+
"path",
|
|
1569
|
+
"rect",
|
|
1570
|
+
"circle",
|
|
1571
|
+
"ellipse",
|
|
1572
|
+
"line",
|
|
1573
|
+
"polyline",
|
|
1574
|
+
"polygon",
|
|
1575
|
+
"text",
|
|
1576
|
+
"tspan",
|
|
1577
|
+
"switch"
|
|
1578
|
+
];
|
|
1579
|
+
/**
|
|
1580
|
+
* Refused SVG elements removed *with their contents* when they sit inside an
|
|
1581
|
+
* `<svg>`, rather than unwrapped.
|
|
1582
|
+
*
|
|
1583
|
+
* Each of these holds children that are never meant to be painted where they
|
|
1584
|
+
* stand: a clip region, a mask, a gradient's stops, a marker's arrowhead, a
|
|
1585
|
+
* symbol's body, a filter's primitives, an exporter's RDF, the text of a
|
|
1586
|
+
* `title` or `desc` (see `SVG_CHILD_TAGS`), or — for `foreignObject` — HTML.
|
|
1587
|
+
* Unwrapped, those children land in the drawing as ordinary shapes. A default
|
|
1588
|
+
* Figma export ends in `<defs><clipPath><rect fill="white"/>`, and unwrapping
|
|
1589
|
+
* it painted that white rect over the whole drawing; a `<marker>` arrowhead
|
|
1590
|
+
* became a stray triangle at the origin. The HTML inside a `foreignObject`
|
|
1591
|
+
* survived as descendants of `svg`, which React then created as invisible
|
|
1592
|
+
* SVG-namespace `div`s and `p`s — the anchor an Open Question button looks for
|
|
1593
|
+
* — and which a browser re-parsing `renderMarkdown`'s string output breaks out
|
|
1594
|
+
* of the `svg` at, spilling the rest of the drawing into the page.
|
|
1595
|
+
*
|
|
1596
|
+
* **Only inside an `<svg>`, which is why this is a pass and not the schema's
|
|
1597
|
+
* `strip`.** `strip` removes a tag wherever it stands, and most of these names
|
|
1598
|
+
* are lowercase in prose too. There a bare `<pattern>` on a line of its own is
|
|
1599
|
+
* an HTML element nothing closes, so every Markdown block after it was parsed
|
|
1600
|
+
* into it, and stripping it removed the rest of the document — in every
|
|
1601
|
+
* renderer, with `vantage-check` silent. Inside a drawing the same container is
|
|
1602
|
+
* bounded by the `svg`, because a Markdown paragraph or heading breaks out of
|
|
1603
|
+
* SVG. Outside one the sanitizer unwraps these like any tag it does not know:
|
|
1604
|
+
* `title` and `desc` are not admitted anywhere, so no `title` element survives
|
|
1605
|
+
* to be hoisted, and what they held is text or ordinary sanitized HTML.
|
|
1606
|
+
*
|
|
1607
|
+
* Names compare case-insensitively. The camel-cased ones are how the HTML
|
|
1608
|
+
* parser spells these elements in SVG, but under `<math><svg>` the parser stays
|
|
1609
|
+
* in MathML and leaves them lowercase.
|
|
1610
|
+
*/
|
|
1611
|
+
const SVG_STRIPPED = [
|
|
1612
|
+
"foreignObject",
|
|
1613
|
+
"defs",
|
|
1614
|
+
"clipPath",
|
|
1615
|
+
"mask",
|
|
1616
|
+
"pattern",
|
|
1617
|
+
"marker",
|
|
1618
|
+
"symbol",
|
|
1619
|
+
"linearGradient",
|
|
1620
|
+
"radialGradient",
|
|
1621
|
+
"filter",
|
|
1622
|
+
"metadata",
|
|
1623
|
+
"title",
|
|
1624
|
+
"desc"
|
|
1625
|
+
];
|
|
1626
|
+
const SVG_STRIPPED_NAMES = new Set(SVG_STRIPPED.map((name) => name.toLowerCase()));
|
|
1627
|
+
/**
|
|
1628
|
+
* The camel-cased `SVG_STRIPPED` names, which the schema strips on its own.
|
|
1629
|
+
*
|
|
1630
|
+
* Only an SVG-namespace element carries one of these spellings, so stripping
|
|
1631
|
+
* them everywhere costs prose nothing. It keeps the worst of the list — the
|
|
1632
|
+
* HTML inside a `foreignObject` — out of a drawing for a consumer who hands
|
|
1633
|
+
* `sanitizeSchema` to `rehype-sanitize` without the rest of `buildPipeline`.
|
|
1634
|
+
*/
|
|
1635
|
+
const SVG_STRIPPED_EVERYWHERE = SVG_STRIPPED.filter((name) => name !== name.toLowerCase());
|
|
1636
|
+
function stripSvgContainers(parent, inSvg) {
|
|
1637
|
+
if (inSvg) parent.children = parent.children.filter((child) => child.type !== "element" || !SVG_STRIPPED_NAMES.has(child.tagName.toLowerCase()));
|
|
1638
|
+
for (const child of parent.children) if (child.type === "element") stripSvgContainers(child, inSvg || child.tagName.toLowerCase() === "svg");
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Remove every `SVG_STRIPPED` element below an `svg`, with its contents.
|
|
1642
|
+
*
|
|
1643
|
+
* Runs immediately before `rehypeSanitize` (`pipeline.ts`), and only when the
|
|
1644
|
+
* pipeline sanitizes: with the sanitizer off these elements are left to work
|
|
1645
|
+
* as SVG defines them.
|
|
1646
|
+
*/
|
|
1647
|
+
function rehypeStripSvgContainers() {
|
|
1648
|
+
return (tree) => {
|
|
1649
|
+
stripSvgContainers(tree, false);
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* A paint value: a keyword, a named color, or a hex color. No parentheses, for
|
|
1654
|
+
* the same reason `SAFE_STYLE` refuses them — `fill="url(https://…)"` is a
|
|
1655
|
+
* render-time fetch, and `rgb()` is the price of closing it.
|
|
1656
|
+
*/
|
|
1657
|
+
const SVG_PAINT = /^(?:#[0-9a-f]{3,8}|[a-z]+)$/i;
|
|
1658
|
+
/**
|
|
1659
|
+
* Attributes any SVG element may carry, drawing and typography alike.
|
|
1660
|
+
*
|
|
1661
|
+
* **Each entry is a hast property name, which is not always the camel case of
|
|
1662
|
+
* the attribute.** `property-information` decides the spelling, and it has
|
|
1663
|
+
* `strokeLineCap` for `stroke-linecap` — a naive `strokeLinecap` matches
|
|
1664
|
+
* nothing, and the attribute is dropped in every renderer without a word. The
|
|
1665
|
+
* table-driven test in `frontend/src/lib/sanitize.test.ts` renders every entry
|
|
1666
|
+
* in its written spelling, so a misspelled one fails there.
|
|
1667
|
+
*
|
|
1668
|
+
* `stroke-dasharray` (and with it `stroke-dashoffset`, which does nothing
|
|
1669
|
+
* alone) is refused, because it lets a few bytes buy an unbounded paint. The
|
|
1670
|
+
* number of dashes is the path's length over the dash period, both in user
|
|
1671
|
+
* units the document chooses. Measured in headless Chromium, fifty 90-byte
|
|
1672
|
+
* paths with a 0.0011 dash took 21 s to paint and a hundred took 37 s. A value
|
|
1673
|
+
* grammar cannot bound it: refusing sub-unit dashes is undone by scaling the
|
|
1674
|
+
* user units, and the same fifty paths with `stroke-dasharray="1.1"` under a
|
|
1675
|
+
* `viewBox` a thousand times larger still took 15 s.
|
|
1676
|
+
*/
|
|
1677
|
+
const SVG_ATTRIBUTES = [
|
|
1678
|
+
["fill", SVG_PAINT],
|
|
1679
|
+
["stroke", SVG_PAINT],
|
|
1680
|
+
"fillOpacity",
|
|
1681
|
+
"fillRule",
|
|
1682
|
+
"strokeOpacity",
|
|
1683
|
+
"strokeWidth",
|
|
1684
|
+
"strokeLineCap",
|
|
1685
|
+
"strokeLineJoin",
|
|
1686
|
+
"opacity",
|
|
1687
|
+
"transform",
|
|
1688
|
+
"fontFamily",
|
|
1689
|
+
"fontSize",
|
|
1690
|
+
"fontWeight",
|
|
1691
|
+
"fontStyle",
|
|
1692
|
+
"letterSpacing",
|
|
1693
|
+
"textAnchor",
|
|
1694
|
+
"dominantBaseline",
|
|
1695
|
+
"x",
|
|
1696
|
+
"y",
|
|
1697
|
+
"x1",
|
|
1698
|
+
"y1",
|
|
1699
|
+
"x2",
|
|
1700
|
+
"y2",
|
|
1701
|
+
"cx",
|
|
1702
|
+
"cy",
|
|
1703
|
+
"r",
|
|
1704
|
+
"rx",
|
|
1705
|
+
"ry",
|
|
1706
|
+
"dx",
|
|
1707
|
+
"dy",
|
|
1708
|
+
"d",
|
|
1709
|
+
"points"
|
|
1710
|
+
];
|
|
1711
|
+
/**
|
|
1712
|
+
* The root `<svg>`'s attributes: `SVG_ATTRIBUTES` less `transform`, plus the
|
|
1713
|
+
* viewport, a `role` from a closed list, and the accessible name.
|
|
1714
|
+
*
|
|
1715
|
+
* `transform` on the root is not an SVG transform at all. The root is an
|
|
1716
|
+
* in-flow CSS box, and the attribute becomes a CSS transform of that box, so
|
|
1717
|
+
* `<svg width="10" height="10" transform="translate(-300 -300) scale(80)">`
|
|
1718
|
+
* laid out as a 10px box and painted as an 800px one over the paragraphs
|
|
1719
|
+
* around it. On a child it moves shapes inside the viewport, which clips them.
|
|
1720
|
+
*
|
|
1721
|
+
* Residual: that clip is the root's `overflow`, which `SAFE_STYLE` lets a
|
|
1722
|
+
* document set. `style="overflow:visible"` on the root lets a child with a
|
|
1723
|
+
* large `transform` or large coordinates paint over its neighbors — measured in
|
|
1724
|
+
* Chromium, the same overlap. It is the negative-`margin` residual described at
|
|
1725
|
+
* `SAFE_STYLE` by another route: it stays in the flow, scrolls with the content,
|
|
1726
|
+
* and the scroll container clips it.
|
|
1727
|
+
*/
|
|
1728
|
+
const SVG_ROOT_ATTRIBUTES = [
|
|
1729
|
+
...SVG_ATTRIBUTES.filter((attribute) => attribute !== "transform"),
|
|
1730
|
+
"xmlns",
|
|
1731
|
+
"viewBox",
|
|
1732
|
+
"preserveAspectRatio",
|
|
1733
|
+
[
|
|
1734
|
+
"role",
|
|
1735
|
+
"img",
|
|
1736
|
+
"presentation",
|
|
1737
|
+
"none"
|
|
1738
|
+
],
|
|
1739
|
+
"ariaLabel"
|
|
1740
|
+
];
|
|
1741
|
+
/** Every element the sanitizer admits. */
|
|
1742
|
+
const TAG_NAMES = [
|
|
1743
|
+
...defaultSchema.tagNames || [],
|
|
1744
|
+
"math",
|
|
1745
|
+
"semantics",
|
|
1746
|
+
"mrow",
|
|
1747
|
+
"mi",
|
|
1748
|
+
"mo",
|
|
1749
|
+
"mn",
|
|
1750
|
+
"msup",
|
|
1751
|
+
"msub",
|
|
1752
|
+
"mfrac",
|
|
1753
|
+
"mover",
|
|
1754
|
+
"munder",
|
|
1755
|
+
"msqrt",
|
|
1756
|
+
"mroot",
|
|
1757
|
+
"mtable",
|
|
1758
|
+
"mtr",
|
|
1759
|
+
"mtd",
|
|
1760
|
+
"mtext",
|
|
1761
|
+
"mspace",
|
|
1762
|
+
"annotation",
|
|
1763
|
+
"figure",
|
|
1764
|
+
"figcaption",
|
|
1765
|
+
"summary",
|
|
1766
|
+
"details",
|
|
1767
|
+
"svg",
|
|
1768
|
+
...SVG_CHILD_TAGS
|
|
1769
|
+
];
|
|
1770
|
+
/**
|
|
1771
|
+
* The admitted elements that take no `style`: only `input`, which GFM emits as
|
|
1772
|
+
* a task list's checkbox, and never with a style.
|
|
1773
|
+
*
|
|
1774
|
+
* `styles/task-list.css` draws that checkbox and positions it, so on it `top`
|
|
1775
|
+
* and `left` are not the no-ops they are on an element nothing positions, and
|
|
1776
|
+
* with `width`, `height` and `background-color` beside them a document laid a
|
|
1777
|
+
* box of its own over the page. Measured in Chromium at 1280x800 while the
|
|
1778
|
+
* checkbox was positioned absolutely,
|
|
1779
|
+
* `top:-3000px;left:-3000px;width:9000px;height:9000px;background-color:white`
|
|
1780
|
+
* on it covered the whole content pane. It needed no class written:
|
|
1781
|
+
* GFM puts `task-list-item` on the item, and a checkbox typed into the item's
|
|
1782
|
+
* text is a child of that item too.
|
|
1783
|
+
*
|
|
1784
|
+
* **This is why `style` is on each element's own entry, never on `*`.**
|
|
1785
|
+
* `hast-util-sanitize` consults `*` whenever an element's own entry yields
|
|
1786
|
+
* nothing, so no entry on `input` could refuse a value that `*` admits. An
|
|
1787
|
+
* element takes `style` only by being in `TAG_NAMES` and not in this set.
|
|
1788
|
+
*/
|
|
1789
|
+
const UNSTYLED_TAGS = /* @__PURE__ */ new Set(["input"]);
|
|
1790
|
+
/**
|
|
1791
|
+
* `attributes`, with `["style", SAFE_STYLE]` added to the entry of every
|
|
1792
|
+
* element in `TAG_NAMES` but the `UNSTYLED_TAGS`.
|
|
1793
|
+
*/
|
|
1794
|
+
function withStyle(attributes) {
|
|
1795
|
+
const styled = { ...attributes };
|
|
1796
|
+
for (const tag of new Set(TAG_NAMES)) if (!UNSTYLED_TAGS.has(tag)) styled[tag] = [...styled[tag] ?? [], ["style", SAFE_STYLE]];
|
|
1797
|
+
return styled;
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1063
1800
|
* Never set `allowComments` here.
|
|
1064
1801
|
*
|
|
1065
1802
|
* `hast-util-sanitize` drops comment nodes because that boolean defaults to
|
|
@@ -1073,38 +1810,16 @@ const COLLAPSE_GROUP_ID = /^[0-9]+$/;
|
|
|
1073
1810
|
*/
|
|
1074
1811
|
const sanitizeSchema = {
|
|
1075
1812
|
...defaultSchema,
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
"
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
"mn",
|
|
1084
|
-
"msup",
|
|
1085
|
-
"msub",
|
|
1086
|
-
"mfrac",
|
|
1087
|
-
"mover",
|
|
1088
|
-
"munder",
|
|
1089
|
-
"msqrt",
|
|
1090
|
-
"mroot",
|
|
1091
|
-
"mtable",
|
|
1092
|
-
"mtr",
|
|
1093
|
-
"mtd",
|
|
1094
|
-
"mtext",
|
|
1095
|
-
"mspace",
|
|
1096
|
-
"annotation",
|
|
1097
|
-
"figure",
|
|
1098
|
-
"figcaption",
|
|
1099
|
-
"summary",
|
|
1100
|
-
"details"
|
|
1101
|
-
],
|
|
1102
|
-
attributes: {
|
|
1813
|
+
strip: [...defaultSchema.strip || [], ...SVG_STRIPPED_EVERYWHERE],
|
|
1814
|
+
tagNames: TAG_NAMES,
|
|
1815
|
+
ancestors: {
|
|
1816
|
+
...defaultSchema.ancestors,
|
|
1817
|
+
...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, ["svg"]]))
|
|
1818
|
+
},
|
|
1819
|
+
attributes: withStyle({
|
|
1103
1820
|
...defaultSchema.attributes,
|
|
1104
1821
|
"*": [
|
|
1105
1822
|
...defaultSchema.attributes?.["*"] || [],
|
|
1106
|
-
"className",
|
|
1107
|
-
["style", SAFE_STYLE],
|
|
1108
1823
|
"dataSourceLine",
|
|
1109
1824
|
["dataVantageTone", ...VANTAGE_TONES],
|
|
1110
1825
|
["dataVantageEmphasis", ...VANTAGE_EMPHASIS],
|
|
@@ -1114,35 +1829,22 @@ const sanitizeSchema = {
|
|
|
1114
1829
|
["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
|
|
1115
1830
|
["dataVantageRun", ...VANTAGE_RUNS],
|
|
1116
1831
|
["dataVantageOq", "true"],
|
|
1832
|
+
["dataVantageQuestion", "true"],
|
|
1117
1833
|
["dataVantageOqId", VANTAGE_OQ_ID],
|
|
1118
1834
|
["dataVantageAlert", ...VANTAGE_ALERTS],
|
|
1119
1835
|
"dataVantageLeaning"
|
|
1120
1836
|
],
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
...defaultSchema.attributes?.span || [],
|
|
1124
|
-
"className",
|
|
1125
|
-
["style", SAFE_STYLE]
|
|
1126
|
-
],
|
|
1127
|
-
div: [
|
|
1128
|
-
...defaultSchema.attributes?.div || [],
|
|
1129
|
-
"className",
|
|
1130
|
-
["style", SAFE_STYLE]
|
|
1131
|
-
],
|
|
1132
|
-
a: [
|
|
1133
|
-
...defaultSchema.attributes?.a || [],
|
|
1134
|
-
"id",
|
|
1135
|
-
"className"
|
|
1136
|
-
],
|
|
1837
|
+
...Object.fromEntries(Object.keys(PIPELINE_CLASSES).map((tag) => [tag, withClasses(tag)])),
|
|
1838
|
+
a: withClasses("a", "id"),
|
|
1137
1839
|
math: ["xmlns"],
|
|
1138
1840
|
annotation: ["encoding"],
|
|
1139
1841
|
img: [...defaultSchema.attributes?.img || [], "loading"],
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
}
|
|
1842
|
+
svg: SVG_ROOT_ATTRIBUTES,
|
|
1843
|
+
...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, SVG_ATTRIBUTES]))
|
|
1844
|
+
})
|
|
1143
1845
|
};
|
|
1144
1846
|
//#endregion
|
|
1145
|
-
//#region src/
|
|
1847
|
+
//#region src/remarkPlugins.ts
|
|
1146
1848
|
/**
|
|
1147
1849
|
* The mdast half of the chain. Exported on its own because there is a real
|
|
1148
1850
|
* mdast-only consumer: the CLI checker parses documents without ever running
|
|
@@ -1156,6 +1858,8 @@ function buildRemarkPlugins(options = {}) {
|
|
|
1156
1858
|
if (math) plugins.push([remarkMath, { singleDollarTextMath: false }]);
|
|
1157
1859
|
return plugins;
|
|
1158
1860
|
}
|
|
1861
|
+
//#endregion
|
|
1862
|
+
//#region src/pipeline.ts
|
|
1159
1863
|
/** The hast half. Deliberately not exported: see `buildPipeline`. */
|
|
1160
1864
|
function buildRehypePlugins(options = {}) {
|
|
1161
1865
|
const { math = true, highlight = true, sourceLines = true, sanitize = true, bodyLineOffset = 0 } = options;
|
|
@@ -1163,7 +1867,7 @@ function buildRehypePlugins(options = {}) {
|
|
|
1163
1867
|
if (sourceLines) plugins.push([rehypeSourceLines, { offset: bodyLineOffset }]);
|
|
1164
1868
|
plugins.push(rehypeVantageAlerts);
|
|
1165
1869
|
plugins.push(rehypeVantageDirectives);
|
|
1166
|
-
if (sanitize) plugins.push([rehypeSanitize, sanitizeSchema]);
|
|
1870
|
+
if (sanitize) plugins.push(rehypeStripSvgContainers, [rehypeSanitize, sanitizeSchema]);
|
|
1167
1871
|
plugins.push(rehypeVantageAnchors);
|
|
1168
1872
|
plugins.push(rehypeSlug);
|
|
1169
1873
|
if (highlight) plugins.push(rehypeHighlight);
|
|
@@ -1727,6 +2431,43 @@ let mermaidLoading = null;
|
|
|
1727
2431
|
* configured for, `null` until loaded.
|
|
1728
2432
|
*/
|
|
1729
2433
|
let configuredTheme = null;
|
|
2434
|
+
/**
|
|
2435
|
+
* The configuration keys a diagram's own source may not set, from an `init`
|
|
2436
|
+
* directive or from frontmatter: Mermaid's default list, then the three
|
|
2437
|
+
* that write a diagram's stylesheet.
|
|
2438
|
+
*
|
|
2439
|
+
* **A diagram's stylesheet reaches past the diagram.** Mermaid puts every
|
|
2440
|
+
* selector of it under the diagram's id, but leaves the names of its
|
|
2441
|
+
* `@keyframes` global, so a diagram that defined `flash-update` redefined the
|
|
2442
|
+
* animation the viewer plays on a block that just changed. Given
|
|
2443
|
+
* `position: fixed` at the size of the window, the next flash laid that block,
|
|
2444
|
+
* and the link it held, over the header and the sidebar. The same stylesheet
|
|
2445
|
+
* fetched images from any host, which is why `SAFE_STYLE` refuses parentheses.
|
|
2446
|
+
*
|
|
2447
|
+
* - `themeCSS` is a stylesheet, and Mermaid checks only that its braces pair.
|
|
2448
|
+
* - `fontFamily` is copied into `themeVariables` *after* Mermaid's check on
|
|
2449
|
+
* theme variables has run, and lands in the stylesheet as a declaration's
|
|
2450
|
+
* value, where a nested block becomes a rule of its own.
|
|
2451
|
+
* - `altFontFamily` lands in a rule the browser parses first, which drops
|
|
2452
|
+
* what does not belong. It is refused anyway: nothing needs it, and that
|
|
2453
|
+
* parse is all that stands in the way.
|
|
2454
|
+
*
|
|
2455
|
+
* Mermaid applies the list at every depth, so `themeVariables.fontFamily` in
|
|
2456
|
+
* a diagram is refused too. The list is spelled whole rather than relying on
|
|
2457
|
+
* Mermaid merging it with its default. `frontend/e2e/mermaid.spec.ts` measures
|
|
2458
|
+
* all four routes; see "Mermaid" in `docs/reference/inline-markup.md`.
|
|
2459
|
+
*/
|
|
2460
|
+
const MERMAID_SECURE_KEYS = [
|
|
2461
|
+
"secure",
|
|
2462
|
+
"securityLevel",
|
|
2463
|
+
"startOnLoad",
|
|
2464
|
+
"maxTextSize",
|
|
2465
|
+
"suppressErrorRendering",
|
|
2466
|
+
"maxEdges",
|
|
2467
|
+
"themeCSS",
|
|
2468
|
+
"fontFamily",
|
|
2469
|
+
"altFontFamily"
|
|
2470
|
+
];
|
|
1730
2471
|
function configure(m, palette) {
|
|
1731
2472
|
const theme = mermaidThemeOf(palette);
|
|
1732
2473
|
m.initialize({
|
|
@@ -1734,7 +2475,8 @@ function configure(m, palette) {
|
|
|
1734
2475
|
theme,
|
|
1735
2476
|
themeVariables: mermaidThemeVariables(theme),
|
|
1736
2477
|
securityLevel: "strict",
|
|
1737
|
-
suppressErrorRendering: true
|
|
2478
|
+
suppressErrorRendering: true,
|
|
2479
|
+
secure: MERMAID_SECURE_KEYS
|
|
1738
2480
|
});
|
|
1739
2481
|
configuredTheme = palette;
|
|
1740
2482
|
}
|
|
@@ -1769,15 +2511,6 @@ async function getMermaid() {
|
|
|
1769
2511
|
//#endregion
|
|
1770
2512
|
//#region src/renderMermaidBlocks.ts
|
|
1771
2513
|
/**
|
|
1772
|
-
* Client-side utility to find and render mermaid code blocks in a container.
|
|
1773
|
-
*
|
|
1774
|
-
* After calling `renderMarkdown()`, mermaid blocks come through as
|
|
1775
|
-
* `<pre><code class="language-mermaid">...</code></pre>`. This function
|
|
1776
|
-
* finds those blocks and replaces them with rendered SVG diagrams.
|
|
1777
|
-
*
|
|
1778
|
-
* Framework-agnostic — works in any browser environment.
|
|
1779
|
-
*/
|
|
1780
|
-
/**
|
|
1781
2514
|
* Find all `<pre><code class="language-mermaid">` blocks in a container
|
|
1782
2515
|
* and replace them with rendered SVG diagrams.
|
|
1783
2516
|
*
|
|
@@ -1858,6 +2591,7 @@ function replaceWithSvg(preEl, svg, className) {
|
|
|
1858
2591
|
wrapper.innerHTML = svg;
|
|
1859
2592
|
preEl.replaceWith(wrapper);
|
|
1860
2593
|
}
|
|
2594
|
+
unified().use(remarkParse).use(buildRemarkPlugins());
|
|
1861
2595
|
//#endregion
|
|
1862
2596
|
//#region src/resolveLinks.ts
|
|
1863
2597
|
/**
|
|
@@ -1908,12 +2642,15 @@ function resolveLinks(html, options = {}) {
|
|
|
1908
2642
|
* The canonical Vantage Markdown style guide.
|
|
1909
2643
|
*
|
|
1910
2644
|
* This string is the single source of truth for the conventions Vantage's
|
|
1911
|
-
* renderer expects.
|
|
2645
|
+
* renderer expects. It reaches readers three ways:
|
|
1912
2646
|
*
|
|
1913
2647
|
* - the in-app "Style Guide for Agents" modal, which shows it with a copy
|
|
1914
|
-
* button,
|
|
2648
|
+
* button,
|
|
1915
2649
|
* - the `vantage-check style-guide` command, which prints it so an agent can
|
|
1916
|
-
* fetch it without a human in the loop
|
|
2650
|
+
* fetch it without a human in the loop, and
|
|
2651
|
+
* - this package's own export, for anyone who renders with `vantage-md`.
|
|
2652
|
+
*
|
|
2653
|
+
* How the CLI serves it, and why there is one copy: docs/reference/agent-cli.md
|
|
1917
2654
|
*
|
|
1918
2655
|
* Every rule stated here should be one a checker can enforce or a renderer
|
|
1919
2656
|
* actually cares about — if a line is neither, it does not belong.
|
|
@@ -1969,6 +2706,41 @@ vantage:
|
|
|
1969
2706
|
- **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.
|
|
1970
2707
|
- The chip's vocabulary is \`status\`'s, exactly: \`draft | in-review | accepted | deprecated\`, lowercase. \`Draft\` renders no chip at all, silently.
|
|
1971
2708
|
|
|
2709
|
+
### Planning documents: \`stage\`, \`next\`, \`depends-on\`
|
|
2710
|
+
- **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.
|
|
2711
|
+
- 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:\`:
|
|
2712
|
+
\`\`\`yaml
|
|
2713
|
+
---
|
|
2714
|
+
title: "Payload bootstrap"
|
|
2715
|
+
status: in-review
|
|
2716
|
+
stage: DESIGN
|
|
2717
|
+
next: "Rule OQ-B2 \u2014 the install step waits on it"
|
|
2718
|
+
depends-on:
|
|
2719
|
+
- pypi-distribution.md
|
|
2720
|
+
- ../plans/rollout.md#OQ-R1
|
|
2721
|
+
---
|
|
2722
|
+
\`\`\`
|
|
2723
|
+
- **\`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\`.
|
|
2724
|
+
- **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.
|
|
2725
|
+
- **\`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.
|
|
2726
|
+
- **\`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\`).
|
|
2727
|
+
- **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.
|
|
2728
|
+
- **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.
|
|
2729
|
+
- **\`[planning]\` in \`.vantage.toml\`** says which files are read, which of them are roadmaps, and what each stage word means. Every key is optional:
|
|
2730
|
+
\`\`\`toml
|
|
2731
|
+
[planning]
|
|
2732
|
+
# roadmap = ["roadmap.md", "docs/plans/roadmap.md"] # absent: every roadmap.md
|
|
2733
|
+
exclude = ["docs/gallery/**"] # gitignore syntax; every .md is included by default
|
|
2734
|
+
|
|
2735
|
+
[planning.stages] # each word maps to open | ready | built | done
|
|
2736
|
+
DESIGN = "open"
|
|
2737
|
+
DECIDED = "ready"
|
|
2738
|
+
BUILT = "built"
|
|
2739
|
+
SUPERSEDED = "done"
|
|
2740
|
+
\`\`\`
|
|
2741
|
+
- **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.
|
|
2742
|
+
- **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.
|
|
2743
|
+
|
|
1972
2744
|
### Mermaid diagrams
|
|
1973
2745
|
- Use \`\`\`mermaid code blocks for flowcharts, sequence diagrams, and architecture diagrams. Vantage provides interactive zoom, pan, dark/light theme adaptation, and SVG export.
|
|
1974
2746
|
- **Quote labels with special characters**: Always quote node labels containing parentheses, brackets, or colons to prevent syntax errors:
|
|
@@ -1978,6 +2750,26 @@ flowchart TD
|
|
|
1978
2750
|
srv --> git["Git CLI (git diff)"]
|
|
1979
2751
|
\`\`\`
|
|
1980
2752
|
|
|
2753
|
+
### Inline SVG (needs Vantage 0.8 or later)
|
|
2754
|
+
- **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.
|
|
2755
|
+
- **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.
|
|
2756
|
+
- **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>\`.
|
|
2757
|
+
- **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.
|
|
2758
|
+
\`\`\`markdown
|
|
2759
|
+
<div>
|
|
2760
|
+
<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">
|
|
2761
|
+
<rect x="1" y="1" width="40" height="38" fill="none" stroke="currentColor"/>
|
|
2762
|
+
<path d="M45 20 H75 M68 14 L75 20 L68 26" fill="none" stroke="currentColor"/>
|
|
2763
|
+
<rect x="79" y="1" width="40" height="38" fill="none" stroke="currentColor"/>
|
|
2764
|
+
</svg>
|
|
2765
|
+
</div>
|
|
2766
|
+
|
|
2767
|
+
<!-- vantage: fallback -->
|
|
2768
|
+
|
|
2769
|
+
Two boxes joined by an arrow. This drawing needs Vantage 0.8 or later.
|
|
2770
|
+
\`\`\`
|
|
2771
|
+
- **For a document read mostly on GitHub**, commit the drawing as a file and embed it with \`\` instead, which every renderer shows.
|
|
2772
|
+
|
|
1981
2773
|
### Code blocks and diffs
|
|
1982
2774
|
- Always tag fenced code blocks with language identifiers (\`ts\`, \`go\`, \`python\`, \`bash\`, \`json\`, \`yaml\`, \`diff\`, \`sql\`, etc.) for syntax highlighting.
|
|
1983
2775
|
- For proposed code modifications, use \`\`\`diff blocks with \`+\` and \`-\` prefixes:
|
|
@@ -2015,25 +2807,60 @@ Vantage reads a few styling hints from ordinary HTML comments. Every other rende
|
|
|
2015
2807
|
The steps below predate the rewrite.
|
|
2016
2808
|
\`\`\`
|
|
2017
2809
|
|
|
2018
|
-
- **
|
|
2810
|
+
- **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).
|
|
2019
2811
|
- **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.
|
|
2020
2812
|
- **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.
|
|
2021
2813
|
- **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.
|
|
2022
2814
|
- **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.
|
|
2023
2815
|
- **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.
|
|
2024
|
-
- **
|
|
2816
|
+
- **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.
|
|
2025
2817
|
- **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.
|
|
2026
|
-
|
|
2027
|
-
|
|
2818
|
+
|
|
2819
|
+
### Open questions
|
|
2820
|
+
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.
|
|
2821
|
+
|
|
2822
|
+
- **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:
|
|
2823
|
+
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?**\`.
|
|
2824
|
+
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.
|
|
2825
|
+
3. **The options as a list**, one item each, when there are options to choose between.
|
|
2826
|
+
4. **\`_Leaning:_\` as a paragraph of its own**: which option, and why, in a sentence or two.
|
|
2827
|
+
5. **\`**Answer:**\` as a paragraph of its own**, with the ruling below it once there is one.
|
|
2028
2828
|
|
|
2029
2829
|
\`\`\`markdown
|
|
2030
|
-
|
|
2830
|
+
## Open Questions
|
|
2831
|
+
|
|
2832
|
+
1. \u{1F4AC} **OQ-9: Where does a job go when it re-enters the queue?**
|
|
2031
2833
|
|
|
2032
|
-
|
|
2834
|
+
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.
|
|
2033
2835
|
|
|
2034
|
-
|
|
2836
|
+
- **A — The back of the queue.** It waits its turn again.
|
|
2837
|
+
- **B — Its old place.** It skips ahead of what joined since.
|
|
2838
|
+
|
|
2839
|
+
<!-- vantage: question id=OQ-9 leaning="A — the back of the queue: the fix might interact with what merged while it was out." -->
|
|
2840
|
+
|
|
2841
|
+
_Leaning:_ A. The fix might interact with what merged while it was out.
|
|
2842
|
+
|
|
2843
|
+
**Answer:**
|
|
2844
|
+
|
|
2845
|
+
> _(empty — fill in when decided)_
|
|
2846
|
+
|
|
2847
|
+
2. \u{1F512} **OQ-10: How large is the retry budget?**
|
|
2848
|
+
|
|
2849
|
+
<!-- vantage: question id=OQ-10 -->
|
|
2850
|
+
|
|
2851
|
+
Waits on the load test, so there is no leaning to state yet.
|
|
2035
2852
|
\`\`\`
|
|
2036
2853
|
|
|
2854
|
+
- **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.
|
|
2855
|
+
- **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.
|
|
2856
|
+
- **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.
|
|
2857
|
+
- **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."
|
|
2858
|
+
- **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.
|
|
2859
|
+
- **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]\`.
|
|
2860
|
+
- **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\`).
|
|
2861
|
+
- **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\`.
|
|
2862
|
+
- **\`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\`).
|
|
2863
|
+
|
|
2037
2864
|
### Tables, task lists, and math
|
|
2038
2865
|
- **Tables**: Use standard markdown tables for structured comparisons and schemas.
|
|
2039
2866
|
- **Task lists**: Use \`- [ ]\` and \`- [x]\` for actionable checklists and status tracking.
|
|
@@ -2041,6 +2868,6 @@ The steps below predate the rewrite.
|
|
|
2041
2868
|
- Single dollars are **not** math delimiters: \`$HOME\` and \`$100\` stay literal, so prose and shell snippets are safe to write as-is.
|
|
2042
2869
|
`;
|
|
2043
2870
|
//#endregion
|
|
2044
|
-
export { ALERT_TITLES, COLOR_THEME_ATTRIBUTE, COLOR_THEME_SOURCE_ATTRIBUTE, DIRECTIVE_NAMES, DIRECTIVE_VOCABULARY, DOC_STATUSES, DOC_STATUS_TONES, SAFE_STYLE, STYLE_GUIDE, VANTAGE_ALERTS, VANTAGE_BADGES, VANTAGE_COLLAPSED, VANTAGE_EMPHASIS, VANTAGE_FRONTMATTER_KEYS, VANTAGE_OQ_HOST_TARGETS, VANTAGE_OQ_STATUS, VANTAGE_OQ_STATUS_LABEL, VANTAGE_RUNS, VANTAGE_SENTINEL, VANTAGE_TONES, buildPipeline, buildRemarkPlugins, clearLineAnchorHighlights, currentColorTheme, hasVantageSentinel, isDocStatus, parseFrontmatter, parseLineAnchor, parseVantageDirective, readVantageFrontmatter, rehypeSourceLines, rehypeVantageAlerts, rehypeVantageAnchors, rehypeVantageDirectives, renderMarkdown, renderMermaidBlocks, resolveLinks, sanitizeSchema, scrollToLineAnchor, vantageOqStatus };
|
|
2871
|
+
export { ALERT_TITLES, COLOR_THEME_ATTRIBUTE, COLOR_THEME_SOURCE_ATTRIBUTE, DIRECTIVE_NAMES, DIRECTIVE_VOCABULARY, DOC_STATUSES, DOC_STATUS_TONES, SAFE_STYLE, STYLE_GUIDE, VANTAGE_ALERTS, VANTAGE_BADGES, VANTAGE_COLLAPSED, VANTAGE_EMPHASIS, VANTAGE_FRONTMATTER_KEYS, VANTAGE_LEANING_ATTRIBUTE, VANTAGE_OQ_ATTRIBUTE, VANTAGE_OQ_HOST_TARGETS, VANTAGE_OQ_STATUS, VANTAGE_OQ_STATUS_LABEL, VANTAGE_QUESTION_ATTRIBUTE, VANTAGE_QUESTION_NAMES, VANTAGE_QUESTION_SELECTOR, VANTAGE_RUNS, VANTAGE_SENTINEL, VANTAGE_TONES, buildPipeline, buildRemarkPlugins, clearLineAnchorHighlights, currentColorTheme, hasVantageSentinel, isDocStatus, isQuestionDirective, parseFrontmatter, parseLineAnchor, parseVantageDirective, questionOffersTake, readVantageFrontmatter, rehypeSourceLines, rehypeVantageAlerts, rehypeVantageAnchors, rehypeVantageDirectives, renderMarkdown, renderMermaidBlocks, resolveLinks, sanitizeSchema, scrollToLineAnchor, vantageOqStatus };
|
|
2045
2872
|
|
|
2046
2873
|
//# sourceMappingURL=index.js.map
|