diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock
index 448a14620..43e70c552 100644
--- a/web/workspace/deno.lock
+++ b/web/workspace/deno.lock
@@ -22,6 +22,7 @@
"npm:devalue@5.6.4": "5.6.4",
"npm:gen-interface-jp@0.8.0": "0.8.0",
"npm:happy-dom@18.0.1": "18.0.1",
+ "npm:marked@18.0.13": "18.0.13",
"npm:set-cookie-parser@2.7.2": "2.7.2",
"npm:shiki@4.4.2": "4.4.2",
"npm:sv@*": "0.10.8",
diff --git a/web/workspace/src/lib/workspace/console/MarkdownLink.svelte b/web/workspace/src/lib/workspace/console/MarkdownLink.svelte
index fd1cd2bf4..7f3f13ada 100644
--- a/web/workspace/src/lib/workspace/console/MarkdownLink.svelte
+++ b/web/workspace/src/lib/workspace/console/MarkdownLink.svelte
@@ -10,8 +10,23 @@
title?: string;
children?: Snippet;
} = $props();
+
+ let safeHref = $derived(httpHref(href));
+
+ function httpHref(value: string): string | undefined {
+ try {
+ const protocol = new URL(value).protocol.toLowerCase();
+ return protocol === "http:" || protocol === "https:" ? value : undefined;
+ } catch {
+ return undefined;
+ }
+ }
-
+{#if safeHref}
+
+ {@render children?.()}
+
+{:else}
{@render children?.()}
-
+{/if}
diff --git a/web/workspace/src/lib/workspace/console/RichMarkdown.browser.spec.ts b/web/workspace/src/lib/workspace/console/RichMarkdown.browser.spec.ts
index 42650f66a..788eabdbd 100644
--- a/web/workspace/src/lib/workspace/console/RichMarkdown.browser.spec.ts
+++ b/web/workspace/src/lib/workspace/console/RichMarkdown.browser.spec.ts
@@ -6,42 +6,22 @@ import RichMarkdown from "./RichMarkdown.svelte";
afterEach(cleanup);
-test("preserves safe links, blocks raw HTML, and renders Shiki code", async () => {
- const source = [
- "[unsafe](javascript:alert(1)) and [safe](https://example.com)",
- "",
- '
',
- "",
- "```ts",
- "const answer: number = 42;",
- "```",
- ].join("\n");
- const { container } = render(RichMarkdown, {
- text: source,
- streamId: "message-security",
- });
-
- await waitFor(() => {
- expect(container.querySelectorAll("a")).toHaveLength(2);
- expect(container.querySelector("pre.shiki code span")).not.toBeNull();
- });
-
- const [unsafe, safe] = container.querySelectorAll("a");
- expect(unsafe.getAttribute("href")).toBe("");
- expect(safe.getAttribute("href")).toBe("https://example.com");
- expect(safe.getAttribute("target")).toBe("_blank");
- expect(safe.getAttribute("rel")).toBe("noreferrer");
- expect(container.querySelector("img")).toBeNull();
- expect(container.querySelector("script")).toBeNull();
-});
-
-test("keeps completed DOM nodes while a later markdown block grows", async () => {
+test("keeps completed block nodes while a trailing inline token grows", async () => {
const initial = [
"# Stable heading",
"",
"This paragraph is already complete.",
"",
- "The final paragraph is stream",
+ "- stable list item",
+ "- another item",
+ "",
+ "> stable quote",
+ "",
+ "```ts",
+ "const answer: number = 42;",
+ "```",
+ "",
+ "The final paragraph has **partial",
].join("\n");
const { container, rerender } = render(RichMarkdown, {
text: initial,
@@ -50,21 +30,163 @@ test("keeps completed DOM nodes while a later markdown block grows", async () =>
await waitFor(() => {
expect(container.querySelector("h1")?.textContent).toBe("Stable heading");
- expect(container.querySelectorAll("p")).toHaveLength(2);
+ expect(container.querySelector("ul")).not.toBeNull();
+ expect(container.querySelector("blockquote")).not.toBeNull();
+ expect(container.querySelector("pre.shiki code span")).not.toBeNull();
});
- const heading = container.querySelector("h1");
- const completedParagraph = container.querySelector("p");
+ const stableNodes = [
+ container.querySelector("h1"),
+ container.querySelector("p"),
+ container.querySelector("ul"),
+ container.querySelector("blockquote"),
+ container.querySelector("pre"),
+ ];
await rerender({
- text: `${initial}ed incrementally.`,
+ text: `${initial} emphasis** and a [safe link](https://example.com).`,
streamId: "message-1",
});
await waitFor(() => {
- expect(container.querySelectorAll("p")[1]?.textContent).toBe(
- "The final paragraph is streamed incrementally.",
+ expect(container.querySelector("strong")?.textContent).toBe(
+ "partial emphasis",
);
+ expect(container.querySelector('a[href="https://example.com"]')).not
+ .toBeNull();
});
- expect(container.querySelector("h1")).toBe(heading);
- expect(container.querySelector("p")).toBe(completedParagraph);
+ expect(container.querySelector("h1")).toBe(stableNodes[0]);
+ expect(container.querySelector("p")).toBe(stableNodes[1]);
+ expect(container.querySelector("ul")).toBe(stableNodes[2]);
+ expect(container.querySelector("blockquote")).toBe(stableNodes[3]);
+ expect(container.querySelector("pre")).toBe(stableNodes[4]);
});
+
+test("resets the DOM for non-prefix rewrites and stream identity changes", async () => {
+ const { container, rerender } = render(RichMarkdown, {
+ text: "# Original\n\nOriginal body.",
+ streamId: "message-1",
+ });
+ await waitFor(() => {
+ expect(container.querySelector("h1")?.textContent).toBe("Original");
+ });
+ await rerender({
+ text: "# Corrected\n\nRewritten body.",
+ streamId: "message-1",
+ });
+ await waitFor(() => {
+ expect(container.querySelector("h1")?.textContent).toBe("Corrected");
+ });
+ expect(container.textContent).not.toContain("Original body.");
+
+ await rerender({
+ text: "# Corrected\n\nRewritten body for a new message.",
+ streamId: "message-2",
+ });
+ await waitFor(() => {
+ expect(container.querySelector("p")?.textContent).toBe(
+ "Rewritten body for a new message.",
+ );
+ });
+ expect(container.querySelector("h1")?.textContent).toBe("Corrected");
+ expect(container.textContent).not.toContain("Original body.");
+});
+
+test("preserves line breaks and the HTTP(S)-only link boundary", async () => {
+ const source = [
+ "first line\nsecond line",
+ "",
+ "[https](https://example.com) [http](http://example.com)",
+ "[script](javascript:alert(1)) [data](data:text/html,hello)",
+ "[mail](mailto:test@example.com) [relative](/internal)",
+ "",
+ '
',
+ ].join("\n");
+ const { container } = render(RichMarkdown, {
+ text: source,
+ streamId: "message-security",
+ });
+
+ await waitFor(() => {
+ expect(container.querySelectorAll("a")).toHaveLength(2);
+ expect(container.querySelector("br")).not.toBeNull();
+ });
+
+ const links = container.querySelectorAll("a");
+ expect(links[0].getAttribute("href")).toBe("https://example.com");
+ expect(links[1].getAttribute("href")).toBe("http://example.com");
+ expect(links[0].getAttribute("target")).toBe("_blank");
+ expect(links[0].getAttribute("rel")).toBe("noreferrer");
+ expect(container.textContent).toContain("script");
+ expect(container.textContent).toContain("relative");
+ expect(container.querySelector("img")).toBeNull();
+ expect(container.querySelector("script")).toBeNull();
+});
+
+test("keeps prior content through an unclosed fence and unknown-language fallback", async () => {
+ const initial = [
+ "Stable paragraph.",
+ "",
+ "```not-a-real-language",
+ "raw ",
+ ].join("\n");
+ const { container, rerender } = render(RichMarkdown, {
+ text: initial,
+ streamId: "message-fence",
+ });
+ await waitFor(() => {
+ expect(container.querySelector("pre.shiki-fallback code")?.textContent)
+ .toContain(
+ "raw ",
+ );
+ });
+ const stableParagraph = container.querySelector("p");
+
+ await rerender({
+ text: `${initial}\n\`\`\`\n\nAfter the fence.`,
+ streamId: "message-fence",
+ });
+ await waitFor(() => {
+ expect(container.querySelectorAll("p")).toHaveLength(2);
+ expect(container.querySelectorAll("p")[1]?.textContent).toBe(
+ "After the fence.",
+ );
+ });
+ expect(container.querySelector("p")).toBe(stableParagraph);
+ expect(container.querySelector("pre.shiki-fallback code")?.textContent)
+ .toContain(
+ "raw ",
+ );
+});
+
+test("reuses early DOM nodes across a long streaming append", async () => {
+ const firstBatch = Array.from(
+ { length: 80 },
+ (_, index) => `Paragraph ${index}.`,
+ );
+ const { container, rerender } = render(RichMarkdown, {
+ text: firstBatch.join("\n\n"),
+ streamId: "message-long",
+ });
+ await waitFor(
+ () => expect(container.querySelectorAll("p")).toHaveLength(80),
+ { timeout: 5_000 },
+ );
+ const first = container.querySelectorAll("p")[0];
+ const middle = container.querySelectorAll("p")[40];
+
+ const secondBatch = Array.from(
+ { length: 40 },
+ (_, index) => `Paragraph ${index + 80}.`,
+ );
+ await rerender({
+ text: [...firstBatch, ...secondBatch].join("\n\n"),
+ streamId: "message-long",
+ });
+ await waitFor(
+ () => expect(container.querySelectorAll("p")).toHaveLength(120),
+ { timeout: 5_000 },
+ );
+
+ expect(container.querySelectorAll("p")[0]).toBe(first);
+ expect(container.querySelectorAll("p")[40]).toBe(middle);
+}, 10_000);
diff --git a/web/workspace/src/lib/workspace/console/RichMarkdown.svelte b/web/workspace/src/lib/workspace/console/RichMarkdown.svelte
index c609ec121..93772d795 100644
--- a/web/workspace/src/lib/workspace/console/RichMarkdown.svelte
+++ b/web/workspace/src/lib/workspace/console/RichMarkdown.svelte
@@ -23,6 +23,7 @@
import SvelteMarkdown, {
buildUnsupportedHTML,
type Renderers,
+ type SvelteMarkdownProps,
} from "@humanspeak/svelte-markdown";
import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki";
import MarkdownLink from "$lib/workspace/console/MarkdownLink.svelte";
@@ -35,6 +36,11 @@
let { text, streamId = "static", class: className = "" }: Props = $props();
+ const options = {
+ breaks: true,
+ gfm: true,
+ } satisfies NonNullable;
+
const renderers = {
code: ShikiCode,
html: buildUnsupportedHTML(),
@@ -43,7 +49,7 @@
-
+