@mulmoclaude/markdown-utils 1.0.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/image/resolve.d.ts +16 -0
- package/dist/image/resolve.js +47 -0
- package/dist/image/rewriteMarkdownImageRefs.d.ts +22 -0
- package/dist/image/rewriteMarkdownImageRefs.js +243 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/markdown/mermaidExtension.d.ts +2 -0
- package/dist/markdown/mermaidExtension.js +53 -0
- package/dist/markdown/mermaidRender.d.ts +33 -0
- package/dist/markdown/mermaidRender.js +134 -0
- package/package.json +4 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Override the workspace file-server URL (e.g. MulmoTerminal). */
|
|
2
|
+
export declare function setFilesRawUrl(url: string): void;
|
|
3
|
+
/** Convert an imageData value to a displayable URL.
|
|
4
|
+
* Handles data URIs, paths under `artifacts/images/` (resolved via
|
|
5
|
+
* the static mount), and everything else (resolved via the workspace
|
|
6
|
+
* file server). */
|
|
7
|
+
export declare function resolveImageSrc(imageData: string): string;
|
|
8
|
+
/** Same as `resolveImageSrc` but appends the current cache-bust token
|
|
9
|
+
* so the browser re-fetches when the file has been overwritten in
|
|
10
|
+
* place (e.g. the canvas plugin rewrote it).
|
|
11
|
+
*
|
|
12
|
+
* Use this from display-only consumers (Preview, thumbnail list).
|
|
13
|
+
* Avoid inside the canvas View's own `backgroundImage` — changing
|
|
14
|
+
* that URL mid-session makes `vue-drawing-canvas` re-fetch on every
|
|
15
|
+
* redraw, which races with stroke painting and blanks the canvas. */
|
|
16
|
+
export declare function resolveImageSrcFresh(imageData: string): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { getImageBump } from "./cacheBust";
|
|
2
|
+
// Host-configurable base URL for the workspace file server (workspace-
|
|
3
|
+
// relative image paths that aren't under the `artifacts/images/` static
|
|
4
|
+
// mount resolve through here). Defaults to MulmoClaude's route; other
|
|
5
|
+
// hosts call `setFilesRawUrl` at startup.
|
|
6
|
+
let filesRawUrl = "/api/files/raw";
|
|
7
|
+
/** Override the workspace file-server URL (e.g. MulmoTerminal). */
|
|
8
|
+
export function setFilesRawUrl(url) {
|
|
9
|
+
filesRawUrl = url;
|
|
10
|
+
}
|
|
11
|
+
// Files saved by `saveImage()` (Gemini, canvas, image edit) all live
|
|
12
|
+
// under this prefix — see server/utils/files/image-store.ts and
|
|
13
|
+
// server/workspace/paths.ts (WORKSPACE_DIRS.images). Express mounts a
|
|
14
|
+
// static handler for the corresponding URL so these paths route
|
|
15
|
+
// directly to the file without going through /api/files/raw.
|
|
16
|
+
const IMAGES_DIR_PREFIX = "artifacts/images/";
|
|
17
|
+
/** Convert an imageData value to a displayable URL.
|
|
18
|
+
* Handles data URIs, paths under `artifacts/images/` (resolved via
|
|
19
|
+
* the static mount), and everything else (resolved via the workspace
|
|
20
|
+
* file server). */
|
|
21
|
+
export function resolveImageSrc(imageData) {
|
|
22
|
+
if (imageData.startsWith("data:"))
|
|
23
|
+
return imageData;
|
|
24
|
+
if (imageData.startsWith(IMAGES_DIR_PREFIX))
|
|
25
|
+
return `/${imageData}`;
|
|
26
|
+
return `${filesRawUrl}?path=${encodeURIComponent(imageData)}`;
|
|
27
|
+
}
|
|
28
|
+
/** Same as `resolveImageSrc` but appends the current cache-bust token
|
|
29
|
+
* so the browser re-fetches when the file has been overwritten in
|
|
30
|
+
* place (e.g. the canvas plugin rewrote it).
|
|
31
|
+
*
|
|
32
|
+
* Use this from display-only consumers (Preview, thumbnail list).
|
|
33
|
+
* Avoid inside the canvas View's own `backgroundImage` — changing
|
|
34
|
+
* that URL mid-session makes `vue-drawing-canvas` re-fetch on every
|
|
35
|
+
* redraw, which races with stroke painting and blanks the canvas. */
|
|
36
|
+
export function resolveImageSrcFresh(imageData) {
|
|
37
|
+
if (imageData.startsWith("data:"))
|
|
38
|
+
return imageData;
|
|
39
|
+
const base = resolveImageSrc(imageData);
|
|
40
|
+
const bump = getImageBump(imageData);
|
|
41
|
+
if (bump <= 0)
|
|
42
|
+
return base;
|
|
43
|
+
// Both URL forms append a cache-bust param. The static mount form
|
|
44
|
+
// uses `?v=`, the API form already has `?path=` so we use `&v=`.
|
|
45
|
+
const sep = base.includes("?") ? "&" : "?";
|
|
46
|
+
return `${base}${sep}v=${bump}`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare function rewriteImgSrcAttrsInHtml(html: string, basePath: string): string;
|
|
2
|
+
/**
|
|
3
|
+
* Rewrite `` image refs in markdown text so workspace-
|
|
4
|
+
* relative paths render through `/api/files/raw`.
|
|
5
|
+
*
|
|
6
|
+
* @param markdown Markdown source text.
|
|
7
|
+
* @param basePath The workspace-relative directory of the markdown
|
|
8
|
+
* file (e.g. `"wiki/pages"` for `wiki/pages/foo.md`). Omit or pass
|
|
9
|
+
* `""` when resolving refs against the workspace root.
|
|
10
|
+
*
|
|
11
|
+
* Also rewrites the `src` attribute of raw `<img>` tags inside HTML
|
|
12
|
+
* blocks / inline HTML so a page mixing both syntaxes resolves the
|
|
13
|
+
* same way. Markdown image syntax inside code blocks / inline code
|
|
14
|
+
* spans is left alone.
|
|
15
|
+
*
|
|
16
|
+
* Absolute URLs, data URIs, and existing API paths pass through
|
|
17
|
+
* untouched. Refs that would escape the workspace root (more `..`
|
|
18
|
+
* than `basePath` depth) also pass through untouched — they would
|
|
19
|
+
* 404 regardless, and passing through lets the user see the broken
|
|
20
|
+
* ref instead of silently re-pointing it.
|
|
21
|
+
*/
|
|
22
|
+
export declare function rewriteMarkdownImageRefs(markdown: string, basePath?: string): string;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import { resolveImageSrc } from "./resolve";
|
|
3
|
+
import { transformResolvableUrlsInHtml } from "./htmlSrcAttrs";
|
|
4
|
+
// Pre-`marked` pass that rewrites workspace-relative image references
|
|
5
|
+
// in markdown source so they render through the backend file server.
|
|
6
|
+
//
|
|
7
|
+
// Without this, a page like `` produces
|
|
8
|
+
// `<img src="../images/foo.png">`, which the browser resolves against
|
|
9
|
+
// the SPA page URL (e.g. `/chat/…foo.png`) and 404s. After this
|
|
10
|
+
// pass, the src becomes `/api/files/raw?path=images/foo.png` which
|
|
11
|
+
// the workspace file server serves.
|
|
12
|
+
//
|
|
13
|
+
// Uses marked's tokenizer to find image refs rather than a raw regex
|
|
14
|
+
// over the source. The regex approach had two problems:
|
|
15
|
+
// - URLs containing `)` (e.g. `Foo_(bar).png`) were truncated at
|
|
16
|
+
// the first close paren.
|
|
17
|
+
// - `` inside fenced code blocks or inline code spans was
|
|
18
|
+
// rewritten even though it's not meant to render as an image.
|
|
19
|
+
// The lexer handles both correctly.
|
|
20
|
+
//
|
|
21
|
+
// Callers that know the markdown file's directory (`basePath`) get
|
|
22
|
+
// correct resolution for `./` and `../` relative refs. Callers that
|
|
23
|
+
// omit `basePath` only resolve refs that are already workspace-rooted
|
|
24
|
+
// (no leading `./` or `../`); relative-with-traversal refs without
|
|
25
|
+
// context would be ambiguous, so they pass through untouched rather
|
|
26
|
+
// than silently pointing at the wrong file.
|
|
27
|
+
//
|
|
28
|
+
// Used by:
|
|
29
|
+
//
|
|
30
|
+
// - `src/plugins/wiki/View.vue`
|
|
31
|
+
// - `src/components/FilesView.vue` (when previewing a .md file)
|
|
32
|
+
// - `src/plugins/markdown/View.vue` (via post-`marked` HTML rewriter)
|
|
33
|
+
function shouldSkip(url) {
|
|
34
|
+
if (url.startsWith("data:"))
|
|
35
|
+
return true;
|
|
36
|
+
if (url.startsWith("http://") || url.startsWith("https://"))
|
|
37
|
+
return true;
|
|
38
|
+
// Already an API route — nothing to do.
|
|
39
|
+
if (url.startsWith("/api/"))
|
|
40
|
+
return true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve `url` relative to `basePath` using posix segment arithmetic.
|
|
45
|
+
* Returns the resolved workspace-relative path, or `null` if the URL
|
|
46
|
+
* escapes the workspace root (more `..` than `basePath` depth).
|
|
47
|
+
*
|
|
48
|
+
* Pure string operation — does not touch the filesystem or use Node's
|
|
49
|
+
* `path` module (this file runs in the browser).
|
|
50
|
+
*/
|
|
51
|
+
function resolveWorkspacePath(basePath, url) {
|
|
52
|
+
// Absolute-within-workspace (e.g. "/images/foo.png") — reset base.
|
|
53
|
+
const isAbsolute = url.startsWith("/");
|
|
54
|
+
const baseSegs = isAbsolute ? [] : basePath.split("/").filter((seg) => seg !== "" && seg !== ".");
|
|
55
|
+
const segs = [...baseSegs];
|
|
56
|
+
const urlSegs = (isAbsolute ? url.slice(1) : url).split("/");
|
|
57
|
+
for (const seg of urlSegs) {
|
|
58
|
+
if (seg === "" || seg === ".")
|
|
59
|
+
continue;
|
|
60
|
+
if (seg === "..") {
|
|
61
|
+
if (segs.length === 0)
|
|
62
|
+
return null;
|
|
63
|
+
segs.pop();
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
segs.push(seg);
|
|
67
|
+
}
|
|
68
|
+
if (segs.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
return segs.join("/");
|
|
71
|
+
}
|
|
72
|
+
// Extract the alt-text span `[...]` from an image ref ``.
|
|
73
|
+
// CommonMark allows balanced nested brackets inside alt (`![x [y]](z)`),
|
|
74
|
+
// which a greedy regex would get wrong — scan with a depth counter and
|
|
75
|
+
// return the slice between the outermost brackets.
|
|
76
|
+
function extractBracketedAlt(raw) {
|
|
77
|
+
if (!raw.startsWith("!["))
|
|
78
|
+
return null;
|
|
79
|
+
let depth = 1;
|
|
80
|
+
for (let i = 2; i < raw.length; i++) {
|
|
81
|
+
const char = raw[i];
|
|
82
|
+
if (char === "[")
|
|
83
|
+
depth++;
|
|
84
|
+
else if (char === "]") {
|
|
85
|
+
depth--;
|
|
86
|
+
if (depth === 0)
|
|
87
|
+
return raw.slice(2, i);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
function rewriteImageToken(token, basePath) {
|
|
93
|
+
const href = (token.href ?? "").trim();
|
|
94
|
+
if (href === "" || shouldSkip(href))
|
|
95
|
+
return null;
|
|
96
|
+
const resolved = resolveWorkspacePath(basePath, href);
|
|
97
|
+
if (resolved === null)
|
|
98
|
+
return null;
|
|
99
|
+
const newHref = resolveImageSrc(resolved);
|
|
100
|
+
// Preserve alt text verbatim — read from the raw so any special
|
|
101
|
+
// characters (brackets, entities) survive unmodified.
|
|
102
|
+
const alt = extractBracketedAlt(token.raw) ?? token.text ?? "";
|
|
103
|
+
if (token.title) {
|
|
104
|
+
// Escape backslashes BEFORE quotes so a title containing `\` (or one
|
|
105
|
+
// ending in `\`) can't break out of the quoted title delimiter.
|
|
106
|
+
const escapedTitle = token.title.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
107
|
+
return ``;
|
|
108
|
+
}
|
|
109
|
+
return ``;
|
|
110
|
+
}
|
|
111
|
+
// Rewrite URL-bearing attributes of every recognised tag inside an
|
|
112
|
+
// HTML fragment, applying the same basePath / shouldSkip /
|
|
113
|
+
// resolveImageSrc pipeline used for `` markdown images.
|
|
114
|
+
// Other attributes (alt, class, style, id, …) are preserved verbatim.
|
|
115
|
+
//
|
|
116
|
+
// Tags + attributes covered (single source of truth at
|
|
117
|
+
// `htmlSrcAttrs.ts:RESOLVABLE_TAG_ATTRS`): `<img src>`, `<source src>`,
|
|
118
|
+
// `<video poster|src>`, `<audio src>`. Add a row there to extend
|
|
119
|
+
// coverage; both this rewriter and the server-side PDF rewriter pick
|
|
120
|
+
// it up automatically (#1011 Stage B).
|
|
121
|
+
//
|
|
122
|
+
// Output URLs come from `resolveImageSrc`, which either returns a
|
|
123
|
+
// mount-rooted path (`/artifacts/images/<file>`) or runs the input
|
|
124
|
+
// through `encodeURIComponent`. `"` becomes `%22`, `'` becomes `%27`,
|
|
125
|
+
// `<` / `>` are encoded — the rewritten attribute can't break out of
|
|
126
|
+
// its own quotes or close the tag.
|
|
127
|
+
//
|
|
128
|
+
// Limitations:
|
|
129
|
+
// - `srcset` (comma-separated descriptor list) is deferred —
|
|
130
|
+
// tracked under #1011 Stage B follow-up.
|
|
131
|
+
// - SVG `<image href>` and CSS `url()` are deferred per plan
|
|
132
|
+
// §修正提案 P3-A.
|
|
133
|
+
// - A regex can't perfectly distinguish a real tag from one
|
|
134
|
+
// embedded in another attribute's value; embedded matches get
|
|
135
|
+
// rewritten too. Harmless because the rewritten URL is encoded
|
|
136
|
+
// safely.
|
|
137
|
+
export function rewriteImgSrcAttrsInHtml(html, basePath) {
|
|
138
|
+
return transformResolvableUrlsInHtml(html, (url) => {
|
|
139
|
+
if (shouldSkip(url))
|
|
140
|
+
return null;
|
|
141
|
+
const resolved = resolveWorkspacePath(basePath, url);
|
|
142
|
+
if (resolved === null)
|
|
143
|
+
return null;
|
|
144
|
+
return resolveImageSrc(resolved);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
function isSkippable(token) {
|
|
148
|
+
return token.type === "code" || token.type === "codespan";
|
|
149
|
+
}
|
|
150
|
+
function getContainerChildren(token) {
|
|
151
|
+
const container = token;
|
|
152
|
+
if (Array.isArray(container.tokens) && container.tokens.length > 0) {
|
|
153
|
+
return container.tokens;
|
|
154
|
+
}
|
|
155
|
+
if (Array.isArray(container.items) && container.items.length > 0) {
|
|
156
|
+
return container.items;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
// Render a container's children back into the output, preserving any
|
|
161
|
+
// structural glue the parent carries outside the children's combined
|
|
162
|
+
// raw span (list markers, blockquote prefixes, trailing newlines).
|
|
163
|
+
// Returns true if the container was rendered via its children, false
|
|
164
|
+
// if the caller should fall back to emitting the parent's raw.
|
|
165
|
+
function renderContainerChildren(raw, children, basePath, out) {
|
|
166
|
+
const joined = children.map((token) => token.raw ?? "").join("");
|
|
167
|
+
if (joined === "")
|
|
168
|
+
return false;
|
|
169
|
+
const idx = raw.indexOf(joined);
|
|
170
|
+
if (idx < 0)
|
|
171
|
+
return false;
|
|
172
|
+
if (idx > 0)
|
|
173
|
+
out.push(raw.slice(0, idx));
|
|
174
|
+
for (const child of children)
|
|
175
|
+
renderToken(child, basePath, out);
|
|
176
|
+
const tail = raw.slice(idx + joined.length);
|
|
177
|
+
if (tail)
|
|
178
|
+
out.push(tail);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
// Recursively render a token back to markdown, rewriting image refs
|
|
182
|
+
// in-place. Code / codespan tokens are emitted verbatim so image-ref
|
|
183
|
+
// syntax inside them stays literal. HTML tokens get a separate pass
|
|
184
|
+
// (`rewriteImgSrcAttrsInHtml`) so raw `<img>` tags route through the
|
|
185
|
+
// same basePath + shouldSkip pipeline as the markdown image syntax.
|
|
186
|
+
// Token-tree recursion uses the lexer's structural knowledge and never
|
|
187
|
+
// crosses a skip boundary — unlike the earlier `indexOf` splice which
|
|
188
|
+
// could rewrite a code-block literal when the same ref appeared in
|
|
189
|
+
// real markdown.
|
|
190
|
+
function renderToken(token, basePath, out) {
|
|
191
|
+
if (isSkippable(token)) {
|
|
192
|
+
out.push(token.raw);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (token.type === "image") {
|
|
196
|
+
const replacement = rewriteImageToken(token, basePath);
|
|
197
|
+
out.push(replacement ?? token.raw);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (token.type === "html") {
|
|
201
|
+
// Block / inline HTML — rewrite raw <img> tags inside before
|
|
202
|
+
// emitting. Markdown image syntax () is handled by the
|
|
203
|
+
// image-token branch above; this branch covers the HTML-fallback
|
|
204
|
+
// path (#1011 Stage A). Fall back to verbatim raw if `raw` is
|
|
205
|
+
// unexpectedly missing — defensive against future marked changes.
|
|
206
|
+
const raw = token.raw ?? "";
|
|
207
|
+
out.push(rewriteImgSrcAttrsInHtml(raw, basePath));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const raw = token.raw ?? "";
|
|
211
|
+
const children = getContainerChildren(token);
|
|
212
|
+
if (children && renderContainerChildren(raw, children, basePath, out)) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
out.push(raw);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Rewrite `` image refs in markdown text so workspace-
|
|
219
|
+
* relative paths render through `/api/files/raw`.
|
|
220
|
+
*
|
|
221
|
+
* @param markdown Markdown source text.
|
|
222
|
+
* @param basePath The workspace-relative directory of the markdown
|
|
223
|
+
* file (e.g. `"wiki/pages"` for `wiki/pages/foo.md`). Omit or pass
|
|
224
|
+
* `""` when resolving refs against the workspace root.
|
|
225
|
+
*
|
|
226
|
+
* Also rewrites the `src` attribute of raw `<img>` tags inside HTML
|
|
227
|
+
* blocks / inline HTML so a page mixing both syntaxes resolves the
|
|
228
|
+
* same way. Markdown image syntax inside code blocks / inline code
|
|
229
|
+
* spans is left alone.
|
|
230
|
+
*
|
|
231
|
+
* Absolute URLs, data URIs, and existing API paths pass through
|
|
232
|
+
* untouched. Refs that would escape the workspace root (more `..`
|
|
233
|
+
* than `basePath` depth) also pass through untouched — they would
|
|
234
|
+
* 404 regardless, and passing through lets the user see the broken
|
|
235
|
+
* ref instead of silently re-pointing it.
|
|
236
|
+
*/
|
|
237
|
+
export function rewriteMarkdownImageRefs(markdown, basePath = "") {
|
|
238
|
+
const tokens = marked.lexer(markdown);
|
|
239
|
+
const parts = [];
|
|
240
|
+
for (const token of tokens)
|
|
241
|
+
renderToken(token, basePath, parts);
|
|
242
|
+
return parts.join("");
|
|
243
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,3 +8,7 @@ export * from "./image/cacheBust.js";
|
|
|
8
8
|
export * from "./image/htmlSrcAttrs.js";
|
|
9
9
|
export * from "./dom/externalLink.js";
|
|
10
10
|
export * from "./files/filename.js";
|
|
11
|
+
export * from "./image/resolve.js";
|
|
12
|
+
export * from "./image/rewriteMarkdownImageRefs.js";
|
|
13
|
+
export * from "./markdown/mermaidRender.js";
|
|
14
|
+
export * from "./markdown/mermaidExtension.js";
|
package/dist/index.js
CHANGED
|
@@ -8,3 +8,7 @@ export * from "./image/cacheBust.js";
|
|
|
8
8
|
export * from "./image/htmlSrcAttrs.js";
|
|
9
9
|
export * from "./dom/externalLink.js";
|
|
10
10
|
export * from "./files/filename.js";
|
|
11
|
+
export * from "./image/resolve.js";
|
|
12
|
+
export * from "./image/rewriteMarkdownImageRefs.js";
|
|
13
|
+
export * from "./markdown/mermaidRender.js";
|
|
14
|
+
export * from "./markdown/mermaidExtension.js";
|
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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 `"`). It also stamps `token.escaped = true`. If we
|
|
43
|
+
// re-escape here, `&` in `"` becomes `&`, the browser
|
|
44
|
+
// decodes `&quot;` back to `"` on parse, and mermaid
|
|
45
|
+
// sees literal `"` 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.
|
|
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",
|
|
@@ -33,12 +33,15 @@
|
|
|
33
33
|
"license": "MIT",
|
|
34
34
|
"author": "Receptron Team",
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"marked": "^18.0.6",
|
|
36
37
|
"js-yaml": "^5.2.1"
|
|
37
38
|
},
|
|
38
39
|
"peerDependencies": {
|
|
40
|
+
"mermaid": "^11.16.0",
|
|
39
41
|
"vue": "^3.5.0"
|
|
40
42
|
},
|
|
41
43
|
"devDependencies": {
|
|
44
|
+
"mermaid": "^11.16.0",
|
|
42
45
|
"typescript": "^6.0.3",
|
|
43
46
|
"vue": "^3.5.40"
|
|
44
47
|
}
|