@mulmoclaude/markdown-utils 1.1.0 → 1.2.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/index.d.ts CHANGED
@@ -10,3 +10,5 @@ export * from "./dom/externalLink.js";
10
10
  export * from "./files/filename.js";
11
11
  export * from "./image/resolve.js";
12
12
  export * from "./image/rewriteMarkdownImageRefs.js";
13
+ export * from "./markdown/mermaidRender.js";
14
+ export * from "./markdown/mermaidExtension.js";
package/dist/index.js CHANGED
@@ -10,3 +10,5 @@ export * from "./dom/externalLink.js";
10
10
  export * from "./files/filename.js";
11
11
  export * from "./image/resolve.js";
12
12
  export * from "./image/rewriteMarkdownImageRefs.js";
13
+ export * from "./markdown/mermaidRender.js";
14
+ export * from "./markdown/mermaidExtension.js";
@@ -0,0 +1,2 @@
1
+ import type { MarkedExtension } from "marked";
2
+ export declare const mermaidExtension: MarkedExtension;
@@ -0,0 +1,53 @@
1
+ // Marked `code` renderer override that intercepts fenced code blocks
2
+ // whose language tag is `mermaid` and rewrites them into a
3
+ // `<pre class="mermaid" data-mermaid-pending="1">` placeholder. The
4
+ // diagram render itself is deferred to `mermaidRender.ts`, which
5
+ // scans the placeholders in the DOM after Vue's v-html injects the
6
+ // html. Two-step split keeps this file pure (no runtime deps beyond
7
+ // `marked`) so tests can assert the html shape without booting a
8
+ // browser.
9
+ //
10
+ // Why a renderer override and not a block tokenizer:
11
+ // - marked already handles every fence variation CommonMark / GFM
12
+ // permits (backticks vs tildes, LF vs CRLF, top-level vs indented
13
+ // inside a list item, up to 3 spaces of leading whitespace on the
14
+ // fence). Re-implementing that surface in a bespoke regex means
15
+ // silently falling back to plaintext on the edge cases the regex
16
+ // misses. Overriding the `code` renderer catches everything marked
17
+ // already tokenised as a code block, so no CommonMark variant is
18
+ // left behind.
19
+ //
20
+ // Registration order (see setup.ts): register AFTER
21
+ // `markedHighlightExtension` so this renderer wraps highlight's — a
22
+ // non-mermaid fence returns `false` from here and falls through to
23
+ // highlight's code renderer unchanged, while a `mermaid` fence
24
+ // short-circuits into the placeholder and never reaches highlight.
25
+ function escapeHtml(text) {
26
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
27
+ }
28
+ export const mermaidExtension = {
29
+ renderer: {
30
+ code(token) {
31
+ // marked v18 hands the whole token in — read `lang` from there.
32
+ // `lang` may carry trailing whitespace (`\`\`\`mermaid `), so
33
+ // trim before comparing. Empty `lang` (indented 4-space blocks
34
+ // or plain triple-backtick with no tag) can never match here.
35
+ const lang = (token.lang ?? "").trim();
36
+ if (lang !== "mermaid")
37
+ return false;
38
+ // markedHighlight's `walkTokens` fires on EVERY code token —
39
+ // regardless of language — and rewrites `token.text` to an
40
+ // HTML-escaped, highlight.js-processed string (mermaid falls
41
+ // to `plaintext`, so no <span> tags land, but every `"` is
42
+ // now `&quot;`). It also stamps `token.escaped = true`. If we
43
+ // re-escape here, `&` in `&quot;` becomes `&amp;`, the browser
44
+ // decodes `&amp;quot;` back to `&quot;` on parse, and mermaid
45
+ // sees literal `&quot;` in its input — parse error. Honour
46
+ // the `escaped` flag: pass through when already escaped, escape
47
+ // ourselves when not (host code paths that don't wire highlight,
48
+ // and the plugin, still need our own escape).
49
+ const html = token.escaped === true ? token.text : escapeHtml(token.text);
50
+ return `<pre class="mermaid" data-mermaid-pending="1">${html}</pre>\n`;
51
+ },
52
+ },
53
+ };
@@ -0,0 +1,33 @@
1
+ /** Localised strings the render pipeline surfaces when it fails.
2
+ * Callers (composables) resolve `t("markdownMermaid.…")` at
3
+ * component-setup time and hand the formatter down. Fallback
4
+ * defaults keep the pure module testable without a Vue / i18n
5
+ * runtime — they mirror the English text in `src/lang/en.ts`. */
6
+ export interface MermaidRenderLabels {
7
+ loadFailed: (error: string) => string;
8
+ renderFailed: (error: string) => string;
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
+ /** Render every unprocessed mermaid placeholder under `root`. Safe to
28
+ * call repeatedly — nodes get replaced on success (no `data-*` to
29
+ * match a second time) and gain an `.mermaid-error` class on failure.
30
+ * Returns once every discovered node has been resolved. `labels`
31
+ * defaults to English fallbacks so the pure module remains callable
32
+ * from tests / node environments without an i18n runtime. */
33
+ export declare function renderMermaidNodes(root: Element | Document | null | undefined, labels?: MermaidRenderLabels, idPrefix?: string): Promise<void>;
@@ -0,0 +1,134 @@
1
+ // Runtime side of the mermaid pipeline: scans the DOM for
2
+ // `<pre class="mermaid" data-mermaid-pending>` placeholders written by
3
+ // `mermaidExtension.ts`, lazy-loads the mermaid runtime on the first
4
+ // hit, renders each block, and swaps the placeholder in place with
5
+ // the resulting SVG.
6
+ //
7
+ // Lazy-load: mermaid.js is heavy (~500 KB gzip). The dynamic import
8
+ // keeps it out of the initial bundle for users who never encounter a
9
+ // diagram. `mermaidPromise` memoises the module so subsequent calls
10
+ // don't re-import.
11
+ const DEFAULT_LABELS = {
12
+ loadFailed: (error) => `⚠ Mermaid failed to load: ${error}`,
13
+ renderFailed: (error) => `⚠ Mermaid render failed: ${error}`,
14
+ };
15
+ let mermaidPromise = null;
16
+ async function loadMermaid() {
17
+ if (mermaidPromise)
18
+ return mermaidPromise;
19
+ const attempt = import("mermaid").then((mod) => {
20
+ const mermaid = mod.default;
21
+ // `startOnLoad: false` — we drive rendering explicitly per node
22
+ // instead of letting mermaid walk the document on DOMContentLoaded.
23
+ // `securityLevel: "strict"` — mermaid sanitises its own labels and
24
+ // will not execute user-authored HTML/JS in diagram text.
25
+ mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "default" });
26
+ return mermaid;
27
+ });
28
+ // Share the in-flight promise with parallel callers, but drop the
29
+ // cache once it rejects so a transient failure (offline / stale
30
+ // chunk after a deploy / ad-blocker hiccup) can be retried by the
31
+ // next fence to render. Without this reset the module would be
32
+ // dead until the user reloaded.
33
+ attempt.catch(() => {
34
+ if (mermaidPromise === attempt)
35
+ mermaidPromise = null;
36
+ });
37
+ mermaidPromise = attempt;
38
+ return attempt;
39
+ }
40
+ function placeLoadError(nodes, err, labels) {
41
+ const message = labels.loadFailed(String(err));
42
+ for (const node of nodes) {
43
+ const errBox = document.createElement("pre");
44
+ errBox.className = "mermaid-error";
45
+ errBox.textContent = message;
46
+ node.replaceWith(errBox);
47
+ }
48
+ }
49
+ // Distinct per-diagram DOM id. Two diagrams on one page must not collide
50
+ // (mermaid uses the id as the SVG root id).
51
+ let renderCounter = 0;
52
+ function nextRenderId(idPrefix) {
53
+ renderCounter += 1;
54
+ return `${idPrefix}-${renderCounter}`;
55
+ }
56
+ function pendingNodes(root) {
57
+ return Array.from(root.querySelectorAll("pre.mermaid[data-mermaid-pending]"));
58
+ }
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
+ async function renderOne(node, mermaid, labels, idPrefix) {
84
+ // `textContent` gives us the raw source — DOMPurify preserves it
85
+ // verbatim inside `<pre>` and we escaped it going in, so entity
86
+ // decoding is browser-native from the DOM read.
87
+ const source = node.textContent ?? "";
88
+ const svgId = nextRenderId(idPrefix);
89
+ try {
90
+ const { svg } = await mermaid.render(svgId, source);
91
+ const svgNode = adoptSvg(svg);
92
+ if (!svgNode)
93
+ throw new Error("mermaid produced malformed SVG");
94
+ const wrapper = document.createElement("div");
95
+ wrapper.className = "mermaid-diagram";
96
+ wrapper.appendChild(svgNode);
97
+ node.replaceWith(wrapper);
98
+ }
99
+ catch (err) {
100
+ // Preserve the source below the localised header so the author
101
+ // can see WHICH diagram broke.
102
+ const errBox = document.createElement("pre");
103
+ errBox.className = "mermaid-error";
104
+ errBox.textContent = `${labels.renderFailed(String(err))}\n---\n${source}`;
105
+ node.replaceWith(errBox);
106
+ }
107
+ }
108
+ /** Render every unprocessed mermaid placeholder under `root`. Safe to
109
+ * call repeatedly — nodes get replaced on success (no `data-*` to
110
+ * match a second time) and gain an `.mermaid-error` class on failure.
111
+ * Returns once every discovered node has been resolved. `labels`
112
+ * defaults to English fallbacks so the pure module remains callable
113
+ * from tests / node environments without an i18n runtime. */
114
+ export async function renderMermaidNodes(root, labels = DEFAULT_LABELS, idPrefix = "mulmo-mermaid") {
115
+ if (!root)
116
+ return;
117
+ const nodes = pendingNodes(root);
118
+ if (nodes.length === 0)
119
+ return;
120
+ let mermaid;
121
+ try {
122
+ mermaid = await loadMermaid();
123
+ }
124
+ catch (err) {
125
+ // The dynamic import failed (network / bundler / adblock). Swap
126
+ // every pending placeholder for a visible error box so the user
127
+ // sees WHY the diagram is missing instead of a raw code fence, and
128
+ // don't let the rejection escape as an unhandled promise (callers
129
+ // fire this via `void run()` in the composable).
130
+ placeLoadError(nodes, err, labels);
131
+ return;
132
+ }
133
+ await Promise.all(nodes.map((node) => renderOne(node, mermaid, labels, idPrefix)));
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmoclaude/markdown-utils",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",
@@ -37,9 +37,11 @@
37
37
  "js-yaml": "^5.2.1"
38
38
  },
39
39
  "peerDependencies": {
40
+ "mermaid": "^11.16.0",
40
41
  "vue": "^3.5.0"
41
42
  },
42
43
  "devDependencies": {
44
+ "mermaid": "^11.16.0",
43
45
  "typescript": "^6.0.3",
44
46
  "vue": "^3.5.40"
45
47
  }