vantage-md 0.7.1 → 0.8.0
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 +173 -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/react.cjs
CHANGED
|
@@ -25,10 +25,6 @@ let react = require("react");
|
|
|
25
25
|
react = __toESM(react, 1);
|
|
26
26
|
let react_markdown = require("react-markdown");
|
|
27
27
|
react_markdown = __toESM(react_markdown, 1);
|
|
28
|
-
let remark_gfm = require("remark-gfm");
|
|
29
|
-
remark_gfm = __toESM(remark_gfm, 1);
|
|
30
|
-
let remark_math = require("remark-math");
|
|
31
|
-
remark_math = __toESM(remark_math, 1);
|
|
32
28
|
let rehype_raw = require("rehype-raw");
|
|
33
29
|
rehype_raw = __toESM(rehype_raw, 1);
|
|
34
30
|
let rehype_sanitize = require("rehype-sanitize");
|
|
@@ -40,13 +36,17 @@ rehype_katex = __toESM(rehype_katex, 1);
|
|
|
40
36
|
let rehype_slug = require("rehype-slug");
|
|
41
37
|
rehype_slug = __toESM(rehype_slug, 1);
|
|
42
38
|
let unist_util_visit = require("unist-util-visit");
|
|
39
|
+
let remark_gfm = require("remark-gfm");
|
|
40
|
+
remark_gfm = __toESM(remark_gfm, 1);
|
|
41
|
+
let remark_math = require("remark-math");
|
|
42
|
+
remark_math = __toESM(remark_math, 1);
|
|
43
43
|
let yaml = require("yaml");
|
|
44
44
|
yaml = __toESM(yaml, 1);
|
|
45
45
|
let smol_toml = require("smol-toml");
|
|
46
46
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
47
|
-
let unified = require("unified");
|
|
48
47
|
let remark_parse = require("remark-parse");
|
|
49
48
|
remark_parse = __toESM(remark_parse, 1);
|
|
49
|
+
let unified = require("unified");
|
|
50
50
|
let remark_rehype = require("remark-rehype");
|
|
51
51
|
remark_rehype = __toESM(remark_rehype, 1);
|
|
52
52
|
let rehype_stringify = require("rehype-stringify");
|
|
@@ -96,21 +96,21 @@ const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
|
96
96
|
"hr",
|
|
97
97
|
"div"
|
|
98
98
|
]);
|
|
99
|
-
function visit$
|
|
99
|
+
function visit$2(node, offset) {
|
|
100
100
|
if ("children" in node) {
|
|
101
101
|
for (const child of node.children) if (child.type === "element") {
|
|
102
102
|
if (BLOCK_TAGS.has(child.tagName) && child.position?.start?.line) {
|
|
103
103
|
child.properties = child.properties || {};
|
|
104
104
|
child.properties["dataSourceLine"] = child.position.start.line + offset;
|
|
105
105
|
}
|
|
106
|
-
visit$
|
|
106
|
+
visit$2(child, offset);
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
const rehypeSourceLines = (options) => {
|
|
111
111
|
const offset = options?.offset ?? 0;
|
|
112
112
|
return (tree) => {
|
|
113
|
-
visit$
|
|
113
|
+
visit$2(tree, offset);
|
|
114
114
|
};
|
|
115
115
|
};
|
|
116
116
|
//#endregion
|
|
@@ -163,6 +163,12 @@ const VANTAGE_ALERTS = [
|
|
|
163
163
|
"warning",
|
|
164
164
|
"caution"
|
|
165
165
|
];
|
|
166
|
+
/**
|
|
167
|
+
* The class on the title element this plugin injects, which
|
|
168
|
+
* `styles/directives.css` styles. Exported because the sanitizer runs after
|
|
169
|
+
* this plugin and admits a `div`'s class only when it is this one.
|
|
170
|
+
*/
|
|
171
|
+
const ALERT_TITLE_CLASS = "vantage-alert-title";
|
|
166
172
|
/** The visible label per kind. Title case, as GitHub renders it. */
|
|
167
173
|
const ALERT_TITLES = {
|
|
168
174
|
note: "Note",
|
|
@@ -186,7 +192,7 @@ const ALERT_TITLES = {
|
|
|
186
192
|
* anchored test on the first text node is enough, and the plugin does not have
|
|
187
193
|
* to reassemble the marker across siblings.
|
|
188
194
|
*/
|
|
189
|
-
const
|
|
195
|
+
const ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\r?\n|$)/;
|
|
190
196
|
/** The first child, if it is an element. */
|
|
191
197
|
function firstElement(node) {
|
|
192
198
|
const child = node.children.find((c) => c.type === "element" || c.type === "text" && c.value.trim() !== "");
|
|
@@ -213,7 +219,7 @@ function rehypeVantageAlerts() {
|
|
|
213
219
|
if (paragraph === void 0 || paragraph.tagName !== "p") return;
|
|
214
220
|
const lead = paragraph.children[0];
|
|
215
221
|
if (lead === void 0 || lead.type !== "text") return;
|
|
216
|
-
const match =
|
|
222
|
+
const match = ALERT_MARKER.exec(lead.value);
|
|
217
223
|
if (match === null) return;
|
|
218
224
|
const kind = match[1].toLowerCase();
|
|
219
225
|
lead.value = lead.value.slice(match[0].length);
|
|
@@ -225,7 +231,7 @@ function rehypeVantageAlerts() {
|
|
|
225
231
|
node.children.unshift({
|
|
226
232
|
type: "element",
|
|
227
233
|
tagName: "div",
|
|
228
|
-
properties: { className: [
|
|
234
|
+
properties: { className: [ALERT_TITLE_CLASS] },
|
|
229
235
|
children: [{
|
|
230
236
|
type: "text",
|
|
231
237
|
value: ALERT_TITLES[kind]
|
|
@@ -236,6 +242,10 @@ function rehypeVantageAlerts() {
|
|
|
236
242
|
}
|
|
237
243
|
//#endregion
|
|
238
244
|
//#region src/vantageDirectives.ts
|
|
245
|
+
/** Whether `name` declares a question, `question` or the deprecated `oq`. */
|
|
246
|
+
function isQuestionDirective(name) {
|
|
247
|
+
return name === "oq" || name === "question";
|
|
248
|
+
}
|
|
239
249
|
/**
|
|
240
250
|
* The `tone` vocabulary: GitHub's alert words plus `muted`.
|
|
241
251
|
*
|
|
@@ -328,13 +338,32 @@ const VANTAGE_STYLE_TARGETS = [
|
|
|
328
338
|
"div"
|
|
329
339
|
];
|
|
330
340
|
/**
|
|
331
|
-
* The tags
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
341
|
+
* The tags a `fallback` directive may **withhold**: `VANTAGE_STYLE_TARGETS`
|
|
342
|
+
* minus the six headings, written as a subtraction so the narrowing stays
|
|
343
|
+
* visible.
|
|
344
|
+
*
|
|
345
|
+
* A heading is refused because it is the document's structure rather than a
|
|
346
|
+
* stand-in for anything: the contents column lists it, other documents link to
|
|
347
|
+
* its slug, and the planning index reads sections under it. A fallback heading
|
|
348
|
+
* would be an anchor that exists on GitHub and nowhere in Vantage. What a
|
|
349
|
+
* fallback holds — a sentence naming the release a drawing needs, a link to
|
|
350
|
+
* the drawing as a file, an ASCII sketch in a fence — is a paragraph, a list,
|
|
351
|
+
* a quote, a code block, a table or a `<div>`, and those are all here.
|
|
352
|
+
*
|
|
353
|
+
* Three callers read it, and they must agree: `rehypeVantageDirectives`, which
|
|
354
|
+
* withholds the block; the planning index's scan, which must not read a
|
|
355
|
+
* question or a link from a block the page never shows; and the checker's
|
|
356
|
+
* `vantage/orphan`, which reports a fallback that withholds nothing.
|
|
357
|
+
*/
|
|
358
|
+
const VANTAGE_FALLBACK_TARGETS = VANTAGE_STYLE_TARGETS.filter((tag) => !/^h[1-6]$/.test(tag));
|
|
359
|
+
/**
|
|
360
|
+
* The tags a question directive, `oq` or `question`, may stamp — strictly the
|
|
361
|
+
* tags the review system can resolve an anchor on (`ANCHOR_TAGS` in the app's
|
|
362
|
+
* `MarkdownViewer`, and the block map in `useReviewHighlights`). `ul`, `ol`,
|
|
363
|
+
* `tr`, `hr` and `div` are in neither, so a button on one of them would build
|
|
364
|
+
* an anchor no review pass can find — the "mis-wired button" D6 forbids.
|
|
336
365
|
*
|
|
337
|
-
* The gap between this list and `VANTAGE_STYLE_TARGETS` is why
|
|
366
|
+
* The gap between this list and `VANTAGE_STYLE_TARGETS` is why a question
|
|
338
367
|
* directive at column 0 above a list silently does nothing: the target is the
|
|
339
368
|
* `<ul>`, not the `<li>`. The checker says so.
|
|
340
369
|
*/
|
|
@@ -353,9 +382,9 @@ const VANTAGE_ANCHOR_TARGETS = [
|
|
|
353
382
|
];
|
|
354
383
|
VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
|
|
355
384
|
/**
|
|
356
|
-
* The shape of
|
|
357
|
-
*
|
|
358
|
-
* and a bare `OQ6` are not.
|
|
385
|
+
* The shape of a question directive's `id`, on `oq` and `question` alike:
|
|
386
|
+
* `OQ-` then an optional short uppercase prefix then digits. `OQ-9`, `OQ-TP6`
|
|
387
|
+
* and `OQ-A03` are ids; `OQ-foo`, `OQ-tp6` and a bare `OQ6` are not.
|
|
359
388
|
*
|
|
360
389
|
* The prefix is what keeps ids distinct once one document references another's
|
|
361
390
|
* questions — `trust-paths.md`'s `OQ-4` and a design sketch's `OQ-4` are
|
|
@@ -363,10 +392,15 @@ VANTAGE_ANCHOR_TARGETS.filter((tag) => tag !== "pre" && tag !== "table");
|
|
|
363
392
|
* reference means. It is optional because most documents never leave their own
|
|
364
393
|
* file, and requiring it everywhere would fire on every single-doc sketch.
|
|
365
394
|
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
* the checker
|
|
369
|
-
*
|
|
395
|
+
* Read from here, never re-spelled, by the sanitizer that allowlists the value,
|
|
396
|
+
* by the checker's `collectOqIds` (which feeds `vantage/oq-id-format` and the
|
|
397
|
+
* anchors the checker accepts) and by the planning index. The directive plugin
|
|
398
|
+
* that stamps the anchor deliberately does not test it: it stamps any non-empty
|
|
399
|
+
* id and leaves refusing a malformed one to the sanitizer. A re-spelled copy is
|
|
400
|
+
* how the checker starts calling a working anchor malformed — and the patterns
|
|
401
|
+
* that look for an id in text (`OQ_REFERENCE` and `OQ_EXACT` in the checker's
|
|
402
|
+
* `rules/references.ts`, `OQ_TOKEN` in `planning/scan.ts` and in
|
|
403
|
+
* `FrontmatterDisplay.tsx`) restate this shape, so they change with it.
|
|
370
404
|
*/
|
|
371
405
|
const VANTAGE_OQ_ID = /^OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+$/;
|
|
372
406
|
const STYLE_KEYS = {
|
|
@@ -379,10 +413,21 @@ const STYLE_KEYS = {
|
|
|
379
413
|
* Name → key → the closed value set for that key.
|
|
380
414
|
*
|
|
381
415
|
* `section` and `block` share their keys: they differ in *extent*, not in what
|
|
382
|
-
* they can say.
|
|
383
|
-
*
|
|
384
|
-
* neither can be value-allowlisted,
|
|
385
|
-
* than left to a caller to guess.
|
|
416
|
+
* they can say. The two question names share theirs too, and they are the
|
|
417
|
+
* design's only values with no closed set — `id` is a token an author chose
|
|
418
|
+
* and `leaning` is a sentence (§8.3) — so neither can be value-allowlisted,
|
|
419
|
+
* which is recorded here as `null` rather than left to a caller to guess.
|
|
420
|
+
*
|
|
421
|
+
* `question` takes exactly `oq`'s keys and nothing more. Starting narrow is
|
|
422
|
+
* the forward-compatible choice: a key added later is one older viewers drop
|
|
423
|
+
* pair by pair, while a key accepted now could never be given a meaning (P0).
|
|
424
|
+
* A `leaning` on a 🔒 or ✅ question is allowed and simply not offered
|
|
425
|
+
* (`questionOffersTake`), so changing a question's state changes only its
|
|
426
|
+
* marker.
|
|
427
|
+
*
|
|
428
|
+
* `fallback` takes no keys: whether a block is withheld is the whole of what
|
|
429
|
+
* it says, and a key added later is dropped pair by pair by this release, as
|
|
430
|
+
* D2 drops any other.
|
|
386
431
|
*/
|
|
387
432
|
const DIRECTIVE_VOCABULARY = {
|
|
388
433
|
section: STYLE_KEYS,
|
|
@@ -390,9 +435,59 @@ const DIRECTIVE_VOCABULARY = {
|
|
|
390
435
|
oq: {
|
|
391
436
|
id: null,
|
|
392
437
|
leaning: null
|
|
393
|
-
}
|
|
438
|
+
},
|
|
439
|
+
question: {
|
|
440
|
+
id: null,
|
|
441
|
+
leaning: null
|
|
442
|
+
},
|
|
443
|
+
fallback: {}
|
|
394
444
|
};
|
|
395
445
|
/**
|
|
446
|
+
* Merge the question directives of one run — the comments up to the block they
|
|
447
|
+
* land on — into the one question they declare, or `undefined` when the run
|
|
448
|
+
* holds none. Directives of any other name are skipped.
|
|
449
|
+
*
|
|
450
|
+
* A run holding an `oq` is read exactly as a viewer that predates `question`
|
|
451
|
+
* reads it: that viewer drops every `question` and reads the `oq` alone, so
|
|
452
|
+
* the `oq` wins wherever it appears in the run, and only its keys apply — a
|
|
453
|
+
* key only a `question` in the same run sets applies nowhere, in no release.
|
|
454
|
+
* That is the one reading every viewer agrees on (`VANTAGE_QUESTION_NAMES`),
|
|
455
|
+
* so the same bytes file the same Take in 0.7 and in 0.8. Within one name, the
|
|
456
|
+
* last value of a key wins, as `section` and `block` resolve theirs. A pair
|
|
457
|
+
* its name does not accept, or a value outside a closed set, is dropped (D2).
|
|
458
|
+
*
|
|
459
|
+
* One function for every reader of a run: the plugin that stamps the question
|
|
460
|
+
* and the planning index that reads it, which the checker reads a question
|
|
461
|
+
* through, so none can disagree about which leaning or which id a run carries
|
|
462
|
+
* (D5).
|
|
463
|
+
*/
|
|
464
|
+
function mergeQuestionRun(run) {
|
|
465
|
+
let name;
|
|
466
|
+
for (const directive of run) {
|
|
467
|
+
if (!isQuestionDirective(directive.name)) continue;
|
|
468
|
+
if (name !== "oq") name = directive.name;
|
|
469
|
+
}
|
|
470
|
+
if (name === void 0) return void 0;
|
|
471
|
+
const accepted = DIRECTIVE_VOCABULARY[name] ?? {};
|
|
472
|
+
const keys = /* @__PURE__ */ new Map();
|
|
473
|
+
const sources = /* @__PURE__ */ new Map();
|
|
474
|
+
run.forEach((directive, index) => {
|
|
475
|
+
if (directive.name !== name) return;
|
|
476
|
+
for (const pair of directive.pairs) {
|
|
477
|
+
const values = accepted[pair.key];
|
|
478
|
+
if (values === void 0) continue;
|
|
479
|
+
if (values !== null && !values.includes(pair.value)) continue;
|
|
480
|
+
keys.set(pair.key, pair.value);
|
|
481
|
+
sources.set(pair.key, index);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
return {
|
|
485
|
+
name,
|
|
486
|
+
keys,
|
|
487
|
+
sources
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
396
491
|
* `ws` is `[ \t\r\n]` — the design's grammar leaves it undefined, and `\n` has
|
|
397
492
|
* to be in the set because a directive may legally wrap: a multi-line comment
|
|
398
493
|
* is one node whose value contains the newlines.
|
|
@@ -498,10 +593,24 @@ function parseVantageDirective(comment) {
|
|
|
498
593
|
pairs
|
|
499
594
|
};
|
|
500
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* A question directive's `leaning=` value as the page carries it: whitespace
|
|
598
|
+
* runs collapsed to one space, trimmed, and capped at `MAX_LEANING`.
|
|
599
|
+
*
|
|
600
|
+
* A wrapped directive puts newlines and indentation into the value. Two readers
|
|
601
|
+
* need the result and must agree on it: `rehypeVantageDirectives`, which stamps
|
|
602
|
+
* it as `data-vantage-leaning` for the one-click answer, and the planning index,
|
|
603
|
+
* which reports the leaning a question carries. An empty result means the
|
|
604
|
+
* question states no leaning.
|
|
605
|
+
*/
|
|
606
|
+
function normalizeLeaning(raw) {
|
|
607
|
+
return raw.replace(/\s+/g, " ").trim().slice(0, 500);
|
|
608
|
+
}
|
|
501
609
|
//#endregion
|
|
502
610
|
//#region src/rehypeVantageDirectives.ts
|
|
503
611
|
/**
|
|
504
|
-
* What a `section`/`block` and
|
|
612
|
+
* What a `section`/`block` and a question directive (`oq`, `question`) may
|
|
613
|
+
* **target**.
|
|
505
614
|
*
|
|
506
615
|
* Both lists live in `vantageDirectives.ts`, with the reasoning for each tag,
|
|
507
616
|
* because the CLI checker resolves the same question over mdast and must reach
|
|
@@ -511,6 +620,8 @@ function parseVantageDirective(comment) {
|
|
|
511
620
|
*/
|
|
512
621
|
const STYLE_TARGET_TAGS = new Set(VANTAGE_STYLE_TARGETS);
|
|
513
622
|
const ANCHOR_TARGET_TAGS = new Set(VANTAGE_ANCHOR_TARGETS);
|
|
623
|
+
/** What a `fallback` may withhold: every style target but a heading. */
|
|
624
|
+
const FALLBACK_TARGET_TAGS = new Set(VANTAGE_FALLBACK_TARGETS);
|
|
514
625
|
const HEADING_DEPTHS = /* @__PURE__ */ new Map([
|
|
515
626
|
["h1", 1],
|
|
516
627
|
["h2", 2],
|
|
@@ -551,6 +662,19 @@ const RANGE_PROPERTIES = /* @__PURE__ */ new Map([["tone", "dataVantageTone"], [
|
|
|
551
662
|
*/
|
|
552
663
|
const POINT_PROPERTIES = /* @__PURE__ */ new Map([["badge", "dataVantageBadge"]]);
|
|
553
664
|
const RUN_PROPERTY = "dataVantageRun";
|
|
665
|
+
/**
|
|
666
|
+
* What a question stamps, whichever name declared it: `data-vantage-question`
|
|
667
|
+
* on every question, and `data-vantage-oq` besides on one an `oq` declared
|
|
668
|
+
* (`VANTAGE_QUESTION_ATTRIBUTE`, `VANTAGE_OQ_ATTRIBUTE`).
|
|
669
|
+
*
|
|
670
|
+
* Two attributes, and neither one ever means more than it says. Every release
|
|
671
|
+
* since 0.7 stamps `data-vantage-oq` on an `oq` and on nothing else, so a
|
|
672
|
+
* reader of this package's markup that has ever read it keeps reading exactly
|
|
673
|
+
* what it read; `data-vantage-question` is the one to read for "a question",
|
|
674
|
+
* and the question's state, from its marker, is what decides whether anything
|
|
675
|
+
* offers to answer it (`questionOffersTake`).
|
|
676
|
+
*/
|
|
677
|
+
const QUESTION_PROPERTY = "dataVantageQuestion";
|
|
554
678
|
const OQ_PROPERTY = "dataVantageOq";
|
|
555
679
|
const LEANING_PROPERTY = "dataVantageLeaning";
|
|
556
680
|
/**
|
|
@@ -585,8 +709,6 @@ const OQ_ID_PROPERTY = "dataVantageOqId";
|
|
|
585
709
|
const COLLAPSED_PROPERTY = "dataVantageCollapsed";
|
|
586
710
|
const COLLAPSE_GROUP_PROPERTY = "dataVantageCollapseGroup";
|
|
587
711
|
const COLLAPSE_TOGGLE_PROPERTY = "dataVantageCollapseToggle";
|
|
588
|
-
/** A review-comment body, not prose. Bounds the attribute; 500 is generous. */
|
|
589
|
-
const MAX_LEANING = 500;
|
|
590
712
|
/**
|
|
591
713
|
* Nodes that may sit between a directive and its target.
|
|
592
714
|
*
|
|
@@ -720,13 +842,14 @@ function stampStyle(children, targetIndex, name, pairs, state) {
|
|
|
720
842
|
}
|
|
721
843
|
}
|
|
722
844
|
}
|
|
723
|
-
function
|
|
724
|
-
setProperty(target,
|
|
845
|
+
function stampQuestion(target, name, pairs) {
|
|
846
|
+
setProperty(target, QUESTION_PROPERTY, "true");
|
|
847
|
+
if (name === "oq") setProperty(target, OQ_PROPERTY, "true");
|
|
725
848
|
const id = pairs.get("id");
|
|
726
849
|
if (id !== void 0 && id !== "") setProperty(target, OQ_ID_PROPERTY, id);
|
|
727
850
|
const leaning = pairs.get("leaning");
|
|
728
851
|
if (leaning === void 0) return;
|
|
729
|
-
const text = leaning
|
|
852
|
+
const text = normalizeLeaning(leaning);
|
|
730
853
|
if (text !== "") setProperty(target, LEANING_PROPERTY, text);
|
|
731
854
|
}
|
|
732
855
|
/**
|
|
@@ -741,18 +864,14 @@ function stampOq(target, pairs) {
|
|
|
741
864
|
function stampRun(children, targetIndex, run, state) {
|
|
742
865
|
const target = children[targetIndex];
|
|
743
866
|
const style = /* @__PURE__ */ new Map();
|
|
744
|
-
const oq = /* @__PURE__ */ new Map();
|
|
745
867
|
let styleName;
|
|
746
|
-
let hasOq = false;
|
|
747
868
|
for (const directive of run) if (directive.name === "section" || directive.name === "block") {
|
|
748
869
|
styleName = directive.name;
|
|
749
870
|
for (const pair of directive.pairs) style.set(pair.key, pair.value);
|
|
750
|
-
} else if (directive.name === "oq") {
|
|
751
|
-
hasOq = true;
|
|
752
|
-
for (const pair of directive.pairs) oq.set(pair.key, pair.value);
|
|
753
871
|
}
|
|
754
872
|
if (styleName !== void 0) stampStyle(children, targetIndex, styleName, style, state);
|
|
755
|
-
|
|
873
|
+
const question = mergeQuestionRun(run);
|
|
874
|
+
if (question !== void 0 && ANCHOR_TARGET_TAGS.has(target.tagName)) stampQuestion(target, question.name, question.keys);
|
|
756
875
|
}
|
|
757
876
|
/** A directive comment, or `undefined` for anything else — malformed included. */
|
|
758
877
|
function directiveOf(node) {
|
|
@@ -765,7 +884,7 @@ function directiveOf(node) {
|
|
|
765
884
|
*
|
|
766
885
|
* The whole tree, not just the root: `rehype-raw` leaves comment nodes inside
|
|
767
886
|
* `blockquote`, inside `li`, inside `td` and inline inside `p`, and the real
|
|
768
|
-
* Open Questions layout puts the
|
|
887
|
+
* Open Questions layout puts the question directive inside a list item — so a
|
|
769
888
|
* root-only walk finds none of them.
|
|
770
889
|
*
|
|
771
890
|
* Pass order is also what resolves a nested section: an inner heading's
|
|
@@ -804,8 +923,66 @@ function processChildren(parent, state) {
|
|
|
804
923
|
i = j;
|
|
805
924
|
}
|
|
806
925
|
}
|
|
926
|
+
/**
|
|
927
|
+
* Remove every block a `fallback` run lands on, with the run itself.
|
|
928
|
+
*
|
|
929
|
+
* A fallback block is written for the renderers that cannot show something
|
|
930
|
+
* Vantage shows — "This drawing needs Vantage 0.8 or later", beside an inline
|
|
931
|
+
* `<svg>`. Every renderer that does not know the name drops the comment and
|
|
932
|
+
* shows the block; this one never renders it, in any mode, in print, or
|
|
933
|
+
* through any of the renderers that share this plugin (D5).
|
|
934
|
+
*
|
|
935
|
+
* The run is found exactly as `processChildren` finds one — the same skip
|
|
936
|
+
* rules, the same first-element target, the same whole-tree walk — and it
|
|
937
|
+
* withholds when any directive in it is a `fallback` and the target is on
|
|
938
|
+
* `VANTAGE_FALLBACK_TARGETS`. Anything else is left for the stamping pass,
|
|
939
|
+
* where a `fallback` is inert.
|
|
940
|
+
*
|
|
941
|
+
* A pass of its own, and first, because a `section` reaches *forward* over its
|
|
942
|
+
* siblings: stamped in one pass, a toned section would count a fallback block
|
|
943
|
+
* after it among its members, give it the run's last `end` marker, and leave
|
|
944
|
+
* the last block anyone sees marked `middle`. Withheld first, the block was
|
|
945
|
+
* never there for any later decision — the section's extent, its run markers,
|
|
946
|
+
* a collapse group's members, a review anchor or an outline entry.
|
|
947
|
+
*/
|
|
948
|
+
function withholdFallbacks(parent) {
|
|
949
|
+
const children = parent.children;
|
|
950
|
+
let i = 0;
|
|
951
|
+
while (i < children.length) {
|
|
952
|
+
const node = children[i];
|
|
953
|
+
if (node.type === "element") {
|
|
954
|
+
withholdFallbacks(node);
|
|
955
|
+
i++;
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
const first = directiveOf(node);
|
|
959
|
+
if (first === void 0) {
|
|
960
|
+
i++;
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
let withholds = first.name === "fallback";
|
|
964
|
+
let j = i + 1;
|
|
965
|
+
let targetIndex = -1;
|
|
966
|
+
for (; j < children.length; j++) {
|
|
967
|
+
const next = children[j];
|
|
968
|
+
if (next.type === "element") {
|
|
969
|
+
targetIndex = j;
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
if (!isSkippable(next)) break;
|
|
973
|
+
if (directiveOf(next)?.name === "fallback") withholds = true;
|
|
974
|
+
}
|
|
975
|
+
const target = targetIndex >= 0 ? children[targetIndex] : void 0;
|
|
976
|
+
if (withholds && target?.type === "element" && FALLBACK_TARGET_TAGS.has(target.tagName)) {
|
|
977
|
+
children.splice(i, targetIndex - i + 1);
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
i = j;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
807
983
|
const rehypeVantageDirectives = () => {
|
|
808
984
|
return (tree) => {
|
|
985
|
+
withholdFallbacks(tree);
|
|
809
986
|
processChildren(tree, { nextGroup: 1 });
|
|
810
987
|
};
|
|
811
988
|
};
|
|
@@ -898,12 +1075,144 @@ const rehypeRestoreMathStamps = () => {
|
|
|
898
1075
|
};
|
|
899
1076
|
//#endregion
|
|
900
1077
|
//#region src/sanitize.ts
|
|
1078
|
+
/** Whether an attribute definition is for `className`. */
|
|
1079
|
+
const isClassName = (definition) => (typeof definition === "string" ? definition : definition[0]) === "className";
|
|
1080
|
+
/**
|
|
1081
|
+
* The value list the default schema gives `className` on `tag`, or an empty
|
|
1082
|
+
* list if it gives none.
|
|
1083
|
+
*/
|
|
1084
|
+
function defaultClasses(tag) {
|
|
1085
|
+
const entry = rehype_sanitize.defaultSchema.attributes?.[tag]?.find(isClassName);
|
|
1086
|
+
return Array.isArray(entry) ? entry.slice(1) : [];
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* The only classes an element keeps: the ones the pipeline itself emits on it
|
|
1090
|
+
* before `rehypeSanitize` runs. Every other element keeps none.
|
|
1091
|
+
*
|
|
1092
|
+
* **A class is a style by another name.** The app ships Tailwind's utility CSS
|
|
1093
|
+
* for its own markup, so `<div class="fixed inset-0 z-50 bg-white">` in a
|
|
1094
|
+
* document laid a white sheet over the whole Vantage window, header and
|
|
1095
|
+
* sidebar included. That is the overlay `SAFE_STYLE` bans `position` to
|
|
1096
|
+
* prevent, spelled as a class instead of a declaration, and `className` used
|
|
1097
|
+
* to be admitted with any value on every element. A document could also
|
|
1098
|
+
* borrow a class the app's own code looks for: `.overflow-y-auto` is how an
|
|
1099
|
+
* anchor link finds the element to scroll when no `data-content-scroll` is in
|
|
1100
|
+
* reach, and `.heading-anchor` is what the outline strips from a heading's
|
|
1101
|
+
* text. The comment-body sanitizer (`frontend/src/lib/commentMarkdown.ts`)
|
|
1102
|
+
* already refused `class` for the same reason.
|
|
1103
|
+
*
|
|
1104
|
+
* What each entry is for, and who emits it:
|
|
1105
|
+
*
|
|
1106
|
+
* - `code`: `language-*`, from a fence's info string (`mdast-util-to-hast`).
|
|
1107
|
+
* `rehype-highlight` reads it, `rehype-katex` keys on `language-math`, and
|
|
1108
|
+
* the viewers render `language-mermaid` as a diagram.
|
|
1109
|
+
* - `ul`, `ol` and `li`: `contains-task-list` and `task-list-item`, from GFM
|
|
1110
|
+
* task lists. `styles/task-list.css` draws the checkbox from them.
|
|
1111
|
+
* - `section`, `h2` and `a`: GFM footnotes (`mdast-util-to-hast`), on the
|
|
1112
|
+
* section, its label and each back-reference.
|
|
1113
|
+
* - `div`: `ALERT_TITLE_CLASS`, on the title `rehypeVantageAlerts` injects.
|
|
1114
|
+
*
|
|
1115
|
+
* `remark-math` also emits `math-display` and `math-inline` on its `code`.
|
|
1116
|
+
* They were never kept, because the default schema's `code` entry takes only
|
|
1117
|
+
* `language-*`, and nothing needs them: `rehype-katex` keys on `language-math`
|
|
1118
|
+
* and tells display from inline by whether the `code` sits in a `pre`.
|
|
1119
|
+
*
|
|
1120
|
+
* **A class added after the sanitizer needs no entry**, and that is most of
|
|
1121
|
+
* the classes the page styles: `rehype-highlight`'s `hljs-*`, KaTeX's output,
|
|
1122
|
+
* Mermaid's diagrams, and everything the app's components and hooks add. A
|
|
1123
|
+
* document that writes one of those on an element keeps nothing.
|
|
1124
|
+
*
|
|
1125
|
+
* **Mermaid source is the route this list cannot close.** Mermaid renders
|
|
1126
|
+
* after the sanitizer and copies the names a diagram's source gives a node
|
|
1127
|
+
* (`:::name`, `class A name`, `classDef`) onto that node, so a diagram can
|
|
1128
|
+
* carry any of the app's utilities on its own nodes. The diagram bounds it:
|
|
1129
|
+
* `position` does nothing on an SVG group, and the diagram's `svg` clips what
|
|
1130
|
+
* is inside it. `frontend/e2e/mermaid.spec.ts` measures that bound. The
|
|
1131
|
+
* stylesheet a diagram's source could write did reach past the diagram, and
|
|
1132
|
+
* `MERMAID_SECURE_KEYS` in `mermaidLoader.ts` refuses the settings that write
|
|
1133
|
+
* it.
|
|
1134
|
+
*
|
|
1135
|
+
* A document may still write these names on these elements. By the time the
|
|
1136
|
+
* sanitizer runs, a hand-written `<li class="task-list-item">` and the one GFM
|
|
1137
|
+
* emitted are the same node, and none of the names can lay an element over
|
|
1138
|
+
* anything outside the document's scroll container. `styles/task-list.css`
|
|
1139
|
+
* floats the checkbox of a `task-list-item` instead of positioning it
|
|
1140
|
+
* absolutely, so it stays in the document's flow whatever the item's own
|
|
1141
|
+
* `style` does, and the checkbox takes no `style` itself (see
|
|
1142
|
+
* `UNSTYLED_TAGS`). The footnote label's class, where a stylesheet defines it,
|
|
1143
|
+
* clips its element to a single pixel.
|
|
1144
|
+
*
|
|
1145
|
+
* **The footnote label's class is read from the default schema, never
|
|
1146
|
+
* spelled here, and the tests spell it in halves.** It is a Tailwind utility,
|
|
1147
|
+
* and Tailwind generates a utility for every class name it finds in the files
|
|
1148
|
+
* it scans, comments included — this directory and all of `frontend/`. The app
|
|
1149
|
+
* renders the label as a visible heading only because nothing it scans spells
|
|
1150
|
+
* that name. Spelled whole in a test, it generated the rule, and the label
|
|
1151
|
+
* would have been hidden from sight as it is on GitHub: a change of rendering,
|
|
1152
|
+
* not a sanitizer fix.
|
|
1153
|
+
*
|
|
1154
|
+
* The list is measured, not remembered. The test "takes no class from
|
|
1155
|
+
* anything the pipeline emits" in `frontend/src/lib/sanitize.test.ts` records
|
|
1156
|
+
* every class on the tree on both sides of `rehypeSanitize`, over a document
|
|
1157
|
+
* that uses every feature that emits one, so a plugin that starts emitting a
|
|
1158
|
+
* new class fails there instead of losing it in every renderer.
|
|
1159
|
+
*/
|
|
1160
|
+
const PIPELINE_CLASSES = {
|
|
1161
|
+
code: [/^language-./],
|
|
1162
|
+
ul: ["contains-task-list"],
|
|
1163
|
+
ol: ["contains-task-list"],
|
|
1164
|
+
li: ["task-list-item"],
|
|
1165
|
+
section: ["footnotes"],
|
|
1166
|
+
h2: defaultClasses("h2"),
|
|
1167
|
+
a: ["data-footnote-backref"],
|
|
1168
|
+
div: [ALERT_TITLE_CLASS]
|
|
1169
|
+
};
|
|
901
1170
|
/**
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
|
|
906
|
-
|
|
1171
|
+
* `tag`'s attributes: the default schema's, less any `className`, then its
|
|
1172
|
+
* `PIPELINE_CLASSES` entry, then `extra`.
|
|
1173
|
+
*
|
|
1174
|
+
* **Always a value list, never a bare `"className"`.** `hast-util-sanitize`
|
|
1175
|
+
* reads a bare name, or a tuple holding no values, as "any value", and an
|
|
1176
|
+
* element's own entry is consulted before `*`'s — the first match wins, and
|
|
1177
|
+
* `*` is not consulted when that element's own entry filters a class list down
|
|
1178
|
+
* to nothing. An element with no entry has none added.
|
|
1179
|
+
*/
|
|
1180
|
+
function withClasses(tag, ...extra) {
|
|
1181
|
+
const classes = PIPELINE_CLASSES[tag] ?? [];
|
|
1182
|
+
const own = (rehype_sanitize.defaultSchema.attributes?.[tag] ?? []).filter((definition) => !isClassName(definition));
|
|
1183
|
+
if (classes.length > 0) own.push(["className", ...classes]);
|
|
1184
|
+
return [...own, ...extra];
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* CSS properties an inline `style` may set.
|
|
1188
|
+
*
|
|
1189
|
+
* **The only `style` this list ever filters is one a document wrote by hand.**
|
|
1190
|
+
* Nothing the pipeline generates reaches it: `rehypeKatex` and `rehypeHighlight`
|
|
1191
|
+
* both run *after* `rehypeSanitize` (`pipeline.ts`), so their output is trusted
|
|
1192
|
+
* rather than filtered, and `remark-gfm` emits table alignment as an `align`
|
|
1193
|
+
* attribute rather than as CSS. So this is a filter on untrusted author HTML and
|
|
1194
|
+
* nothing else — which is exactly the hole that made it necessary: `<div
|
|
1195
|
+
* style="position:fixed;inset:0">` covered the viewport and
|
|
1196
|
+
* `style="background:url(https://…)"` called home on render, both verbatim,
|
|
1197
|
+
* because `rehype-sanitize` does not parse CSS. Scripts were never the risk
|
|
1198
|
+
* here; layout and network were.
|
|
1199
|
+
*
|
|
1200
|
+
* The list is therefore deliberately typographic: the styling a prose document
|
|
1201
|
+
* has any business asking for. It is *not* sized to KaTeX, and a KaTeX release
|
|
1202
|
+
* that starts using a new property is a non-event here — `\pmb` already emits
|
|
1203
|
+
* `text-shadow`, which is not on this list and renders anyway.
|
|
1204
|
+
*
|
|
1205
|
+
* **The design doc used to argue the opposite — that `style` had to be allowed
|
|
1206
|
+
* and `position` enumerated because KaTeX needs them — and it was wrong.** The
|
|
1207
|
+
* measurement behind it was real (KaTeX does emit `position:relative` on every
|
|
1208
|
+
* integral) but the inference was not, because the sanitizer has finished before
|
|
1209
|
+
* the first KaTeX span exists. Rebuilding the shipped rehype order with a filter
|
|
1210
|
+
* that rejects *every* value leaves all ten of the integral's style attributes
|
|
1211
|
+
* untouched. The "Security" section of `docs/reference/inline-markup.md`
|
|
1212
|
+
* records the correction; the test that would catch a reordering is in
|
|
1213
|
+
* `frontend/src/lib/sanitize.test.ts`.
|
|
1214
|
+
*/
|
|
1215
|
+
const SAFE_STYLE_PROPERTIES = [
|
|
907
1216
|
"height",
|
|
908
1217
|
"min-height",
|
|
909
1218
|
"max-height",
|
|
@@ -958,12 +1267,89 @@ const DECLARATION = `(?:(?:${[
|
|
|
958
1267
|
"white-space",
|
|
959
1268
|
"vertical-align",
|
|
960
1269
|
"list-style-type",
|
|
961
|
-
"display",
|
|
962
1270
|
"float",
|
|
963
1271
|
"clear",
|
|
964
1272
|
"opacity",
|
|
965
1273
|
"overflow"
|
|
966
|
-
]
|
|
1274
|
+
];
|
|
1275
|
+
/**
|
|
1276
|
+
* A `style` value we will keep, as a whole.
|
|
1277
|
+
*
|
|
1278
|
+
* One rule beyond the property list does the work: **no parentheses anywhere**,
|
|
1279
|
+
* which closes `url(…)` and `expression(…)` in one stroke — the network and
|
|
1280
|
+
* legacy-script vectors. Its cost is borne entirely by authors, who lose
|
|
1281
|
+
* `calc()`, `rgb()` and `var()` along with them; that is the trade, and it is
|
|
1282
|
+
* worth it for a filter this small.
|
|
1283
|
+
*
|
|
1284
|
+
* **`position` is not on the property list at all**, so every value of it is
|
|
1285
|
+
* refused — `static` and `relative` along with `fixed` and `sticky`. It used to
|
|
1286
|
+
* be enumerated, on the belief that KaTeX needed `relative`; KaTeX renders after
|
|
1287
|
+
* the sanitizer and never meets this regex, so the enumeration was buying
|
|
1288
|
+
* nothing but the residual it conceded. Banning the property closes that
|
|
1289
|
+
* residual, and it is bigger than "overlaps its neighbors" made it sound:
|
|
1290
|
+
* measured in Chrome against the viewer's real ancestor chain, an author's
|
|
1291
|
+
* `position:absolute;top:0;left:0;width:100%;height:100%` is sized to the whole
|
|
1292
|
+
* content pane (the nearest positioned ancestor is outside the scroll
|
|
1293
|
+
* container), is not clipped by the scroller, and survives scrolling to the end
|
|
1294
|
+
* of the document. It was `position:fixed` in all but the keyword.
|
|
1295
|
+
*
|
|
1296
|
+
* Matching is all-or-nothing: one unrecognized declaration drops the whole
|
|
1297
|
+
* attribute, and the element renders unstyled rather than partly styled. That
|
|
1298
|
+
* is the safe direction to fail, and it degrades to plain text rather than to a
|
|
1299
|
+
* broken page.
|
|
1300
|
+
*
|
|
1301
|
+
* **The grammar must stay unambiguous, and `;` is what keeps it so.** The value
|
|
1302
|
+
* class is "anything but the delimiters", which includes whitespace — a value
|
|
1303
|
+
* legitimately contains it (`margin: 0 auto`). So if whitespace could *also*
|
|
1304
|
+
* end a declaration, both constructs would compete for the same characters and
|
|
1305
|
+
* the match would fork at every declaration; on a value that ultimately fails,
|
|
1306
|
+
* the engine explores every fork. An earlier form of this regex separated
|
|
1307
|
+
* declarations with `\s*;?\s*`, and 200 document-controlled characters took the
|
|
1308
|
+
* renderer — and the CLI checker, and therefore CI — 94 seconds. Requiring `;`
|
|
1309
|
+
* pins each declaration's extent to the delimiter positions, so there is exactly
|
|
1310
|
+
* one way to parse any input and rejection is linear. `VALUE` absorbs the
|
|
1311
|
+
* padding on both sides for the same reason: a separate `\\s*` next to it would
|
|
1312
|
+
* put the ambiguity straight back. Pinned by the flat-time test in
|
|
1313
|
+
* `frontend/src/lib/sanitize.test.ts` — do not loosen the separator.
|
|
1314
|
+
*
|
|
1315
|
+
* Residual, stated plainly and now genuinely small: negative `margin` still lets
|
|
1316
|
+
* an element overlap its neighbors *inside the flow*. That one scrolls with the
|
|
1317
|
+
* content and is clipped by the scroll container, and closing it means giving up
|
|
1318
|
+
* margins, which prose actually uses. Containment in the stylesheet, not another
|
|
1319
|
+
* rule here, is what would close it. `overflow: visible` on an inline `<svg>` is
|
|
1320
|
+
* the same residual by another route — see `SVG_ROOT_ATTRIBUTES`.
|
|
1321
|
+
*
|
|
1322
|
+
* **The app's table of contents is inside that scroll container too**, beside
|
|
1323
|
+
* the document column, so a block carried over it took its clicks whenever the
|
|
1324
|
+
* block painted in the positioned layer: one with `opacity`, a toned block, a
|
|
1325
|
+
* heading, or a link to anywhere. The contents sit at `z-10` for that
|
|
1326
|
+
* (`frontend/src/components/TableOfContents.tsx`), and a document cannot climb
|
|
1327
|
+
* back over them only because `z-index` is not on the property list.
|
|
1328
|
+
*/
|
|
1329
|
+
const VALUE = `[^;:()"'\\\\]*`;
|
|
1330
|
+
const DISPLAY_DECLARATION = `display\\s*:\\s*(?:${[
|
|
1331
|
+
"none",
|
|
1332
|
+
"block",
|
|
1333
|
+
"inline-block",
|
|
1334
|
+
"inline-flex",
|
|
1335
|
+
"inline-grid",
|
|
1336
|
+
"inline-table",
|
|
1337
|
+
"inline",
|
|
1338
|
+
"flow-root",
|
|
1339
|
+
"flex",
|
|
1340
|
+
"grid",
|
|
1341
|
+
"table-row-group",
|
|
1342
|
+
"table-row",
|
|
1343
|
+
"table-header-group",
|
|
1344
|
+
"table-footer-group",
|
|
1345
|
+
"table-cell",
|
|
1346
|
+
"table-column-group",
|
|
1347
|
+
"table-column",
|
|
1348
|
+
"table-caption",
|
|
1349
|
+
"table",
|
|
1350
|
+
"list-item"
|
|
1351
|
+
].join("|")})\\s*(?:!\\s*important\\s*)?`;
|
|
1352
|
+
const DECLARATION = `(?:(?:${SAFE_STYLE_PROPERTIES.join("|")})\\s*:${VALUE}|${DISPLAY_DECLARATION})`;
|
|
967
1353
|
const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "i");
|
|
968
1354
|
/**
|
|
969
1355
|
* A collapse group id: one or more digits, anchored.
|
|
@@ -975,6 +1361,281 @@ const SAFE_STYLE = new RegExp(`^\\s*(?:${DECLARATION};\\s*)*${DECLARATION}?$`, "
|
|
|
975
1361
|
*/
|
|
976
1362
|
const COLLAPSE_GROUP_ID = /^[0-9]+$/;
|
|
977
1363
|
/**
|
|
1364
|
+
* Inline SVG, admitted as static drawing and nothing else.
|
|
1365
|
+
*
|
|
1366
|
+
* The allowlist is shapes, text and grouping. Everything in SVG that can run
|
|
1367
|
+
* code, fetch, or reach outside the element is refused: `script`,
|
|
1368
|
+
* `foreignObject`, `use` and `image` (both take an `href`), `xlink:href` on
|
|
1369
|
+
* `a`, the `animate`/`set` family (which can rewrite an `href` after the
|
|
1370
|
+
* sanitizer has looked), `style` elements, and every attribute that takes a
|
|
1371
|
+
* `url(…)` reference — `filter`, `mask`, `clip-path`, `marker-*`, `cursor`.
|
|
1372
|
+
*
|
|
1373
|
+
* Gradients, patterns and `<defs>` are out for a second reason: they are only
|
|
1374
|
+
* reachable through `url(#id)`, and the sanitizer prefixes every `id` with
|
|
1375
|
+
* `user-content-`, so the reference would dangle even if it were allowed.
|
|
1376
|
+
*
|
|
1377
|
+
* **Refusing an element is not the same as removing it.** `hast-util-sanitize`
|
|
1378
|
+
* *unwraps* an element it does not allow — drops the tag and keeps its
|
|
1379
|
+
* children — unless the tag is on `strip`. See `SVG_STRIPPED` for why that
|
|
1380
|
+
* matters here, and for the pass that removes those containers instead.
|
|
1381
|
+
*
|
|
1382
|
+
* `switch` is admitted, as a plain container, because draw.io wraps every label
|
|
1383
|
+
* in one: `<switch><foreignObject>…</foreignObject><text>…</text></switch>`. A
|
|
1384
|
+
* `switch` renders only its first child whose conditions hold, and with the
|
|
1385
|
+
* `foreignObject` removed and the conditional attributes (`requiredFeatures`,
|
|
1386
|
+
* `systemLanguage`) refused, that is the `<text>` fallback. Unwrapped instead,
|
|
1387
|
+
* it would render *every* branch — including the "Text is not SVG - cannot
|
|
1388
|
+
* display" notice draw.io appends for viewers without `foreignObject`.
|
|
1389
|
+
*
|
|
1390
|
+
* Every child requires an `svg` ancestor, so no shape or text element is
|
|
1391
|
+
* admitted into HTML flow on its own.
|
|
1392
|
+
*
|
|
1393
|
+
* `title` and `desc` are not children at all. They are admitted nowhere, inside
|
|
1394
|
+
* a drawing they are removed with their contents (see `SVG_STRIPPED`), and
|
|
1395
|
+
* `aria-label` on the root is the drawing's accessible name. Both are HTML
|
|
1396
|
+
* integration points — the parser reads what is inside them as HTML — and an
|
|
1397
|
+
* `svg` ancestor turned out not to be enough to keep a `<title>` in SVG
|
|
1398
|
+
* context. `<math><svg><title>` keeps MathML context, and React hoists any
|
|
1399
|
+
* `title` outside SVG context into the page's `<head>`, so the document set the
|
|
1400
|
+
* tab title. In `renderMarkdown`'s string output, `<svg><desc><title>`,
|
|
1401
|
+
* `<svg><title><title>` and a `title` inside a `foreignObject` all re-parse into
|
|
1402
|
+
* an HTML `title`, which is what `document.title` reads on a page that has none
|
|
1403
|
+
* of its own.
|
|
1404
|
+
*/
|
|
1405
|
+
const SVG_CHILD_TAGS = [
|
|
1406
|
+
"g",
|
|
1407
|
+
"path",
|
|
1408
|
+
"rect",
|
|
1409
|
+
"circle",
|
|
1410
|
+
"ellipse",
|
|
1411
|
+
"line",
|
|
1412
|
+
"polyline",
|
|
1413
|
+
"polygon",
|
|
1414
|
+
"text",
|
|
1415
|
+
"tspan",
|
|
1416
|
+
"switch"
|
|
1417
|
+
];
|
|
1418
|
+
/**
|
|
1419
|
+
* Refused SVG elements removed *with their contents* when they sit inside an
|
|
1420
|
+
* `<svg>`, rather than unwrapped.
|
|
1421
|
+
*
|
|
1422
|
+
* Each of these holds children that are never meant to be painted where they
|
|
1423
|
+
* stand: a clip region, a mask, a gradient's stops, a marker's arrowhead, a
|
|
1424
|
+
* symbol's body, a filter's primitives, an exporter's RDF, the text of a
|
|
1425
|
+
* `title` or `desc` (see `SVG_CHILD_TAGS`), or — for `foreignObject` — HTML.
|
|
1426
|
+
* Unwrapped, those children land in the drawing as ordinary shapes. A default
|
|
1427
|
+
* Figma export ends in `<defs><clipPath><rect fill="white"/>`, and unwrapping
|
|
1428
|
+
* it painted that white rect over the whole drawing; a `<marker>` arrowhead
|
|
1429
|
+
* became a stray triangle at the origin. The HTML inside a `foreignObject`
|
|
1430
|
+
* survived as descendants of `svg`, which React then created as invisible
|
|
1431
|
+
* SVG-namespace `div`s and `p`s — the anchor an Open Question button looks for
|
|
1432
|
+
* — and which a browser re-parsing `renderMarkdown`'s string output breaks out
|
|
1433
|
+
* of the `svg` at, spilling the rest of the drawing into the page.
|
|
1434
|
+
*
|
|
1435
|
+
* **Only inside an `<svg>`, which is why this is a pass and not the schema's
|
|
1436
|
+
* `strip`.** `strip` removes a tag wherever it stands, and most of these names
|
|
1437
|
+
* are lowercase in prose too. There a bare `<pattern>` on a line of its own is
|
|
1438
|
+
* an HTML element nothing closes, so every Markdown block after it was parsed
|
|
1439
|
+
* into it, and stripping it removed the rest of the document — in every
|
|
1440
|
+
* renderer, with `vantage-check` silent. Inside a drawing the same container is
|
|
1441
|
+
* bounded by the `svg`, because a Markdown paragraph or heading breaks out of
|
|
1442
|
+
* SVG. Outside one the sanitizer unwraps these like any tag it does not know:
|
|
1443
|
+
* `title` and `desc` are not admitted anywhere, so no `title` element survives
|
|
1444
|
+
* to be hoisted, and what they held is text or ordinary sanitized HTML.
|
|
1445
|
+
*
|
|
1446
|
+
* Names compare case-insensitively. The camel-cased ones are how the HTML
|
|
1447
|
+
* parser spells these elements in SVG, but under `<math><svg>` the parser stays
|
|
1448
|
+
* in MathML and leaves them lowercase.
|
|
1449
|
+
*/
|
|
1450
|
+
const SVG_STRIPPED = [
|
|
1451
|
+
"foreignObject",
|
|
1452
|
+
"defs",
|
|
1453
|
+
"clipPath",
|
|
1454
|
+
"mask",
|
|
1455
|
+
"pattern",
|
|
1456
|
+
"marker",
|
|
1457
|
+
"symbol",
|
|
1458
|
+
"linearGradient",
|
|
1459
|
+
"radialGradient",
|
|
1460
|
+
"filter",
|
|
1461
|
+
"metadata",
|
|
1462
|
+
"title",
|
|
1463
|
+
"desc"
|
|
1464
|
+
];
|
|
1465
|
+
const SVG_STRIPPED_NAMES = new Set(SVG_STRIPPED.map((name) => name.toLowerCase()));
|
|
1466
|
+
/**
|
|
1467
|
+
* The camel-cased `SVG_STRIPPED` names, which the schema strips on its own.
|
|
1468
|
+
*
|
|
1469
|
+
* Only an SVG-namespace element carries one of these spellings, so stripping
|
|
1470
|
+
* them everywhere costs prose nothing. It keeps the worst of the list — the
|
|
1471
|
+
* HTML inside a `foreignObject` — out of a drawing for a consumer who hands
|
|
1472
|
+
* `sanitizeSchema` to `rehype-sanitize` without the rest of `buildPipeline`.
|
|
1473
|
+
*/
|
|
1474
|
+
const SVG_STRIPPED_EVERYWHERE = SVG_STRIPPED.filter((name) => name !== name.toLowerCase());
|
|
1475
|
+
function stripSvgContainers(parent, inSvg) {
|
|
1476
|
+
if (inSvg) parent.children = parent.children.filter((child) => child.type !== "element" || !SVG_STRIPPED_NAMES.has(child.tagName.toLowerCase()));
|
|
1477
|
+
for (const child of parent.children) if (child.type === "element") stripSvgContainers(child, inSvg || child.tagName.toLowerCase() === "svg");
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Remove every `SVG_STRIPPED` element below an `svg`, with its contents.
|
|
1481
|
+
*
|
|
1482
|
+
* Runs immediately before `rehypeSanitize` (`pipeline.ts`), and only when the
|
|
1483
|
+
* pipeline sanitizes: with the sanitizer off these elements are left to work
|
|
1484
|
+
* as SVG defines them.
|
|
1485
|
+
*/
|
|
1486
|
+
function rehypeStripSvgContainers() {
|
|
1487
|
+
return (tree) => {
|
|
1488
|
+
stripSvgContainers(tree, false);
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* A paint value: a keyword, a named color, or a hex color. No parentheses, for
|
|
1493
|
+
* the same reason `SAFE_STYLE` refuses them — `fill="url(https://…)"` is a
|
|
1494
|
+
* render-time fetch, and `rgb()` is the price of closing it.
|
|
1495
|
+
*/
|
|
1496
|
+
const SVG_PAINT = /^(?:#[0-9a-f]{3,8}|[a-z]+)$/i;
|
|
1497
|
+
/**
|
|
1498
|
+
* Attributes any SVG element may carry, drawing and typography alike.
|
|
1499
|
+
*
|
|
1500
|
+
* **Each entry is a hast property name, which is not always the camel case of
|
|
1501
|
+
* the attribute.** `property-information` decides the spelling, and it has
|
|
1502
|
+
* `strokeLineCap` for `stroke-linecap` — a naive `strokeLinecap` matches
|
|
1503
|
+
* nothing, and the attribute is dropped in every renderer without a word. The
|
|
1504
|
+
* table-driven test in `frontend/src/lib/sanitize.test.ts` renders every entry
|
|
1505
|
+
* in its written spelling, so a misspelled one fails there.
|
|
1506
|
+
*
|
|
1507
|
+
* `stroke-dasharray` (and with it `stroke-dashoffset`, which does nothing
|
|
1508
|
+
* alone) is refused, because it lets a few bytes buy an unbounded paint. The
|
|
1509
|
+
* number of dashes is the path's length over the dash period, both in user
|
|
1510
|
+
* units the document chooses. Measured in headless Chromium, fifty 90-byte
|
|
1511
|
+
* paths with a 0.0011 dash took 21 s to paint and a hundred took 37 s. A value
|
|
1512
|
+
* grammar cannot bound it: refusing sub-unit dashes is undone by scaling the
|
|
1513
|
+
* user units, and the same fifty paths with `stroke-dasharray="1.1"` under a
|
|
1514
|
+
* `viewBox` a thousand times larger still took 15 s.
|
|
1515
|
+
*/
|
|
1516
|
+
const SVG_ATTRIBUTES = [
|
|
1517
|
+
["fill", SVG_PAINT],
|
|
1518
|
+
["stroke", SVG_PAINT],
|
|
1519
|
+
"fillOpacity",
|
|
1520
|
+
"fillRule",
|
|
1521
|
+
"strokeOpacity",
|
|
1522
|
+
"strokeWidth",
|
|
1523
|
+
"strokeLineCap",
|
|
1524
|
+
"strokeLineJoin",
|
|
1525
|
+
"opacity",
|
|
1526
|
+
"transform",
|
|
1527
|
+
"fontFamily",
|
|
1528
|
+
"fontSize",
|
|
1529
|
+
"fontWeight",
|
|
1530
|
+
"fontStyle",
|
|
1531
|
+
"letterSpacing",
|
|
1532
|
+
"textAnchor",
|
|
1533
|
+
"dominantBaseline",
|
|
1534
|
+
"x",
|
|
1535
|
+
"y",
|
|
1536
|
+
"x1",
|
|
1537
|
+
"y1",
|
|
1538
|
+
"x2",
|
|
1539
|
+
"y2",
|
|
1540
|
+
"cx",
|
|
1541
|
+
"cy",
|
|
1542
|
+
"r",
|
|
1543
|
+
"rx",
|
|
1544
|
+
"ry",
|
|
1545
|
+
"dx",
|
|
1546
|
+
"dy",
|
|
1547
|
+
"d",
|
|
1548
|
+
"points"
|
|
1549
|
+
];
|
|
1550
|
+
/**
|
|
1551
|
+
* The root `<svg>`'s attributes: `SVG_ATTRIBUTES` less `transform`, plus the
|
|
1552
|
+
* viewport, a `role` from a closed list, and the accessible name.
|
|
1553
|
+
*
|
|
1554
|
+
* `transform` on the root is not an SVG transform at all. The root is an
|
|
1555
|
+
* in-flow CSS box, and the attribute becomes a CSS transform of that box, so
|
|
1556
|
+
* `<svg width="10" height="10" transform="translate(-300 -300) scale(80)">`
|
|
1557
|
+
* laid out as a 10px box and painted as an 800px one over the paragraphs
|
|
1558
|
+
* around it. On a child it moves shapes inside the viewport, which clips them.
|
|
1559
|
+
*
|
|
1560
|
+
* Residual: that clip is the root's `overflow`, which `SAFE_STYLE` lets a
|
|
1561
|
+
* document set. `style="overflow:visible"` on the root lets a child with a
|
|
1562
|
+
* large `transform` or large coordinates paint over its neighbors — measured in
|
|
1563
|
+
* Chromium, the same overlap. It is the negative-`margin` residual described at
|
|
1564
|
+
* `SAFE_STYLE` by another route: it stays in the flow, scrolls with the content,
|
|
1565
|
+
* and the scroll container clips it.
|
|
1566
|
+
*/
|
|
1567
|
+
const SVG_ROOT_ATTRIBUTES = [
|
|
1568
|
+
...SVG_ATTRIBUTES.filter((attribute) => attribute !== "transform"),
|
|
1569
|
+
"xmlns",
|
|
1570
|
+
"viewBox",
|
|
1571
|
+
"preserveAspectRatio",
|
|
1572
|
+
[
|
|
1573
|
+
"role",
|
|
1574
|
+
"img",
|
|
1575
|
+
"presentation",
|
|
1576
|
+
"none"
|
|
1577
|
+
],
|
|
1578
|
+
"ariaLabel"
|
|
1579
|
+
];
|
|
1580
|
+
/** Every element the sanitizer admits. */
|
|
1581
|
+
const TAG_NAMES = [
|
|
1582
|
+
...rehype_sanitize.defaultSchema.tagNames || [],
|
|
1583
|
+
"math",
|
|
1584
|
+
"semantics",
|
|
1585
|
+
"mrow",
|
|
1586
|
+
"mi",
|
|
1587
|
+
"mo",
|
|
1588
|
+
"mn",
|
|
1589
|
+
"msup",
|
|
1590
|
+
"msub",
|
|
1591
|
+
"mfrac",
|
|
1592
|
+
"mover",
|
|
1593
|
+
"munder",
|
|
1594
|
+
"msqrt",
|
|
1595
|
+
"mroot",
|
|
1596
|
+
"mtable",
|
|
1597
|
+
"mtr",
|
|
1598
|
+
"mtd",
|
|
1599
|
+
"mtext",
|
|
1600
|
+
"mspace",
|
|
1601
|
+
"annotation",
|
|
1602
|
+
"figure",
|
|
1603
|
+
"figcaption",
|
|
1604
|
+
"summary",
|
|
1605
|
+
"details",
|
|
1606
|
+
"svg",
|
|
1607
|
+
...SVG_CHILD_TAGS
|
|
1608
|
+
];
|
|
1609
|
+
/**
|
|
1610
|
+
* The admitted elements that take no `style`: only `input`, which GFM emits as
|
|
1611
|
+
* a task list's checkbox, and never with a style.
|
|
1612
|
+
*
|
|
1613
|
+
* `styles/task-list.css` draws that checkbox and positions it, so on it `top`
|
|
1614
|
+
* and `left` are not the no-ops they are on an element nothing positions, and
|
|
1615
|
+
* with `width`, `height` and `background-color` beside them a document laid a
|
|
1616
|
+
* box of its own over the page. Measured in Chromium at 1280x800 while the
|
|
1617
|
+
* checkbox was positioned absolutely,
|
|
1618
|
+
* `top:-3000px;left:-3000px;width:9000px;height:9000px;background-color:white`
|
|
1619
|
+
* on it covered the whole content pane. It needed no class written:
|
|
1620
|
+
* GFM puts `task-list-item` on the item, and a checkbox typed into the item's
|
|
1621
|
+
* text is a child of that item too.
|
|
1622
|
+
*
|
|
1623
|
+
* **This is why `style` is on each element's own entry, never on `*`.**
|
|
1624
|
+
* `hast-util-sanitize` consults `*` whenever an element's own entry yields
|
|
1625
|
+
* nothing, so no entry on `input` could refuse a value that `*` admits. An
|
|
1626
|
+
* element takes `style` only by being in `TAG_NAMES` and not in this set.
|
|
1627
|
+
*/
|
|
1628
|
+
const UNSTYLED_TAGS = /* @__PURE__ */ new Set(["input"]);
|
|
1629
|
+
/**
|
|
1630
|
+
* `attributes`, with `["style", SAFE_STYLE]` added to the entry of every
|
|
1631
|
+
* element in `TAG_NAMES` but the `UNSTYLED_TAGS`.
|
|
1632
|
+
*/
|
|
1633
|
+
function withStyle(attributes) {
|
|
1634
|
+
const styled = { ...attributes };
|
|
1635
|
+
for (const tag of new Set(TAG_NAMES)) if (!UNSTYLED_TAGS.has(tag)) styled[tag] = [...styled[tag] ?? [], ["style", SAFE_STYLE]];
|
|
1636
|
+
return styled;
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
978
1639
|
* Never set `allowComments` here.
|
|
979
1640
|
*
|
|
980
1641
|
* `hast-util-sanitize` drops comment nodes because that boolean defaults to
|
|
@@ -988,38 +1649,16 @@ const COLLAPSE_GROUP_ID = /^[0-9]+$/;
|
|
|
988
1649
|
*/
|
|
989
1650
|
const sanitizeSchema = {
|
|
990
1651
|
...rehype_sanitize.defaultSchema,
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
"
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
"mn",
|
|
999
|
-
"msup",
|
|
1000
|
-
"msub",
|
|
1001
|
-
"mfrac",
|
|
1002
|
-
"mover",
|
|
1003
|
-
"munder",
|
|
1004
|
-
"msqrt",
|
|
1005
|
-
"mroot",
|
|
1006
|
-
"mtable",
|
|
1007
|
-
"mtr",
|
|
1008
|
-
"mtd",
|
|
1009
|
-
"mtext",
|
|
1010
|
-
"mspace",
|
|
1011
|
-
"annotation",
|
|
1012
|
-
"figure",
|
|
1013
|
-
"figcaption",
|
|
1014
|
-
"summary",
|
|
1015
|
-
"details"
|
|
1016
|
-
],
|
|
1017
|
-
attributes: {
|
|
1652
|
+
strip: [...rehype_sanitize.defaultSchema.strip || [], ...SVG_STRIPPED_EVERYWHERE],
|
|
1653
|
+
tagNames: TAG_NAMES,
|
|
1654
|
+
ancestors: {
|
|
1655
|
+
...rehype_sanitize.defaultSchema.ancestors,
|
|
1656
|
+
...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, ["svg"]]))
|
|
1657
|
+
},
|
|
1658
|
+
attributes: withStyle({
|
|
1018
1659
|
...rehype_sanitize.defaultSchema.attributes,
|
|
1019
1660
|
"*": [
|
|
1020
1661
|
...rehype_sanitize.defaultSchema.attributes?.["*"] || [],
|
|
1021
|
-
"className",
|
|
1022
|
-
["style", SAFE_STYLE],
|
|
1023
1662
|
"dataSourceLine",
|
|
1024
1663
|
["dataVantageTone", ...VANTAGE_TONES],
|
|
1025
1664
|
["dataVantageEmphasis", ...VANTAGE_EMPHASIS],
|
|
@@ -1029,35 +1668,22 @@ const sanitizeSchema = {
|
|
|
1029
1668
|
["dataVantageCollapseToggle", COLLAPSE_GROUP_ID],
|
|
1030
1669
|
["dataVantageRun", ...VANTAGE_RUNS],
|
|
1031
1670
|
["dataVantageOq", "true"],
|
|
1671
|
+
["dataVantageQuestion", "true"],
|
|
1032
1672
|
["dataVantageOqId", VANTAGE_OQ_ID],
|
|
1033
1673
|
["dataVantageAlert", ...VANTAGE_ALERTS],
|
|
1034
1674
|
"dataVantageLeaning"
|
|
1035
1675
|
],
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
...rehype_sanitize.defaultSchema.attributes?.span || [],
|
|
1039
|
-
"className",
|
|
1040
|
-
["style", SAFE_STYLE]
|
|
1041
|
-
],
|
|
1042
|
-
div: [
|
|
1043
|
-
...rehype_sanitize.defaultSchema.attributes?.div || [],
|
|
1044
|
-
"className",
|
|
1045
|
-
["style", SAFE_STYLE]
|
|
1046
|
-
],
|
|
1047
|
-
a: [
|
|
1048
|
-
...rehype_sanitize.defaultSchema.attributes?.a || [],
|
|
1049
|
-
"id",
|
|
1050
|
-
"className"
|
|
1051
|
-
],
|
|
1676
|
+
...Object.fromEntries(Object.keys(PIPELINE_CLASSES).map((tag) => [tag, withClasses(tag)])),
|
|
1677
|
+
a: withClasses("a", "id"),
|
|
1052
1678
|
math: ["xmlns"],
|
|
1053
1679
|
annotation: ["encoding"],
|
|
1054
1680
|
img: [...rehype_sanitize.defaultSchema.attributes?.img || [], "loading"],
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
}
|
|
1681
|
+
svg: SVG_ROOT_ATTRIBUTES,
|
|
1682
|
+
...Object.fromEntries(SVG_CHILD_TAGS.map((tag) => [tag, SVG_ATTRIBUTES]))
|
|
1683
|
+
})
|
|
1058
1684
|
};
|
|
1059
1685
|
//#endregion
|
|
1060
|
-
//#region src/
|
|
1686
|
+
//#region src/remarkPlugins.ts
|
|
1061
1687
|
/**
|
|
1062
1688
|
* The mdast half of the chain. Exported on its own because there is a real
|
|
1063
1689
|
* mdast-only consumer: the CLI checker parses documents without ever running
|
|
@@ -1071,6 +1697,8 @@ function buildRemarkPlugins(options = {}) {
|
|
|
1071
1697
|
if (math) plugins.push([remark_math.default, { singleDollarTextMath: false }]);
|
|
1072
1698
|
return plugins;
|
|
1073
1699
|
}
|
|
1700
|
+
//#endregion
|
|
1701
|
+
//#region src/pipeline.ts
|
|
1074
1702
|
/** The hast half. Deliberately not exported: see `buildPipeline`. */
|
|
1075
1703
|
function buildRehypePlugins(options = {}) {
|
|
1076
1704
|
const { math = true, highlight = true, sourceLines = true, sanitize = true, bodyLineOffset = 0 } = options;
|
|
@@ -1078,7 +1706,7 @@ function buildRehypePlugins(options = {}) {
|
|
|
1078
1706
|
if (sourceLines) plugins.push([rehypeSourceLines, { offset: bodyLineOffset }]);
|
|
1079
1707
|
plugins.push(rehypeVantageAlerts);
|
|
1080
1708
|
plugins.push(rehypeVantageDirectives);
|
|
1081
|
-
if (sanitize) plugins.push([rehype_sanitize.default, sanitizeSchema]);
|
|
1709
|
+
if (sanitize) plugins.push(rehypeStripSvgContainers, [rehype_sanitize.default, sanitizeSchema]);
|
|
1082
1710
|
plugins.push(rehypeVantageAnchors);
|
|
1083
1711
|
plugins.push(rehype_slug.default);
|
|
1084
1712
|
if (highlight) plugins.push(rehype_highlight.default);
|
|
@@ -1362,6 +1990,9 @@ const cacheKey = (code, theme) => `${theme}\u0000${code}`;
|
|
|
1362
1990
|
function getCachedSvg(code, theme = currentMermaidPalette()) {
|
|
1363
1991
|
return svgCache.get(cacheKey(code, theme));
|
|
1364
1992
|
}
|
|
1993
|
+
function hasCachedSvg(code, theme = currentMermaidPalette()) {
|
|
1994
|
+
return svgCache.has(cacheKey(code, theme));
|
|
1995
|
+
}
|
|
1365
1996
|
function setCachedSvg(code, svg, theme = currentMermaidPalette()) {
|
|
1366
1997
|
svgCache.set(cacheKey(code, theme), svg);
|
|
1367
1998
|
}
|
|
@@ -1374,6 +2005,43 @@ let mermaidLoading = null;
|
|
|
1374
2005
|
* configured for, `null` until loaded.
|
|
1375
2006
|
*/
|
|
1376
2007
|
let configuredTheme = null;
|
|
2008
|
+
/**
|
|
2009
|
+
* The configuration keys a diagram's own source may not set, from an `init`
|
|
2010
|
+
* directive or from frontmatter: Mermaid's default list, then the three
|
|
2011
|
+
* that write a diagram's stylesheet.
|
|
2012
|
+
*
|
|
2013
|
+
* **A diagram's stylesheet reaches past the diagram.** Mermaid puts every
|
|
2014
|
+
* selector of it under the diagram's id, but leaves the names of its
|
|
2015
|
+
* `@keyframes` global, so a diagram that defined `flash-update` redefined the
|
|
2016
|
+
* animation the viewer plays on a block that just changed. Given
|
|
2017
|
+
* `position: fixed` at the size of the window, the next flash laid that block,
|
|
2018
|
+
* and the link it held, over the header and the sidebar. The same stylesheet
|
|
2019
|
+
* fetched images from any host, which is why `SAFE_STYLE` refuses parentheses.
|
|
2020
|
+
*
|
|
2021
|
+
* - `themeCSS` is a stylesheet, and Mermaid checks only that its braces pair.
|
|
2022
|
+
* - `fontFamily` is copied into `themeVariables` *after* Mermaid's check on
|
|
2023
|
+
* theme variables has run, and lands in the stylesheet as a declaration's
|
|
2024
|
+
* value, where a nested block becomes a rule of its own.
|
|
2025
|
+
* - `altFontFamily` lands in a rule the browser parses first, which drops
|
|
2026
|
+
* what does not belong. It is refused anyway: nothing needs it, and that
|
|
2027
|
+
* parse is all that stands in the way.
|
|
2028
|
+
*
|
|
2029
|
+
* Mermaid applies the list at every depth, so `themeVariables.fontFamily` in
|
|
2030
|
+
* a diagram is refused too. The list is spelled whole rather than relying on
|
|
2031
|
+
* Mermaid merging it with its default. `frontend/e2e/mermaid.spec.ts` measures
|
|
2032
|
+
* all four routes; see "Mermaid" in `docs/reference/inline-markup.md`.
|
|
2033
|
+
*/
|
|
2034
|
+
const MERMAID_SECURE_KEYS = [
|
|
2035
|
+
"secure",
|
|
2036
|
+
"securityLevel",
|
|
2037
|
+
"startOnLoad",
|
|
2038
|
+
"maxTextSize",
|
|
2039
|
+
"suppressErrorRendering",
|
|
2040
|
+
"maxEdges",
|
|
2041
|
+
"themeCSS",
|
|
2042
|
+
"fontFamily",
|
|
2043
|
+
"altFontFamily"
|
|
2044
|
+
];
|
|
1377
2045
|
function configure(m, palette) {
|
|
1378
2046
|
const theme = mermaidThemeOf(palette);
|
|
1379
2047
|
m.initialize({
|
|
@@ -1381,7 +2049,8 @@ function configure(m, palette) {
|
|
|
1381
2049
|
theme,
|
|
1382
2050
|
themeVariables: mermaidThemeVariables(theme),
|
|
1383
2051
|
securityLevel: "strict",
|
|
1384
|
-
suppressErrorRendering: true
|
|
2052
|
+
suppressErrorRendering: true,
|
|
2053
|
+
secure: MERMAID_SECURE_KEYS
|
|
1385
2054
|
});
|
|
1386
2055
|
configuredTheme = palette;
|
|
1387
2056
|
}
|
|
@@ -1424,7 +2093,7 @@ const AlertTriangleIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg
|
|
|
1424
2093
|
strokeWidth: "2",
|
|
1425
2094
|
strokeLinecap: "round",
|
|
1426
2095
|
strokeLinejoin: "round",
|
|
1427
|
-
className: "w-4 h-4 shrink-0",
|
|
2096
|
+
className: "block w-4 h-4 shrink-0",
|
|
1428
2097
|
children: [
|
|
1429
2098
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }),
|
|
1430
2099
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 9v4" }),
|
|
@@ -1440,7 +2109,7 @@ const ChevronDownIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg",
|
|
|
1440
2109
|
strokeWidth: "2",
|
|
1441
2110
|
strokeLinecap: "round",
|
|
1442
2111
|
strokeLinejoin: "round",
|
|
1443
|
-
className: "w-3 h-3",
|
|
2112
|
+
className: "block w-3 h-3",
|
|
1444
2113
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 9 6 6 6-6" })
|
|
1445
2114
|
});
|
|
1446
2115
|
const ChevronUpIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
@@ -1452,7 +2121,7 @@ const ChevronUpIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
|
1452
2121
|
strokeWidth: "2",
|
|
1453
2122
|
strokeLinecap: "round",
|
|
1454
2123
|
strokeLinejoin: "round",
|
|
1455
|
-
className: "w-3 h-3",
|
|
2124
|
+
className: "block w-3 h-3",
|
|
1456
2125
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m18 15-6-6-6 6" })
|
|
1457
2126
|
});
|
|
1458
2127
|
const MaximizeIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
@@ -1464,7 +2133,7 @@ const MaximizeIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
|
1464
2133
|
strokeWidth: "2",
|
|
1465
2134
|
strokeLinecap: "round",
|
|
1466
2135
|
strokeLinejoin: "round",
|
|
1467
|
-
className: "w-4 h-4 text-gray-600 dark:text-slate-200",
|
|
2136
|
+
className: "block w-4 h-4 text-gray-600 dark:text-slate-200",
|
|
1468
2137
|
children: [
|
|
1469
2138
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("polyline", { points: "15 3 21 3 21 9" }),
|
|
1470
2139
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("polyline", { points: "9 21 3 21 3 15" }),
|
|
@@ -1491,7 +2160,7 @@ const CloseIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
|
1491
2160
|
strokeWidth: "2",
|
|
1492
2161
|
strokeLinecap: "round",
|
|
1493
2162
|
strokeLinejoin: "round",
|
|
1494
|
-
className: "w-5 h-5",
|
|
2163
|
+
className: "block w-5 h-5",
|
|
1495
2164
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 6 12 12" })]
|
|
1496
2165
|
});
|
|
1497
2166
|
const PlusIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
@@ -1503,7 +2172,7 @@ const PlusIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
|
1503
2172
|
strokeWidth: "2",
|
|
1504
2173
|
strokeLinecap: "round",
|
|
1505
2174
|
strokeLinejoin: "round",
|
|
1506
|
-
className: "w-4 h-4",
|
|
2175
|
+
className: "block w-4 h-4",
|
|
1507
2176
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12h14" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 5v14" })]
|
|
1508
2177
|
});
|
|
1509
2178
|
const MinusIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
@@ -1515,7 +2184,7 @@ const MinusIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
|
1515
2184
|
strokeWidth: "2",
|
|
1516
2185
|
strokeLinecap: "round",
|
|
1517
2186
|
strokeLinejoin: "round",
|
|
1518
|
-
className: "w-4 h-4",
|
|
2187
|
+
className: "block w-4 h-4",
|
|
1519
2188
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M5 12h14" })
|
|
1520
2189
|
});
|
|
1521
2190
|
const ResetIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
@@ -1527,7 +2196,7 @@ const ResetIcon = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
|
1527
2196
|
strokeWidth: "2",
|
|
1528
2197
|
strokeLinecap: "round",
|
|
1529
2198
|
strokeLinejoin: "round",
|
|
1530
|
-
className: "w-4 h-4",
|
|
2199
|
+
className: "block w-4 h-4",
|
|
1531
2200
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 3v5h5" })]
|
|
1532
2201
|
});
|
|
1533
2202
|
const MIN_SCALE = .2;
|
|
@@ -1796,8 +2465,9 @@ const MermaidDiagramInner = ({ code }) => {
|
|
|
1796
2465
|
});
|
|
1797
2466
|
}
|
|
1798
2467
|
} catch (err) {
|
|
2468
|
+
if (!mounted) return;
|
|
1799
2469
|
console.error("Mermaid render error:", err);
|
|
1800
|
-
|
|
2470
|
+
setFailure({
|
|
1801
2471
|
key,
|
|
1802
2472
|
message: extractErrorMessage(err)
|
|
1803
2473
|
});
|
|
@@ -2055,6 +2725,34 @@ function TagList({ items }) {
|
|
|
2055
2725
|
}, item))
|
|
2056
2726
|
});
|
|
2057
2727
|
}
|
|
2728
|
+
/**
|
|
2729
|
+
* An `OQ-…` token, shaped as `VANTAGE_OQ_ID` shapes an id and bounded as the
|
|
2730
|
+
* planning scan bounds one: by what may not touch it, so an id inside a longer
|
|
2731
|
+
* token (`OQ-B12x`, `xOQ-B1`) is not a token at all. The leading bound is a
|
|
2732
|
+
* group rather than a lookbehind, which Safari parses only since 16.4.
|
|
2733
|
+
*/
|
|
2734
|
+
const OQ_TOKEN = /(^|[^A-Za-z0-9])(OQ-(?:[A-Z][A-Z0-9]{0,5})?[0-9]+)(?![A-Za-z0-9])/g;
|
|
2735
|
+
/** `text`, with each token in `ids` as a link to its question. */
|
|
2736
|
+
function LinkedIds({ text, ids }) {
|
|
2737
|
+
const out = [];
|
|
2738
|
+
let last = 0;
|
|
2739
|
+
for (const match of text.matchAll(OQ_TOKEN)) {
|
|
2740
|
+
const id = match[2];
|
|
2741
|
+
if (id === void 0 || !ids.includes(id)) continue;
|
|
2742
|
+
const start = match.index + match[1].length;
|
|
2743
|
+
if (start > last) out.push(text.slice(last, start));
|
|
2744
|
+
out.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
|
|
2745
|
+
href: `#${id}`,
|
|
2746
|
+
children: id
|
|
2747
|
+
}, start));
|
|
2748
|
+
last = start + id.length;
|
|
2749
|
+
}
|
|
2750
|
+
if (last < text.length) out.push(text.slice(last));
|
|
2751
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2752
|
+
className: "font-medium",
|
|
2753
|
+
children: out
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2058
2756
|
function ValueCell({ value }) {
|
|
2059
2757
|
if (isStringArray(value) && value.length > 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TagList, { items: value });
|
|
2060
2758
|
if (isPlainObject(value)) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
|
|
@@ -2076,7 +2774,7 @@ function flattenEntries(entries) {
|
|
|
2076
2774
|
}
|
|
2077
2775
|
return result;
|
|
2078
2776
|
}
|
|
2079
|
-
const FrontmatterDisplayInner = ({ frontmatter }) => {
|
|
2777
|
+
const FrontmatterDisplayInner = ({ frontmatter, linkIds }) => {
|
|
2080
2778
|
const entries = flattenEntries(Object.entries(frontmatter));
|
|
2081
2779
|
const { statusChip } = readVantageFrontmatter(frontmatter);
|
|
2082
2780
|
if (entries.length === 0 && statusChip === void 0) return null;
|
|
@@ -2102,7 +2800,10 @@ const FrontmatterDisplayInner = ({ frontmatter }) => {
|
|
|
2102
2800
|
children: key
|
|
2103
2801
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
|
|
2104
2802
|
className: "py-2 text-slate-800 dark:text-slate-200 align-top",
|
|
2105
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
2803
|
+
children: key === "next" && linkIds !== void 0 && typeof value === "string" && !/[\r\n]/.test(value.trim()) && value === frontmatter["next"] ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LinkedIds, {
|
|
2804
|
+
text: value,
|
|
2805
|
+
ids: linkIds
|
|
2806
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ValueCell, { value })
|
|
2106
2807
|
})]
|
|
2107
2808
|
}, key)) })
|
|
2108
2809
|
})
|
|
@@ -2378,17 +3079,16 @@ const MarkdownViewerInner = ({ content, currentPath = "", hash = "", baseUrl = "
|
|
|
2378
3079
|
imageApiBase
|
|
2379
3080
|
]);
|
|
2380
3081
|
const markdownComponents = (0, react.useMemo)(() => ({
|
|
2381
|
-
a({ href, children, ...props }) {
|
|
2382
|
-
const resolvedHref = resolveHref(href);
|
|
3082
|
+
a({ node, href, children, ...props }) {
|
|
2383
3083
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
|
|
2384
|
-
href:
|
|
3084
|
+
href: href === void 0 ? void 0 : resolveHref(href),
|
|
2385
3085
|
onClick: (e) => href && handleLinkClick(e, href),
|
|
2386
3086
|
...props,
|
|
2387
3087
|
children
|
|
2388
3088
|
});
|
|
2389
3089
|
},
|
|
2390
3090
|
code(props) {
|
|
2391
|
-
const { children, className: codeClassName, ...rest } = props;
|
|
3091
|
+
const { node, children, className: codeClassName, ...rest } = props;
|
|
2392
3092
|
const match = /language-(\w+)/.exec(codeClassName || "");
|
|
2393
3093
|
if (match && match[1] === "mermaid") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MermaidDiagram, { code: String(children).replace(/\n$/, "") });
|
|
2394
3094
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
|
|
@@ -2432,61 +3132,8 @@ const MarkdownViewerInner = ({ content, currentPath = "", hash = "", baseUrl = "
|
|
|
2432
3132
|
};
|
|
2433
3133
|
const MarkdownViewer = (0, react.memo)(MarkdownViewerInner, (prevProps, nextProps) => prevProps.content === nextProps.content && prevProps.currentPath === nextProps.currentPath && prevProps.hash === nextProps.hash && prevProps.className === nextProps.className);
|
|
2434
3134
|
//#endregion
|
|
2435
|
-
//#region src/renderMarkdown.ts
|
|
2436
|
-
/**
|
|
2437
|
-
* Render a markdown string to HTML using the full Vantage pipeline.
|
|
2438
|
-
*
|
|
2439
|
-
* Features (all enabled by default):
|
|
2440
|
-
* - GitHub Flavored Markdown (tables, strikethrough, task lists)
|
|
2441
|
-
* - KaTeX math rendering, inline and block ($$...$$ only; single $ is not a delimiter)
|
|
2442
|
-
* - Syntax highlighting via highlight.js
|
|
2443
|
-
* - `data-source-line` attributes for line anchors
|
|
2444
|
-
* - XSS sanitization
|
|
2445
|
-
* - Heading slugs/anchors
|
|
2446
|
-
* - YAML/TOML frontmatter parsing
|
|
2447
|
-
*
|
|
2448
|
-
* Mermaid diagrams are NOT rendered server-side (they require a browser).
|
|
2449
|
-
* Mermaid code blocks are preserved as `<pre><code class="language-mermaid">`.
|
|
2450
|
-
* Use the React `<MarkdownViewer>` component for client-side mermaid rendering.
|
|
2451
|
-
*/
|
|
2452
|
-
async function renderMarkdown(content, options = {}) {
|
|
2453
|
-
const { gfm = true, math = true, highlight = true, sourceLines = true, sanitize = true, frontmatter: parseFm = true, tree } = options;
|
|
2454
|
-
let parsed;
|
|
2455
|
-
if (parseFm) parsed = parseFrontmatter(content);
|
|
2456
|
-
else parsed = {
|
|
2457
|
-
frontmatter: {},
|
|
2458
|
-
body: content,
|
|
2459
|
-
format: "none",
|
|
2460
|
-
bodyLineOffset: 0
|
|
2461
|
-
};
|
|
2462
|
-
const { remarkPlugins, rehypePlugins } = buildPipeline({
|
|
2463
|
-
gfm,
|
|
2464
|
-
math,
|
|
2465
|
-
highlight,
|
|
2466
|
-
sourceLines,
|
|
2467
|
-
sanitize,
|
|
2468
|
-
bodyLineOffset: parsed.bodyLineOffset
|
|
2469
|
-
});
|
|
2470
|
-
const processor = (0, unified.unified)().use(remark_parse.default).use(remarkPlugins).use(remark_rehype.default, { allowDangerousHtml: true }).use(rehypePlugins).use(rehype_stringify.default);
|
|
2471
|
-
const result = tree === void 0 ? String(await processor.process(parsed.body)) : processor.stringify(await processor.run(tree, parsed.body), parsed.body);
|
|
2472
|
-
return {
|
|
2473
|
-
html: String(result),
|
|
2474
|
-
frontmatter: parsed.frontmatter,
|
|
2475
|
-
body: parsed.body
|
|
2476
|
-
};
|
|
2477
|
-
}
|
|
2478
|
-
//#endregion
|
|
2479
3135
|
//#region src/renderMermaidBlocks.ts
|
|
2480
3136
|
/**
|
|
2481
|
-
* Client-side utility to find and render mermaid code blocks in a container.
|
|
2482
|
-
*
|
|
2483
|
-
* After calling `renderMarkdown()`, mermaid blocks come through as
|
|
2484
|
-
* `<pre><code class="language-mermaid">...</code></pre>`. This function
|
|
2485
|
-
* finds those blocks and replaces them with rendered SVG diagrams.
|
|
2486
|
-
*
|
|
2487
|
-
* Framework-agnostic — works in any browser environment.
|
|
2488
|
-
*/
|
|
2489
|
-
/**
|
|
2490
3137
|
* Find all `<pre><code class="language-mermaid">` blocks in a container
|
|
2491
3138
|
* and replace them with rendered SVG diagrams.
|
|
2492
3139
|
*
|
|
@@ -2567,6 +3214,104 @@ function replaceWithSvg(preEl, svg, className) {
|
|
|
2567
3214
|
wrapper.innerHTML = svg;
|
|
2568
3215
|
preEl.replaceWith(wrapper);
|
|
2569
3216
|
}
|
|
3217
|
+
/** The viewer's parser: the remark half of `buildPipeline`. */
|
|
3218
|
+
const fenceParser = (0, unified.unified)().use(remark_parse.default).use(buildRemarkPlugins());
|
|
3219
|
+
/**
|
|
3220
|
+
* The code of every Mermaid fence in `markdown`, in source order, exactly as
|
|
3221
|
+
* a viewer's `MermaidDiagram` receives it: the fence's content, without its
|
|
3222
|
+
* container's indentation or its closing newline.
|
|
3223
|
+
*
|
|
3224
|
+
* A fence is a diagram when its info string's first word begins with the word
|
|
3225
|
+
* `mermaid`, which is the test the viewer applies to the `language-…` class
|
|
3226
|
+
* the fence renders with (`/language-(\w+)/`). Text that never says
|
|
3227
|
+
* `mermaid` is not parsed at all.
|
|
3228
|
+
*/
|
|
3229
|
+
function mermaidFences(markdown) {
|
|
3230
|
+
if (!markdown.includes("mermaid")) return [];
|
|
3231
|
+
const codes = [];
|
|
3232
|
+
(0, unist_util_visit.visit)(fenceParser.parse(markdown), "code", (node) => {
|
|
3233
|
+
if (/^\w+/.exec(node.lang ?? "")?.[0] === "mermaid") codes.push(node.value);
|
|
3234
|
+
});
|
|
3235
|
+
return codes;
|
|
3236
|
+
}
|
|
3237
|
+
/** Draws under way, by palette and code, so asking twice draws once. */
|
|
3238
|
+
const drawing = /* @__PURE__ */ new Map();
|
|
3239
|
+
let drawn = 0;
|
|
3240
|
+
/**
|
|
3241
|
+
* Draw one diagram into the SVG cache that `MermaidDiagram` reads when it
|
|
3242
|
+
* mounts, so a diagram rendered after this has settled is drawn at its full
|
|
3243
|
+
* size on its first paint instead of growing into place once Mermaid answers.
|
|
3244
|
+
* The planning page draws every diagram of the cards it is about to show this
|
|
3245
|
+
* way, before it commits them.
|
|
3246
|
+
*
|
|
3247
|
+
* Mermaid is loaded through `getMermaid`, so the drawing is configured, and
|
|
3248
|
+
* made safe, exactly as every other diagram of the page is. `theme` is the
|
|
3249
|
+
* palette key (`currentMermaidPalette`) to draw for; when the page has moved
|
|
3250
|
+
* to another palette by the time Mermaid is ready, nothing is cached, since
|
|
3251
|
+
* Mermaid now draws for the page's palette and not for `theme`.
|
|
3252
|
+
*
|
|
3253
|
+
* Resolves once the diagram is in the cache, at once when it already was, and
|
|
3254
|
+
* rejects when Mermaid cannot draw it.
|
|
3255
|
+
*/
|
|
3256
|
+
function prerenderMermaid(code, theme = currentMermaidPalette()) {
|
|
3257
|
+
if (!code.trim() || hasCachedSvg(code, theme)) return Promise.resolve();
|
|
3258
|
+
const key = `${theme}\u0000${code}`;
|
|
3259
|
+
let pending = drawing.get(key);
|
|
3260
|
+
if (pending === void 0) {
|
|
3261
|
+
pending = (async () => {
|
|
3262
|
+
const mermaid = await getMermaid();
|
|
3263
|
+
if (currentMermaidPalette() !== theme) return;
|
|
3264
|
+
const { svg } = await mermaid.render(`mermaid-ahead-${(++drawn).toString(36)}`, code);
|
|
3265
|
+
setCachedSvg(code, svg, theme);
|
|
3266
|
+
})().finally(() => drawing.delete(key));
|
|
3267
|
+
drawing.set(key, pending);
|
|
3268
|
+
}
|
|
3269
|
+
return pending;
|
|
3270
|
+
}
|
|
3271
|
+
//#endregion
|
|
3272
|
+
//#region src/renderMarkdown.ts
|
|
3273
|
+
/**
|
|
3274
|
+
* Render a markdown string to HTML using the full Vantage pipeline.
|
|
3275
|
+
*
|
|
3276
|
+
* Features (all enabled by default):
|
|
3277
|
+
* - GitHub Flavored Markdown (tables, strikethrough, task lists)
|
|
3278
|
+
* - KaTeX math rendering, inline and block ($$...$$ only; single $ is not a delimiter)
|
|
3279
|
+
* - Syntax highlighting via highlight.js
|
|
3280
|
+
* - `data-source-line` attributes for line anchors
|
|
3281
|
+
* - XSS sanitization
|
|
3282
|
+
* - Heading slugs/anchors
|
|
3283
|
+
* - YAML/TOML frontmatter parsing
|
|
3284
|
+
*
|
|
3285
|
+
* Mermaid diagrams are NOT rendered server-side (they require a browser).
|
|
3286
|
+
* Mermaid code blocks are preserved as `<pre><code class="language-mermaid">`.
|
|
3287
|
+
* Use the React `<MarkdownViewer>` component for client-side mermaid rendering.
|
|
3288
|
+
*/
|
|
3289
|
+
async function renderMarkdown(content, options = {}) {
|
|
3290
|
+
const { gfm = true, math = true, highlight = true, sourceLines = true, sanitize = true, frontmatter: parseFm = true, tree } = options;
|
|
3291
|
+
let parsed;
|
|
3292
|
+
if (parseFm) parsed = parseFrontmatter(content);
|
|
3293
|
+
else parsed = {
|
|
3294
|
+
frontmatter: {},
|
|
3295
|
+
body: content,
|
|
3296
|
+
format: "none",
|
|
3297
|
+
bodyLineOffset: 0
|
|
3298
|
+
};
|
|
3299
|
+
const { remarkPlugins, rehypePlugins } = buildPipeline({
|
|
3300
|
+
gfm,
|
|
3301
|
+
math,
|
|
3302
|
+
highlight,
|
|
3303
|
+
sourceLines,
|
|
3304
|
+
sanitize,
|
|
3305
|
+
bodyLineOffset: parsed.bodyLineOffset
|
|
3306
|
+
});
|
|
3307
|
+
const processor = (0, unified.unified)().use(remark_parse.default).use(remarkPlugins).use(remark_rehype.default, { allowDangerousHtml: true }).use(rehypePlugins).use(rehype_stringify.default);
|
|
3308
|
+
const result = tree === void 0 ? String(await processor.process(parsed.body)) : processor.stringify(await processor.run(tree, parsed.body), parsed.body);
|
|
3309
|
+
return {
|
|
3310
|
+
html: String(result),
|
|
3311
|
+
frontmatter: parsed.frontmatter,
|
|
3312
|
+
body: parsed.body
|
|
3313
|
+
};
|
|
3314
|
+
}
|
|
2570
3315
|
//#endregion
|
|
2571
3316
|
//#region src/resolveLinks.ts
|
|
2572
3317
|
/**
|
|
@@ -2623,8 +3368,10 @@ exports.buildPipeline = buildPipeline;
|
|
|
2623
3368
|
exports.buildRemarkPlugins = buildRemarkPlugins;
|
|
2624
3369
|
exports.clearLineAnchorHighlights = clearLineAnchorHighlights;
|
|
2625
3370
|
exports.isDocStatus = isDocStatus;
|
|
3371
|
+
exports.mermaidFences = mermaidFences;
|
|
2626
3372
|
exports.parseFrontmatter = parseFrontmatter;
|
|
2627
3373
|
exports.parseLineAnchor = parseLineAnchor;
|
|
3374
|
+
exports.prerenderMermaid = prerenderMermaid;
|
|
2628
3375
|
exports.readVantageFrontmatter = readVantageFrontmatter;
|
|
2629
3376
|
exports.rehypeSourceLines = rehypeSourceLines;
|
|
2630
3377
|
exports.renderMarkdown = renderMarkdown;
|