@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 `` 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
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mulmoclaude/markdown-utils",
|
|
3
|
-
"version": "1.
|
|
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": {
|