@mulmoclaude/markdown-utils 1.1.0 → 1.3.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";
@@ -19,6 +19,18 @@ export interface ParsedMarkdown {
19
19
  * a well-formed envelope falls back to `{ meta: {}, hasHeader: false }`
20
20
  * so a typo in the header doesn't break rendering. */
21
21
  export declare function parseFrontmatter(raw: string): ParsedMarkdown;
22
+ /** Split a document into its frontmatter `prefix` (the `---\n…\n---\n`
23
+ * envelope, empty string when there is none) and the `body` that
24
+ * follows. `prefix + body` reproduces the input exactly: `body` is
25
+ * always a suffix of `raw` (see `parseFrontmatter`), so `prefix` is
26
+ * the leading slice of the exact remaining length. Reach for this
27
+ * instead of re-deriving the split when you need to rewrite the body
28
+ * and re-attach the original header verbatim — e.g. toggling a task
29
+ * checkbox without disturbing the YAML. */
30
+ export declare function splitFrontmatter(raw: string): {
31
+ prefix: string;
32
+ body: string;
33
+ };
22
34
  /** Serialize a meta object + body back into the canonical
23
35
  * `---\n...\n---\n\nbody` shape. An empty `meta` returns the body
24
36
  * alone (no envelope) — the lazy-on-write contract: don't add
@@ -38,6 +38,19 @@ export function parseFrontmatter(raw) {
38
38
  }
39
39
  return { meta, body, hasHeader: true };
40
40
  }
41
+ /** Split a document into its frontmatter `prefix` (the `---\n…\n---\n`
42
+ * envelope, empty string when there is none) and the `body` that
43
+ * follows. `prefix + body` reproduces the input exactly: `body` is
44
+ * always a suffix of `raw` (see `parseFrontmatter`), so `prefix` is
45
+ * the leading slice of the exact remaining length. Reach for this
46
+ * instead of re-deriving the split when you need to rewrite the body
47
+ * and re-attach the original header verbatim — e.g. toggling a task
48
+ * checkbox without disturbing the YAML. */
49
+ export function splitFrontmatter(raw) {
50
+ const { body } = parseFrontmatter(raw);
51
+ const prefix = raw.slice(0, raw.length - body.length);
52
+ return { prefix, body };
53
+ }
41
54
  /** Serialize a meta object + body back into the canonical
42
55
  * `---\n...\n---\n\nbody` shape. An empty `meta` returns the body
43
56
  * alone (no envelope) — the lazy-on-write contract: don't add
@@ -0,0 +1,2 @@
1
+ import type { MarkedExtension } from "marked";
2
+ export declare const mermaidExtension: MarkedExtension;
@@ -0,0 +1,51 @@
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` and the zero-dep `@mulmoclaude/common` leaf) so tests can
8
+ // assert the html shape without booting a 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
+ import { escapeHtml } from "@mulmoclaude/common";
26
+ export const mermaidExtension = {
27
+ renderer: {
28
+ code(token) {
29
+ // marked v18 hands the whole token in — read `lang` from there.
30
+ // `lang` may carry trailing whitespace (`\`\`\`mermaid `), so
31
+ // trim before comparing. Empty `lang` (indented 4-space blocks
32
+ // or plain triple-backtick with no tag) can never match here.
33
+ const lang = (token.lang ?? "").trim();
34
+ if (lang !== "mermaid")
35
+ return false;
36
+ // markedHighlight's `walkTokens` fires on EVERY code token —
37
+ // regardless of language — and rewrites `token.text` to an
38
+ // HTML-escaped, highlight.js-processed string (mermaid falls
39
+ // to `plaintext`, so no <span> tags land, but every `"` is
40
+ // now `&quot;`). It also stamps `token.escaped = true`. If we
41
+ // re-escape here, `&` in `&quot;` becomes `&amp;`, the browser
42
+ // decodes `&amp;quot;` back to `&quot;` on parse, and mermaid
43
+ // sees literal `&quot;` in its input — parse error. Honour
44
+ // the `escaped` flag: pass through when already escaped, escape
45
+ // ourselves when not (host code paths that don't wire highlight,
46
+ // and the plugin, still need our own escape).
47
+ const html = token.escaped === true ? token.text : escapeHtml(token.text);
48
+ return `<pre class="mermaid" data-mermaid-pending="1">${html}</pre>\n`;
49
+ },
50
+ },
51
+ };
@@ -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
+ }
@@ -163,8 +163,11 @@ export function makeTasksInteractive(html) {
163
163
  // marked v18 default output:
164
164
  // <input disabled="" type="checkbox"> (unchecked)
165
165
  // <input checked="" disabled="" type="checkbox"> (checked)
166
- // Both end with ` type="checkbox">`. Capture everything between
167
- // `<input ` and `disabled=""` (typically empty or `checked="" `)
168
- // and re-emit with `class="md-task"` in disabled's slot.
169
- return html.replace(/<input ([^>]*)disabled="" type="checkbox">/g, '<input $1class="md-task" type="checkbox">');
166
+ // Match those two shapes exactly and re-emit with `class="md-task"` in
167
+ // disabled's slot. The middle was `[^>]*` once, which is what made this
168
+ // quadratic: on input full of `<input =` the engine re-scans the run for
169
+ // every start position looking for a `disabled=""` that never arrives.
170
+ // An optional literal group can't backtrack, and marked emits nothing else
171
+ // here — the checked/unchecked pair above is the whole surface.
172
+ return html.replace(/<input (checked="" )?disabled="" type="checkbox">/g, '<input $1class="md-task" type="checkbox">');
170
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmoclaude/markdown-utils",
3
- "version": "1.1.0",
3
+ "version": "1.3.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,13 +33,16 @@
33
33
  "license": "MIT",
34
34
  "author": "Receptron Team",
35
35
  "dependencies": {
36
- "marked": "^18.0.6",
37
- "js-yaml": "^5.2.1"
36
+ "@mulmoclaude/common": "^1.1.0",
37
+ "js-yaml": "^5.2.2",
38
+ "marked": "^18.0.7"
38
39
  },
39
40
  "peerDependencies": {
41
+ "mermaid": "^11.16.0",
40
42
  "vue": "^3.5.0"
41
43
  },
42
44
  "devDependencies": {
45
+ "mermaid": "^11.16.0",
43
46
  "typescript": "^6.0.3",
44
47
  "vue": "^3.5.40"
45
48
  }