@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 +2 -0
- package/dist/index.js +2 -0
- package/dist/markdown/frontmatter.d.ts +12 -0
- package/dist/markdown/frontmatter.js +13 -0
- package/dist/markdown/mermaidExtension.d.ts +2 -0
- package/dist/markdown/mermaidExtension.js +51 -0
- package/dist/markdown/mermaidRender.d.ts +33 -0
- package/dist/markdown/mermaidRender.js +134 -0
- package/dist/markdown/taskList.js +7 -4
- package/package.json +6 -3
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,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 `"`). It also stamps `token.escaped = true`. If we
|
|
41
|
+
// re-escape here, `&` in `"` becomes `&`, the browser
|
|
42
|
+
// decodes `&quot;` back to `"` on parse, and mermaid
|
|
43
|
+
// sees literal `"` 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
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
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.
|
|
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
|
-
"
|
|
37
|
-
"js-yaml": "^5.2.
|
|
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
|
}
|