@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.
@@ -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": "1.3.5",
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.1.2",
36
+ "@mulmoclaude/common": "^1.2.0",
37
+ "dompurify": "^3.4.13",
37
38
  "js-yaml": "^5.2.3",
38
- "marked": "^18.0.7"
39
+ "marked": "^18.0.11"
39
40
  },
40
41
  "peerDependencies": {
41
- "mermaid": "^11.16.0",
42
+ "mathjax-full": "^3.2.2",
43
+ "mermaid": "^11.16.1",
42
44
  "vue": "^3.5.0"
43
45
  },
44
46
  "devDependencies": {
45
- "mermaid": "^11.16.0",
47
+ "mathjax-full": "^3.2.2",
48
+ "mermaid": "^11.17.2",
46
49
  "typescript": "^6.0.3",
47
- "vue": "^3.5.40"
50
+ "vue": "^3.5.41"
48
51
  },
49
52
  "homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/markdown-utils#readme",
50
53
  "repository": {