@mulmoclaude/markdown-utils 1.3.5 → 2.0.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/dist/dom/adoptSvg.d.ts +18 -0
- package/dist/dom/adoptSvg.js +28 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/markdown/mathExtension.d.ts +34 -0
- package/dist/markdown/mathExtension.js +195 -0
- package/dist/markdown/mathRender.d.ts +46 -0
- package/dist/markdown/mathRender.js +258 -0
- package/dist/markdown/mermaidRender.d.ts +0 -17
- package/dist/markdown/mermaidRender.js +1 -24
- package/package.json +9 -6
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Parse a markup string with DOMParser in HTML5 mode and hand back its
|
|
2
|
+
* `<body>`, for callers that then pick nodes out of it and import them.
|
|
3
|
+
*
|
|
4
|
+
* Parsing instead of assigning to `.innerHTML` satisfies opengrep's XSS
|
|
5
|
+
* heuristic that flags every raw `innerHTML =`; callers are expected to
|
|
6
|
+
* have produced or sanitised the markup themselves.
|
|
7
|
+
*
|
|
8
|
+
* HTML5 mode (not `image/svg+xml`) is required: mermaid's SVG contains
|
|
9
|
+
* `<foreignObject>` wrappers with nested HTML content for labels
|
|
10
|
+
* (line-broken text via `<br>`, `<div>`, etc.), which is well-formed
|
|
11
|
+
* HTML5 but NOT well-formed XML — the XML parser drops a
|
|
12
|
+
* `<parsererror>` root and refuses. HTML5 mode treats `<svg>` and
|
|
13
|
+
* `<math>` as foreign-namespace roots and correctly parses the mixed
|
|
14
|
+
* subtree. */
|
|
15
|
+
export declare function parseMarkupBody(markup: string): HTMLElement;
|
|
16
|
+
/** Adopt an SVG markup string into a live DOM node. Returns null when
|
|
17
|
+
* the markup has no `<svg>` root. */
|
|
18
|
+
export declare function adoptSvg(svgMarkup: string): SVGElement | null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Shared by every renderer that receives markup as a *string* (mermaid
|
|
2
|
+
// diagrams, MathJax formulas) and has to put it into the live DOM.
|
|
3
|
+
/** Parse a markup string with DOMParser in HTML5 mode and hand back its
|
|
4
|
+
* `<body>`, for callers that then pick nodes out of it and import them.
|
|
5
|
+
*
|
|
6
|
+
* Parsing instead of assigning to `.innerHTML` satisfies opengrep's XSS
|
|
7
|
+
* heuristic that flags every raw `innerHTML =`; callers are expected to
|
|
8
|
+
* have produced or sanitised the markup themselves.
|
|
9
|
+
*
|
|
10
|
+
* HTML5 mode (not `image/svg+xml`) is required: mermaid's SVG contains
|
|
11
|
+
* `<foreignObject>` wrappers with nested HTML content for labels
|
|
12
|
+
* (line-broken text via `<br>`, `<div>`, etc.), which is well-formed
|
|
13
|
+
* HTML5 but NOT well-formed XML — the XML parser drops a
|
|
14
|
+
* `<parsererror>` root and refuses. HTML5 mode treats `<svg>` and
|
|
15
|
+
* `<math>` as foreign-namespace roots and correctly parses the mixed
|
|
16
|
+
* subtree. */
|
|
17
|
+
export function parseMarkupBody(markup) {
|
|
18
|
+
return new DOMParser().parseFromString(markup, "text/html").body;
|
|
19
|
+
}
|
|
20
|
+
/** Adopt an SVG markup string into a live DOM node. Returns null when
|
|
21
|
+
* the markup has no `<svg>` root. */
|
|
22
|
+
export function adoptSvg(svgMarkup) {
|
|
23
|
+
// `<svg>` at the top level lands under `body` in HTML5 parsing.
|
|
24
|
+
const svgEl = parseMarkupBody(svgMarkup).querySelector("svg");
|
|
25
|
+
if (!svgEl)
|
|
26
|
+
return null;
|
|
27
|
+
return document.importNode(svgEl, true);
|
|
28
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,3 +12,6 @@ export * from "./image/resolve.js";
|
|
|
12
12
|
export * from "./image/rewriteMarkdownImageRefs.js";
|
|
13
13
|
export * from "./markdown/mermaidRender.js";
|
|
14
14
|
export * from "./markdown/mermaidExtension.js";
|
|
15
|
+
export * from "./markdown/mathExtension.js";
|
|
16
|
+
export * from "./markdown/mathRender.js";
|
|
17
|
+
export * from "./dom/adoptSvg.js";
|
package/dist/index.js
CHANGED
|
@@ -12,3 +12,6 @@ export * from "./image/resolve.js";
|
|
|
12
12
|
export * from "./image/rewriteMarkdownImageRefs.js";
|
|
13
13
|
export * from "./markdown/mermaidRender.js";
|
|
14
14
|
export * from "./markdown/mermaidExtension.js";
|
|
15
|
+
export * from "./markdown/mathExtension.js";
|
|
16
|
+
export * from "./markdown/mathRender.js";
|
|
17
|
+
export * from "./dom/adoptSvg.js";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { MarkedExtension } from "marked";
|
|
2
|
+
/** Index of the first `$` in `src` that could legally open math, or
|
|
3
|
+
* `undefined` when there is none. Marked uses this to cut the
|
|
4
|
+
* preceding text token, so returning a position is what gets the
|
|
5
|
+
* tokenizer invoked there at all. */
|
|
6
|
+
export declare function findMathStart(src: string): number | undefined;
|
|
7
|
+
/** True when `body` passes the strict inline-`$` rules 2-5. `after` is
|
|
8
|
+
* the character following the closing delimiter (empty at end of
|
|
9
|
+
* input). */
|
|
10
|
+
export declare function isPlausibleInlineMath(body: string, after: string): boolean;
|
|
11
|
+
/** Placeholder `mathRender.ts` looks for. The TeX source rides in the
|
|
12
|
+
* element's text content — DOMPurify preserves text verbatim, and
|
|
13
|
+
* reading it back out of the DOM decodes the entities natively, so no
|
|
14
|
+
* manual unescaping is needed downstream.
|
|
15
|
+
*
|
|
16
|
+
* `block` picks the wrapper element and is independent of `display`:
|
|
17
|
+
* a `$$…$$` sitting mid-sentence typesets in display mode but must
|
|
18
|
+
* still emit a `<span>`, because marked has it inside a `<p>` and a
|
|
19
|
+
* `<div>` there is invalid nesting the browser silently reflows. */
|
|
20
|
+
export declare function mathPlaceholder(tex: string, opts: {
|
|
21
|
+
display: boolean;
|
|
22
|
+
block: boolean;
|
|
23
|
+
}): string;
|
|
24
|
+
/** Index of the first `$$` that begins a line (up to 3 leading spaces),
|
|
25
|
+
* or `undefined`. A block-level `start()` truncates the paragraph
|
|
26
|
+
* marked is accumulating, so pointing it at a mid-sentence `$$…$$`
|
|
27
|
+
* would split the prose around an inline formula. Only a line-leading
|
|
28
|
+
* `$$` can possibly satisfy the block tokenizer, so only those are
|
|
29
|
+
* worth stopping for — mid-paragraph display math is picked up by the
|
|
30
|
+
* inline extension instead. */
|
|
31
|
+
export declare function findMathBlockStart(src: string): number | undefined;
|
|
32
|
+
/** Register with `marked.use(mathExtension)`. Pair with
|
|
33
|
+
* `renderMathNodes()` from `mathRender.js` on the injected DOM. */
|
|
34
|
+
export declare const mathExtension: MarkedExtension;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Marked extension that turns TeX math into placeholder elements. The
|
|
2
|
+
// actual typesetting is deferred to `mathRender.ts`, which scans the
|
|
3
|
+
// placeholders in the DOM after Vue's v-html injects the html — the
|
|
4
|
+
// same two-step split `mermaidExtension.ts` / `mermaidRender.ts` use,
|
|
5
|
+
// and for the same two reasons: this file stays pure (no runtime dep
|
|
6
|
+
// beyond `marked` and the zero-dep `@mulmoclaude/common` leaf) so the
|
|
7
|
+
// html shape is testable without a browser, and the MathJax runtime
|
|
8
|
+
// stays out of the initial bundle.
|
|
9
|
+
//
|
|
10
|
+
// Why MathJax SVG and not KaTeX:
|
|
11
|
+
// - The rendered output is a self-contained `<svg>` using
|
|
12
|
+
// `currentColor` — no stylesheet and no webfont files to ship in
|
|
13
|
+
// the published npm tarball. KaTeX's html output needs
|
|
14
|
+
// `katex.min.css` plus ~60 woff2 files.
|
|
15
|
+
// - Marp decks already render math through marp-core, which uses
|
|
16
|
+
// MathJax SVG by default. Rendering the plain-markdown preview the
|
|
17
|
+
// same way keeps one document from looking like two.
|
|
18
|
+
// - KaTeX's `mathml` output survives `sanitizeMarkdownHtml` badly:
|
|
19
|
+
// DOMPurify drops `<semantics>`/`<annotation>` but keeps the
|
|
20
|
+
// annotation's TEXT, so the raw LaTeX source lands next to the
|
|
21
|
+
// formula as visible garbage.
|
|
22
|
+
//
|
|
23
|
+
// Delimiter rules. `$` is a currency symbol far more often than it is
|
|
24
|
+
// a math delimiter, so the inline form is deliberately strict — the
|
|
25
|
+
// Pandoc rule set, which is what stops `$100 と $200` from swallowing
|
|
26
|
+
// the prose between two prices:
|
|
27
|
+
//
|
|
28
|
+
// 1. The opening `$` must not be preceded by an ASCII alphanumeric
|
|
29
|
+
// (`US$5`) or by a backslash (`\$`, an escaped literal).
|
|
30
|
+
// 2. The character AFTER the opening `$` must not be whitespace.
|
|
31
|
+
// 3. The character BEFORE the closing `$` must not be whitespace,
|
|
32
|
+
// and must not be a backslash.
|
|
33
|
+
// 4. The character AFTER the closing `$` must not be an ASCII digit
|
|
34
|
+
// (`$5-$10`).
|
|
35
|
+
// 5. The body must be non-empty, single-line, and must not consist
|
|
36
|
+
// only of digits and separators (`$1,000$`).
|
|
37
|
+
//
|
|
38
|
+
// Rules 2-5 are enforced in the tokenizer, where the whole match is in
|
|
39
|
+
// hand. Rule 1 needs the character BEFORE the match, which a marked
|
|
40
|
+
// inline tokenizer never sees — its `src` always begins at the current
|
|
41
|
+
// cursor. It is enforced in `start()` instead, which scans forward
|
|
42
|
+
// through the remaining source and therefore does have the preceding
|
|
43
|
+
// character for every `$` except one sitting at index 0 of the
|
|
44
|
+
// remainder (only reachable when the previous inline token ended
|
|
45
|
+
// exactly there). Best-effort by construction; the four tokenizer
|
|
46
|
+
// rules carry the rest.
|
|
47
|
+
//
|
|
48
|
+
// `$$…$$` is unambiguous, so display math is not subject to rules 1-4.
|
|
49
|
+
import { escapeHtml } from "@mulmoclaude/common";
|
|
50
|
+
const ASCII_ALNUM = /[A-Za-z0-9]/;
|
|
51
|
+
const ASCII_DIGIT = /\d/;
|
|
52
|
+
/** Digits, separators and currency-ish punctuation only — `$1,000$`
|
|
53
|
+
* is a price range, not an equation. */
|
|
54
|
+
const NUMERIC_ONLY = /^[\s\d.,:;%+-]*$/;
|
|
55
|
+
/** Index of the first `$` in `src` that could legally open math, or
|
|
56
|
+
* `undefined` when there is none. Marked uses this to cut the
|
|
57
|
+
* preceding text token, so returning a position is what gets the
|
|
58
|
+
* tokenizer invoked there at all. */
|
|
59
|
+
export function findMathStart(src) {
|
|
60
|
+
let from = 0;
|
|
61
|
+
for (;;) {
|
|
62
|
+
const index = src.indexOf("$", from);
|
|
63
|
+
if (index < 0)
|
|
64
|
+
return undefined;
|
|
65
|
+
const prev = index === 0 ? "" : (src[index - 1] ?? "");
|
|
66
|
+
// Rule 1: not `US$5`, not an escaped `\$`.
|
|
67
|
+
if (prev !== "\\" && !ASCII_ALNUM.test(prev))
|
|
68
|
+
return index;
|
|
69
|
+
from = index + 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** True when `body` passes the strict inline-`$` rules 2-5. `after` is
|
|
73
|
+
* the character following the closing delimiter (empty at end of
|
|
74
|
+
* input). */
|
|
75
|
+
export function isPlausibleInlineMath(body, after) {
|
|
76
|
+
if (body.length === 0)
|
|
77
|
+
return false;
|
|
78
|
+
if (body.includes("\n"))
|
|
79
|
+
return false;
|
|
80
|
+
// Rule 2 / 3: no whitespace hugging either delimiter.
|
|
81
|
+
if (/^\s/.test(body) || /\s$/.test(body))
|
|
82
|
+
return false;
|
|
83
|
+
// Rule 3, second half: `\` before the closing `$` escapes it.
|
|
84
|
+
if (body.endsWith("\\"))
|
|
85
|
+
return false;
|
|
86
|
+
// Rule 4: `$5-$10`.
|
|
87
|
+
if (ASCII_DIGIT.test(after))
|
|
88
|
+
return false;
|
|
89
|
+
// Rule 5.
|
|
90
|
+
if (NUMERIC_ONLY.test(body))
|
|
91
|
+
return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
/** Placeholder `mathRender.ts` looks for. The TeX source rides in the
|
|
95
|
+
* element's text content — DOMPurify preserves text verbatim, and
|
|
96
|
+
* reading it back out of the DOM decodes the entities natively, so no
|
|
97
|
+
* manual unescaping is needed downstream.
|
|
98
|
+
*
|
|
99
|
+
* `block` picks the wrapper element and is independent of `display`:
|
|
100
|
+
* a `$$…$$` sitting mid-sentence typesets in display mode but must
|
|
101
|
+
* still emit a `<span>`, because marked has it inside a `<p>` and a
|
|
102
|
+
* `<div>` there is invalid nesting the browser silently reflows. */
|
|
103
|
+
export function mathPlaceholder(tex, opts) {
|
|
104
|
+
const escaped = escapeHtml(tex);
|
|
105
|
+
const flag = opts.display ? "1" : "0";
|
|
106
|
+
const attrs = `data-math-pending="1" data-math-display="${flag}"`;
|
|
107
|
+
if (opts.block)
|
|
108
|
+
return `<div class="math-block" ${attrs}>${escaped}</div>\n`;
|
|
109
|
+
return `<span class="math-inline" ${attrs}>${escaped}</span>`;
|
|
110
|
+
}
|
|
111
|
+
// `$$…$$` occupying its own line(s). Registered at block level so it
|
|
112
|
+
// does not end up wrapped in a paragraph with the surrounding prose.
|
|
113
|
+
const BLOCK_FENCED = /^ {0,3}\$\$[ \t]*\n([\s\S]*?)\n {0,3}\$\$[ \t]*(?:\n+|$)/;
|
|
114
|
+
const BLOCK_ONE_LINE = /^ {0,3}\$\$([^\n]+?)\$\$[ \t]*(?:\n+|$)/;
|
|
115
|
+
/** Index of the first `$$` that begins a line (up to 3 leading spaces),
|
|
116
|
+
* or `undefined`. A block-level `start()` truncates the paragraph
|
|
117
|
+
* marked is accumulating, so pointing it at a mid-sentence `$$…$$`
|
|
118
|
+
* would split the prose around an inline formula. Only a line-leading
|
|
119
|
+
* `$$` can possibly satisfy the block tokenizer, so only those are
|
|
120
|
+
* worth stopping for — mid-paragraph display math is picked up by the
|
|
121
|
+
* inline extension instead. */
|
|
122
|
+
export function findMathBlockStart(src) {
|
|
123
|
+
const match = /(^|\n) {0,3}\$\$/.exec(src);
|
|
124
|
+
if (!match)
|
|
125
|
+
return undefined;
|
|
126
|
+
// Point at the `$$` itself, not at the newline that precedes it.
|
|
127
|
+
return match.index + match[0].length - 2;
|
|
128
|
+
}
|
|
129
|
+
/** Type predicate rather than a cast: marked hands renderers a bare
|
|
130
|
+
* `Tokens.Generic`, and both extensions here are the only producers of
|
|
131
|
+
* these two token types, so the shape is checked once at the boundary
|
|
132
|
+
* instead of asserted. */
|
|
133
|
+
function isMathToken(token) {
|
|
134
|
+
return typeof token.text === "string" && typeof token.display === "boolean";
|
|
135
|
+
}
|
|
136
|
+
const mathBlock = {
|
|
137
|
+
name: "mathBlock",
|
|
138
|
+
level: "block",
|
|
139
|
+
start: findMathBlockStart,
|
|
140
|
+
tokenizer(src) {
|
|
141
|
+
const match = BLOCK_FENCED.exec(src) ?? BLOCK_ONE_LINE.exec(src);
|
|
142
|
+
const text = match?.[1]?.trim();
|
|
143
|
+
if (!match || text === undefined)
|
|
144
|
+
return undefined;
|
|
145
|
+
if (text.length === 0)
|
|
146
|
+
return undefined;
|
|
147
|
+
return { type: "mathBlock", raw: match[0], text, display: true };
|
|
148
|
+
},
|
|
149
|
+
renderer(token) {
|
|
150
|
+
if (!isMathToken(token))
|
|
151
|
+
return "";
|
|
152
|
+
return mathPlaceholder(token.text, { display: true, block: true });
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
// `$$…$$` inside a paragraph (display mode) and `$…$` (inline mode).
|
|
156
|
+
const INLINE_DISPLAY = /^\$\$([^\n]+?)\$\$/;
|
|
157
|
+
// The body alternates an escaped dollar against any other non-`$`
|
|
158
|
+
// character, rather than excluding `$` outright: an escaped dollar has
|
|
159
|
+
// to be consumed as ONE unit, or the first `\$` is taken for the closing
|
|
160
|
+
// delimiter and a legitimate `$\text{Cost: \$5}$` is cut at the
|
|
161
|
+
// backslash — where `isPlausibleInlineMath` then rejects it for ending
|
|
162
|
+
// in one. The escape branch is ordered first so it wins the match.
|
|
163
|
+
const INLINE_PLAIN = /^\$((?:\\\$|[^\n$])+?)\$/;
|
|
164
|
+
const mathInline = {
|
|
165
|
+
name: "mathInline",
|
|
166
|
+
level: "inline",
|
|
167
|
+
start: findMathStart,
|
|
168
|
+
tokenizer(src) {
|
|
169
|
+
const display = INLINE_DISPLAY.exec(src);
|
|
170
|
+
const displayText = display?.[1]?.trim();
|
|
171
|
+
if (display && displayText !== undefined) {
|
|
172
|
+
if (displayText.length === 0)
|
|
173
|
+
return undefined;
|
|
174
|
+
return { type: "mathInline", raw: display[0], text: displayText, display: true };
|
|
175
|
+
}
|
|
176
|
+
const plain = INLINE_PLAIN.exec(src);
|
|
177
|
+
const body = plain?.[1];
|
|
178
|
+
if (!plain || body === undefined)
|
|
179
|
+
return undefined;
|
|
180
|
+
const after = src.slice(plain[0].length, plain[0].length + 1);
|
|
181
|
+
if (!isPlausibleInlineMath(body, after))
|
|
182
|
+
return undefined;
|
|
183
|
+
return { type: "mathInline", raw: plain[0], text: body, display: false };
|
|
184
|
+
},
|
|
185
|
+
renderer(token) {
|
|
186
|
+
if (!isMathToken(token))
|
|
187
|
+
return "";
|
|
188
|
+
return mathPlaceholder(token.text, { display: token.display, block: false });
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
/** Register with `marked.use(mathExtension)`. Pair with
|
|
192
|
+
* `renderMathNodes()` from `mathRender.js` on the injected DOM. */
|
|
193
|
+
export const mathExtension = {
|
|
194
|
+
extensions: [mathBlock, mathInline],
|
|
195
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Localised strings the render pipeline surfaces when it fails.
|
|
2
|
+
* Callers (composables) resolve the keys at component-setup time and
|
|
3
|
+
* hand the formatter down. Fallback defaults keep the pure module
|
|
4
|
+
* testable without a Vue / i18n runtime. */
|
|
5
|
+
export interface MathRenderLabels {
|
|
6
|
+
loadFailed: (error: string) => string;
|
|
7
|
+
renderFailed: (error: string) => string;
|
|
8
|
+
}
|
|
9
|
+
/** A host's EXTRA pass over one formula's markup, run between two
|
|
10
|
+
* `sanitizeMathSvg` passes and never in place of one. Receives what the
|
|
11
|
+
* baseline left (`<mjx-container>` wrapping the `<svg>`, plus the
|
|
12
|
+
* assistive `<math>` twin) and returns a narrower version of it — what
|
|
13
|
+
* it returns is sanitised again on the way out, so a hardener can only
|
|
14
|
+
* ever take things away.
|
|
15
|
+
*
|
|
16
|
+
* Two things it must keep, or the formula degrades: the `<svg>` element
|
|
17
|
+
* itself (`adoptFormula` returns null without it, and the placeholder
|
|
18
|
+
* becomes an error box), and — for inline math to sit on the text
|
|
19
|
+
* baseline — the root `<svg>`'s own `style="vertical-align: …"`. A
|
|
20
|
+
* policy that strips `style` everywhere still renders; the formula just
|
|
21
|
+
* sits slightly high. */
|
|
22
|
+
export type MathHardener = (markup: string) => string;
|
|
23
|
+
/** DOMPurify pass over one formula's SVG. See the SANITISATION note at
|
|
24
|
+
* the top of the file: this is the only thing standing between a
|
|
25
|
+
* `\href{javascript:…}` in an arbitrary `.md` and a clickable payload
|
|
26
|
+
* in the app's origin, because the markdown-level sanitiser has
|
|
27
|
+
* already run by the time this markup exists. */
|
|
28
|
+
export declare function sanitizeMathSvg(markup: string): string;
|
|
29
|
+
/** Split one formula's sanitised markup into the picture and its
|
|
30
|
+
* screen-reader counterpart, both imported into the live document.
|
|
31
|
+
* `mathml` is null when MathJax produced no assistive copy. */
|
|
32
|
+
export declare function adoptFormula(markup: string): {
|
|
33
|
+
svg: SVGElement;
|
|
34
|
+
mathml: Element | null;
|
|
35
|
+
} | null;
|
|
36
|
+
/** Typeset every unprocessed math placeholder under `root`. Safe to
|
|
37
|
+
* call repeatedly — a rendered node loses `data-math-pending` and a
|
|
38
|
+
* failed one is replaced by an `.math-error` box, so neither matches
|
|
39
|
+
* a second time. `labels` defaults to English fallbacks so the pure
|
|
40
|
+
* module remains callable from tests / node environments without an
|
|
41
|
+
* i18n runtime. `harden` is an EXTRA pass over each formula's markup,
|
|
42
|
+
* run between two `sanitizeMathSvg` passes rather than instead of one,
|
|
43
|
+
* so it can only ever narrow what reaches the page (see the HOST note
|
|
44
|
+
* at the top of the file); it defaults to leaving the sanitised markup
|
|
45
|
+
* alone. */
|
|
46
|
+
export declare function renderMathNodes(root: Element | Document | null | undefined, labels?: MathRenderLabels, harden?: MathHardener): Promise<void>;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Runtime side of the math pipeline: scans the DOM for the
|
|
2
|
+
// `[data-math-pending]` placeholders written by `mathExtension.ts`,
|
|
3
|
+
// lazy-loads MathJax on the first hit, typesets each formula to SVG,
|
|
4
|
+
// and swaps the placeholder in place with the resulting `<svg>`.
|
|
5
|
+
//
|
|
6
|
+
// Mirrors `mermaidRender.ts` deliberately — same lazy-load-and-memoise
|
|
7
|
+
// shape, same "replace the node so a second pass finds nothing"
|
|
8
|
+
// idempotence, same localised error box carrying the source that broke.
|
|
9
|
+
//
|
|
10
|
+
// Lazy-load: `mathjax-full`'s TeX→SVG pipeline is heavy (~1 MB). The
|
|
11
|
+
// dynamic import keeps it out of the initial bundle for the documents —
|
|
12
|
+
// most of them — that contain no math at all.
|
|
13
|
+
//
|
|
14
|
+
// SVG, not CommonHTML: the output is a self-contained `<svg>` drawn in
|
|
15
|
+
// `currentColor`, so it needs no stylesheet and no webfont files in the
|
|
16
|
+
// published tarball, and it inherits the surrounding theme's text
|
|
17
|
+
// colour for free.
|
|
18
|
+
//
|
|
19
|
+
// SANITISATION. This SVG is injected AFTER `sanitizeMarkdownHtml` has
|
|
20
|
+
// run on the markdown, so DOMPurify never sees it on that pass — and
|
|
21
|
+
// MathJax's TeX input is NOT inert: the `html` package (part of
|
|
22
|
+
// `AllPackages`) implements `\href`, and `$\href{javascript:alert(1)}{x}$`
|
|
23
|
+
// emits a real `<a href="javascript:…">` inside the SVG. A
|
|
24
|
+
// `presentDocument` path can open any `.md` on disk, including one that
|
|
25
|
+
// came with a cloned repository, so that is a live XSS vector and not a
|
|
26
|
+
// theoretical one. Every formula therefore goes through DOMPurify here,
|
|
27
|
+
// on its way in.
|
|
28
|
+
//
|
|
29
|
+
// `fontCache: "none"` is load-bearing for that, not a size preference.
|
|
30
|
+
// The default (`"local"`) emits each glyph once into a `<defs>` block
|
|
31
|
+
// and references it with `<use xlink:href="#…">` — and DOMPurify drops
|
|
32
|
+
// every `<use>` element, which would leave a formula with correct
|
|
33
|
+
// geometry and no glyphs at all. `"none"` inlines each glyph as its own
|
|
34
|
+
// `<path>`, so nothing depends on an element the sanitiser removes.
|
|
35
|
+
// Measured cost: ~5% more markup per formula.
|
|
36
|
+
//
|
|
37
|
+
// ACCESSIBILITY. MathJax's SVG carries `role="img"` and no accessible
|
|
38
|
+
// name, so a screen reader meets an unlabelled graphic where the formula
|
|
39
|
+
// is. `AssistiveMmlHandler` fixes that at the source: it marks the SVG
|
|
40
|
+
// `aria-hidden="true"` and emits a MathML copy of the same expression
|
|
41
|
+
// beside it, which assistive technology reads as maths rather than as a
|
|
42
|
+
// picture or as raw LaTeX. That copy is visually hidden here with inline
|
|
43
|
+
// styles rather than a class — this module ships no stylesheet and is
|
|
44
|
+
// consumed by more than one host, so a host that never adopted our CSS
|
|
45
|
+
// would otherwise render every formula twice.
|
|
46
|
+
//
|
|
47
|
+
// Sanitising rather than trimming the TeX package list is deliberate.
|
|
48
|
+
// A package allow-list has to be re-audited every time MathJax adds an
|
|
49
|
+
// extension; the sanitiser is a boundary that holds regardless, and it
|
|
50
|
+
// keeps a legitimate `\href{https://…}` working while dropping the
|
|
51
|
+
// `javascript:` one.
|
|
52
|
+
//
|
|
53
|
+
// A HOST MAY NEED A STRICTER POLICY THAN THE DEFAULT, which is why
|
|
54
|
+
// `renderMathNodes` takes an extra pass. `sanitizeMathSvg` runs DOMPurify with
|
|
55
|
+
// its defaults, and those keep `class` and `style` — while the TeX `html`
|
|
56
|
+
// package puts BOTH under the author's control:
|
|
57
|
+
//
|
|
58
|
+
// $\style{position:fixed;inset:0;background:#fff}{x}$ → <g style="position: fixed; …">
|
|
59
|
+
// $\class{fixed inset-0 bg-white}{x}$ → <g class=" fixed inset-0 bg-white">
|
|
60
|
+
//
|
|
61
|
+
// Inside an `<svg>` those declarations are largely inert — CSS box
|
|
62
|
+
// positioning does not apply to SVG child elements, and the root `<svg>`
|
|
63
|
+
// clips its own overflow — so this is not a hole in THIS app, where the
|
|
64
|
+
// markdown is a file on the user's own disk. It is a hole in the
|
|
65
|
+
// invariant of a host that renders STRANGER-WRITTEN markdown on a
|
|
66
|
+
// signed-in origin and has therefore banned author-controlled `class` /
|
|
67
|
+
// `style` outright (mulmoserver's article renderer bans both precisely
|
|
68
|
+
// because a utility-CSS framework turns a class name into positioning).
|
|
69
|
+
// Such a host passes its own function.
|
|
70
|
+
//
|
|
71
|
+
// That function runs BETWEEN two `sanitizeMathSvg` passes, never instead of one.
|
|
72
|
+
// The baseline is not a default a caller can decline: a host writing the targeted
|
|
73
|
+
// transformer this feature exists for — strip `class` and `style`, keep the rest —
|
|
74
|
+
// would otherwise silently re-admit the `\href{javascript:…}` the baseline is
|
|
75
|
+
// there to stop (codex, #2983). And the pass AFTER it is what makes the return
|
|
76
|
+
// type honest: a hardener hands back an arbitrary STRING, so nothing in the type
|
|
77
|
+
// says it only removed things, and the markup it returns is what reaches the
|
|
78
|
+
// document (coderabbit, #2983). Sanitising on both sides is what makes "may only
|
|
79
|
+
// tighten" a property of the code rather than a sentence in a docstring — the
|
|
80
|
+
// cost is one more DOMPurify pass per formula, against a MathJax typeset.
|
|
81
|
+
import DOMPurify from "dompurify";
|
|
82
|
+
import { parseMarkupBody } from "../dom/adoptSvg.js";
|
|
83
|
+
const DEFAULT_LABELS = {
|
|
84
|
+
loadFailed: (error) => `⚠ MathJax failed to load: ${error}`,
|
|
85
|
+
renderFailed: (error) => `⚠ Math render failed: ${error}`,
|
|
86
|
+
};
|
|
87
|
+
/** DOMPurify pass over one formula's SVG. See the SANITISATION note at
|
|
88
|
+
* the top of the file: this is the only thing standing between a
|
|
89
|
+
* `\href{javascript:…}` in an arbitrary `.md` and a clickable payload
|
|
90
|
+
* in the app's origin, because the markdown-level sanitiser has
|
|
91
|
+
* already run by the time this markup exists. */
|
|
92
|
+
export function sanitizeMathSvg(markup) {
|
|
93
|
+
return DOMPurify.sanitize(markup);
|
|
94
|
+
}
|
|
95
|
+
let typesetterPromise = null;
|
|
96
|
+
/** The MathJax pieces this module needs, imported on demand. The return
|
|
97
|
+
* type is inferred on purpose: annotating it would mean naming
|
|
98
|
+
* `mathjax-full`'s deep-path types, which is exactly what the
|
|
99
|
+
* `MathTypesetter` note above explains this module avoids — and an
|
|
100
|
+
* alias referring back to this function is circular, which resolves to
|
|
101
|
+
* `any` and silently unchecks every call below. */
|
|
102
|
+
async function loadMathJax() {
|
|
103
|
+
const [{ mathjax }, { TeX }, { SVG }, { liteAdaptor }, { RegisterHTMLHandler }, { AllPackages }, { LiteElement }, { AssistiveMmlHandler }] = await Promise.all([
|
|
104
|
+
import("mathjax-full/js/mathjax.js"),
|
|
105
|
+
import("mathjax-full/js/input/tex.js"),
|
|
106
|
+
import("mathjax-full/js/output/svg.js"),
|
|
107
|
+
import("mathjax-full/js/adaptors/liteAdaptor.js"),
|
|
108
|
+
import("mathjax-full/js/handlers/html.js"),
|
|
109
|
+
import("mathjax-full/js/input/tex/AllPackages.js"),
|
|
110
|
+
import("mathjax-full/js/adaptors/lite/Element.js"),
|
|
111
|
+
import("mathjax-full/js/a11y/assistive-mml.js"),
|
|
112
|
+
]);
|
|
113
|
+
return { mathjax, TeX, SVG, liteAdaptor, RegisterHTMLHandler, AllPackages, LiteElement, AssistiveMmlHandler };
|
|
114
|
+
}
|
|
115
|
+
/** Build the one-shot TeX→SVG renderer.
|
|
116
|
+
*
|
|
117
|
+
* `RegisterHTMLHandler` mutates a MathJax-global handler list, so it
|
|
118
|
+
* must run exactly once per page — memoising the whole builder in
|
|
119
|
+
* `typesetterPromise` is what guarantees that. `AssistiveMmlHandler`
|
|
120
|
+
* wraps that handler so every formula also carries a MathML copy of
|
|
121
|
+
* itself; see the ACCESSIBILITY note at the top of the file.
|
|
122
|
+
*
|
|
123
|
+
* `doc` and `adaptor` stay captured in the closure rather than being
|
|
124
|
+
* handed back, so both calls keep the concrete types their imports
|
|
125
|
+
* gave them and nothing downstream has to assert a node shape.
|
|
126
|
+
* `AbstractMathDocument.convert` is nonetheless declared `any`, hence
|
|
127
|
+
* the real `instanceof` narrowing against the lite adaptor's own node
|
|
128
|
+
* class. */
|
|
129
|
+
async function buildTypesetter() {
|
|
130
|
+
const { mathjax, TeX, SVG, liteAdaptor, RegisterHTMLHandler, AllPackages, LiteElement, AssistiveMmlHandler } = await loadMathJax();
|
|
131
|
+
const adaptor = liteAdaptor();
|
|
132
|
+
AssistiveMmlHandler(RegisterHTMLHandler(adaptor));
|
|
133
|
+
const doc = mathjax.document("", {
|
|
134
|
+
InputJax: new TeX({ packages: AllPackages }),
|
|
135
|
+
OutputJax: new SVG({ fontCache: "none" }),
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
render: (tex, display, harden) => {
|
|
139
|
+
const node = doc.convert(tex, { display });
|
|
140
|
+
if (!(node instanceof LiteElement))
|
|
141
|
+
throw new Error("MathJax returned an unexpected node type");
|
|
142
|
+
// Baseline, host policy, baseline. Never the host policy alone.
|
|
143
|
+
return sanitizeMathSvg(harden(sanitizeMathSvg(adaptor.outerHTML(node))));
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
async function loadTypesetter() {
|
|
148
|
+
if (typesetterPromise)
|
|
149
|
+
return typesetterPromise;
|
|
150
|
+
const attempt = buildTypesetter();
|
|
151
|
+
// Share the in-flight promise with parallel callers, but drop the
|
|
152
|
+
// cache once it rejects so a transient failure (offline / stale chunk
|
|
153
|
+
// after a deploy / ad-blocker hiccup) can be retried by the next
|
|
154
|
+
// formula to render. Without this reset the module would be dead
|
|
155
|
+
// until the user reloaded.
|
|
156
|
+
attempt.catch(() => {
|
|
157
|
+
if (typesetterPromise === attempt)
|
|
158
|
+
typesetterPromise = null;
|
|
159
|
+
});
|
|
160
|
+
typesetterPromise = attempt;
|
|
161
|
+
return attempt;
|
|
162
|
+
}
|
|
163
|
+
// Visually hidden, still in the accessibility tree. The clip-rect idiom
|
|
164
|
+
// rather than `display:none` / `visibility:hidden`, both of which remove
|
|
165
|
+
// the node from that tree — which would defeat the whole point.
|
|
166
|
+
const VISUALLY_HIDDEN = "position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0;";
|
|
167
|
+
/** Split one formula's sanitised markup into the picture and its
|
|
168
|
+
* screen-reader counterpart, both imported into the live document.
|
|
169
|
+
* `mathml` is null when MathJax produced no assistive copy. */
|
|
170
|
+
export function adoptFormula(markup) {
|
|
171
|
+
const parsed = parseMarkupBody(markup);
|
|
172
|
+
const svg = parsed.querySelector("svg");
|
|
173
|
+
if (!svg)
|
|
174
|
+
return null;
|
|
175
|
+
const math = parsed.querySelector("math");
|
|
176
|
+
return {
|
|
177
|
+
svg: document.importNode(svg, true),
|
|
178
|
+
mathml: math === null ? null : document.importNode(math, true),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function hiddenMathml(mathml) {
|
|
182
|
+
const wrapper = document.createElement("span");
|
|
183
|
+
wrapper.className = "math-a11y";
|
|
184
|
+
wrapper.setAttribute("style", VISUALLY_HIDDEN);
|
|
185
|
+
wrapper.appendChild(mathml);
|
|
186
|
+
return wrapper;
|
|
187
|
+
}
|
|
188
|
+
function errorBox(message, className) {
|
|
189
|
+
const box = document.createElement("code");
|
|
190
|
+
box.className = className;
|
|
191
|
+
box.textContent = message;
|
|
192
|
+
return box;
|
|
193
|
+
}
|
|
194
|
+
function placeLoadError(nodes, err, labels) {
|
|
195
|
+
const message = labels.loadFailed(String(err));
|
|
196
|
+
for (const node of nodes) {
|
|
197
|
+
node.replaceWith(errorBox(message, "math-error"));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function pendingNodes(root) {
|
|
201
|
+
return Array.from(root.querySelectorAll("[data-math-pending]"));
|
|
202
|
+
}
|
|
203
|
+
function renderOne(node, typesetter, labels, harden) {
|
|
204
|
+
// `textContent` gives us the raw TeX — we escaped it going in and
|
|
205
|
+
// DOMPurify preserves text verbatim, so entity decoding is
|
|
206
|
+
// browser-native from the DOM read.
|
|
207
|
+
const source = node.textContent ?? "";
|
|
208
|
+
const display = node.dataset.mathDisplay === "1";
|
|
209
|
+
try {
|
|
210
|
+
const formula = adoptFormula(typesetter.render(source, display, harden));
|
|
211
|
+
if (!formula)
|
|
212
|
+
throw new Error("MathJax produced malformed SVG");
|
|
213
|
+
// Keep the placeholder's own element (a `<div>` for block math, a
|
|
214
|
+
// `<span>` inside a paragraph for inline) so the surrounding flow
|
|
215
|
+
// is unchanged — only its contents and the pending flag change.
|
|
216
|
+
node.replaceChildren(formula.svg);
|
|
217
|
+
if (formula.mathml)
|
|
218
|
+
node.appendChild(hiddenMathml(formula.mathml));
|
|
219
|
+
delete node.dataset.mathPending;
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
// Preserve the source next to the localised header so the author
|
|
223
|
+
// can see WHICH formula broke.
|
|
224
|
+
node.replaceWith(errorBox(`${labels.renderFailed(String(err))} — ${source}`, "math-error"));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Typeset every unprocessed math placeholder under `root`. Safe to
|
|
228
|
+
* call repeatedly — a rendered node loses `data-math-pending` and a
|
|
229
|
+
* failed one is replaced by an `.math-error` box, so neither matches
|
|
230
|
+
* a second time. `labels` defaults to English fallbacks so the pure
|
|
231
|
+
* module remains callable from tests / node environments without an
|
|
232
|
+
* i18n runtime. `harden` is an EXTRA pass over each formula's markup,
|
|
233
|
+
* run between two `sanitizeMathSvg` passes rather than instead of one,
|
|
234
|
+
* so it can only ever narrow what reaches the page (see the HOST note
|
|
235
|
+
* at the top of the file); it defaults to leaving the sanitised markup
|
|
236
|
+
* alone. */
|
|
237
|
+
export async function renderMathNodes(root, labels = DEFAULT_LABELS, harden = (markup) => markup) {
|
|
238
|
+
if (!root)
|
|
239
|
+
return;
|
|
240
|
+
const nodes = pendingNodes(root);
|
|
241
|
+
if (nodes.length === 0)
|
|
242
|
+
return;
|
|
243
|
+
let typesetter;
|
|
244
|
+
try {
|
|
245
|
+
typesetter = await loadTypesetter();
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
// The dynamic import failed (network / bundler / adblock). Swap
|
|
249
|
+
// every pending placeholder for a visible error box so the user
|
|
250
|
+
// sees WHY the formula is missing instead of raw TeX, and don't let
|
|
251
|
+
// the rejection escape as an unhandled promise (callers fire this
|
|
252
|
+
// via `void run()` in the composable).
|
|
253
|
+
placeLoadError(nodes, err, labels);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
for (const node of nodes)
|
|
257
|
+
renderOne(node, typesetter, labels, harden);
|
|
258
|
+
}
|
|
@@ -7,23 +7,6 @@ export interface MermaidRenderLabels {
|
|
|
7
7
|
loadFailed: (error: string) => string;
|
|
8
8
|
renderFailed: (error: string) => string;
|
|
9
9
|
}
|
|
10
|
-
/** Adopt a mermaid-produced SVG string into a live DOM node via
|
|
11
|
-
* DOMParser (HTML5 mode) instead of assigning to `.innerHTML`.
|
|
12
|
-
* Mermaid's `securityLevel: "strict"` already escapes user-authored
|
|
13
|
-
* diagram text before building the SVG, so the string is trusted —
|
|
14
|
-
* but going through the parser satisfies opengrep's XSS heuristic
|
|
15
|
-
* that flags every raw `innerHTML =`. HTML5 mode (not `image/svg+xml`)
|
|
16
|
-
* is required: mermaid's SVG contains `<foreignObject>` wrappers with
|
|
17
|
-
* nested HTML content for labels (line-broken text via `<br>`, `<div>`,
|
|
18
|
-
* etc.), which is well-formed HTML5 but NOT well-formed XML — the
|
|
19
|
-
* XML parser drops a `<parsererror>` root and refuses. HTML5 mode
|
|
20
|
-
* treats `<svg>` as a foreign-namespace root and correctly parses
|
|
21
|
-
* the mixed subtree.
|
|
22
|
-
*
|
|
23
|
-
* Exported for regression tests in
|
|
24
|
-
* `test/utils/markdown/test_mermaidRender.ts` so the assertion
|
|
25
|
-
* exercises the real production helper instead of an inline copy. */
|
|
26
|
-
export declare function adoptSvg(svgMarkup: string): SVGElement | null;
|
|
27
10
|
/** Render every unprocessed mermaid placeholder under `root`. Safe to
|
|
28
11
|
* call repeatedly — nodes get replaced on success (no `data-*` to
|
|
29
12
|
* match a second time) and gain an `.mermaid-error` class on failure.
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// keeps it out of the initial bundle for users who never encounter a
|
|
9
9
|
// diagram. `mermaidPromise` memoises the module so subsequent calls
|
|
10
10
|
// don't re-import.
|
|
11
|
+
import { adoptSvg } from "../dom/adoptSvg.js";
|
|
11
12
|
const DEFAULT_LABELS = {
|
|
12
13
|
loadFailed: (error) => `⚠ Mermaid failed to load: ${error}`,
|
|
13
14
|
renderFailed: (error) => `⚠ Mermaid render failed: ${error}`,
|
|
@@ -56,30 +57,6 @@ function nextRenderId(idPrefix) {
|
|
|
56
57
|
function pendingNodes(root) {
|
|
57
58
|
return Array.from(root.querySelectorAll("pre.mermaid[data-mermaid-pending]"));
|
|
58
59
|
}
|
|
59
|
-
/** Adopt a mermaid-produced SVG string into a live DOM node via
|
|
60
|
-
* DOMParser (HTML5 mode) instead of assigning to `.innerHTML`.
|
|
61
|
-
* Mermaid's `securityLevel: "strict"` already escapes user-authored
|
|
62
|
-
* diagram text before building the SVG, so the string is trusted —
|
|
63
|
-
* but going through the parser satisfies opengrep's XSS heuristic
|
|
64
|
-
* that flags every raw `innerHTML =`. HTML5 mode (not `image/svg+xml`)
|
|
65
|
-
* is required: mermaid's SVG contains `<foreignObject>` wrappers with
|
|
66
|
-
* nested HTML content for labels (line-broken text via `<br>`, `<div>`,
|
|
67
|
-
* etc.), which is well-formed HTML5 but NOT well-formed XML — the
|
|
68
|
-
* XML parser drops a `<parsererror>` root and refuses. HTML5 mode
|
|
69
|
-
* treats `<svg>` as a foreign-namespace root and correctly parses
|
|
70
|
-
* the mixed subtree.
|
|
71
|
-
*
|
|
72
|
-
* Exported for regression tests in
|
|
73
|
-
* `test/utils/markdown/test_mermaidRender.ts` so the assertion
|
|
74
|
-
* exercises the real production helper instead of an inline copy. */
|
|
75
|
-
export function adoptSvg(svgMarkup) {
|
|
76
|
-
const parsed = new DOMParser().parseFromString(svgMarkup, "text/html");
|
|
77
|
-
// `<svg>` at the top level lands under `body` in HTML5 parsing.
|
|
78
|
-
const svgEl = parsed.body.querySelector("svg");
|
|
79
|
-
if (!svgEl)
|
|
80
|
-
return null;
|
|
81
|
-
return document.importNode(svgEl, true);
|
|
82
|
-
}
|
|
83
60
|
async function renderOne(node, mermaid, labels, idPrefix) {
|
|
84
61
|
// `textContent` gives us the raw source — DOMPurify preserves it
|
|
85
62
|
// verbatim inside `<pre>` and we escaped it going in, so entity
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mulmoclaude/markdown-utils",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Browser-safe markdown / image rendering utilities shared by the MulmoClaude host and the markdown plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -33,18 +33,21 @@
|
|
|
33
33
|
"license": "MIT",
|
|
34
34
|
"author": "Receptron Team",
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@mulmoclaude/common": "^1.
|
|
36
|
+
"@mulmoclaude/common": "^1.2.0",
|
|
37
|
+
"dompurify": "^3.4.13",
|
|
37
38
|
"js-yaml": "^5.2.3",
|
|
38
|
-
"marked": "^18.0.
|
|
39
|
+
"marked": "^18.0.11"
|
|
39
40
|
},
|
|
40
41
|
"peerDependencies": {
|
|
41
|
-
"
|
|
42
|
+
"mathjax-full": "^3.2.2",
|
|
43
|
+
"mermaid": "^11.16.1",
|
|
42
44
|
"vue": "^3.5.0"
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
|
45
|
-
"
|
|
47
|
+
"mathjax-full": "^3.2.2",
|
|
48
|
+
"mermaid": "^11.17.2",
|
|
46
49
|
"typescript": "^6.0.3",
|
|
47
|
-
"vue": "^3.5.
|
|
50
|
+
"vue": "^3.5.41"
|
|
48
51
|
},
|
|
49
52
|
"homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/markdown-utils#readme",
|
|
50
53
|
"repository": {
|