@mulmoclaude/markdown-utils 1.0.0 → 1.1.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,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 `![alt](path)` 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 `![chart](../images/foo.png)` 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
+ // - `![x](y)` 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 `![alt](url...)`.
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 `![${alt}](${newHref} "${escapedTitle}")`;
108
+ }
109
+ return `![${alt}](${newHref})`;
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 `![alt](url)` 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 (![alt](url)) 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 `![alt](path)` 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,5 @@ 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";
package/dist/index.js CHANGED
@@ -8,3 +8,5 @@ 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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmoclaude/markdown-utils",
3
- "version": "1.0.0",
3
+ "version": "1.1.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,6 +33,7 @@
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": {