test(workspace): preserve markdown compatibility boundaries

This commit is contained in:
2026-09-18 18:50:57 +09:00
parent 092aea62d2
commit d1161bd1b5
4 changed files with 188 additions and 43 deletions
+1
View File
@@ -22,6 +22,7 @@
"npm:devalue@5.6.4": "5.6.4", "npm:devalue@5.6.4": "5.6.4",
"npm:gen-interface-jp@0.8.0": "0.8.0", "npm:gen-interface-jp@0.8.0": "0.8.0",
"npm:happy-dom@18.0.1": "18.0.1", "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:set-cookie-parser@2.7.2": "2.7.2",
"npm:shiki@4.4.2": "4.4.2", "npm:shiki@4.4.2": "4.4.2",
"npm:sv@*": "0.10.8", "npm:sv@*": "0.10.8",
@@ -10,8 +10,23 @@
title?: string; title?: string;
children?: Snippet; children?: Snippet;
} = $props(); } = $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;
}
}
</script> </script>
<a {href} {title} target="_blank" rel="noreferrer"> {#if safeHref}
<a href={safeHref} {title} target="_blank" rel="noreferrer">
{@render children?.()} {@render children?.()}
</a> </a>
{:else}
{@render children?.()}
{/if}
@@ -6,42 +6,22 @@ import RichMarkdown from "./RichMarkdown.svelte";
afterEach(cleanup); afterEach(cleanup);
test("preserves safe links, blocks raw HTML, and renders Shiki code", async () => { test("keeps completed block nodes while a trailing inline token grows", async () => {
const source = [
"[unsafe](javascript:alert(1)) and [safe](https://example.com)",
"",
'<img src="x" onerror="alert(1)">',
"",
"```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 () => {
const initial = [ const initial = [
"# Stable heading", "# Stable heading",
"", "",
"This paragraph is already complete.", "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"); ].join("\n");
const { container, rerender } = render(RichMarkdown, { const { container, rerender } = render(RichMarkdown, {
text: initial, text: initial,
@@ -50,21 +30,163 @@ test("keeps completed DOM nodes while a later markdown block grows", async () =>
await waitFor(() => { await waitFor(() => {
expect(container.querySelector("h1")?.textContent).toBe("Stable heading"); 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 stableNodes = [
const completedParagraph = container.querySelector("p"); container.querySelector("h1"),
container.querySelector("p"),
container.querySelector("ul"),
container.querySelector("blockquote"),
container.querySelector("pre"),
];
await rerender({ await rerender({
text: `${initial}ed incrementally.`, text: `${initial} emphasis** and a [safe link](https://example.com).`,
streamId: "message-1", streamId: "message-1",
}); });
await waitFor(() => { await waitFor(() => {
expect(container.querySelectorAll("p")[1]?.textContent).toBe( expect(container.querySelector("strong")?.textContent).toBe(
"The final paragraph is streamed incrementally.", "partial emphasis",
); );
expect(container.querySelector('a[href="https://example.com"]')).not
.toBeNull();
}); });
expect(container.querySelector("h1")).toBe(heading); expect(container.querySelector("h1")).toBe(stableNodes[0]);
expect(container.querySelector("p")).toBe(completedParagraph); 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)",
"",
'<img src="x" onerror="alert(1)">',
].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 <text>",
].join("\n");
const { container, rerender } = render(RichMarkdown, {
text: initial,
streamId: "message-fence",
});
await waitFor(() => {
expect(container.querySelector("pre.shiki-fallback code")?.textContent)
.toContain(
"raw <text>",
);
});
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 <text>",
);
});
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);
@@ -23,6 +23,7 @@
import SvelteMarkdown, { import SvelteMarkdown, {
buildUnsupportedHTML, buildUnsupportedHTML,
type Renderers, type Renderers,
type SvelteMarkdownProps,
} from "@humanspeak/svelte-markdown"; } from "@humanspeak/svelte-markdown";
import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki"; import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki";
import MarkdownLink from "$lib/workspace/console/MarkdownLink.svelte"; import MarkdownLink from "$lib/workspace/console/MarkdownLink.svelte";
@@ -35,6 +36,11 @@
let { text, streamId = "static", class: className = "" }: Props = $props(); let { text, streamId = "static", class: className = "" }: Props = $props();
const options = {
breaks: true,
gfm: true,
} satisfies NonNullable<SvelteMarkdownProps["options"]>;
const renderers = { const renderers = {
code: ShikiCode, code: ShikiCode,
html: buildUnsupportedHTML(), html: buildUnsupportedHTML(),
@@ -43,7 +49,7 @@
</script> </script>
<div class={`rich-markdown ${className}`}> <div class={`rich-markdown ${className}`}>
<SvelteMarkdown source={text} {streamId} {renderers} streaming /> <SvelteMarkdown source={text} {streamId} {options} {renderers} streaming />
</div> </div>
<style> <style>
@@ -98,7 +104,8 @@
color: var(--tui-cyan); color: var(--tui-cyan);
} }
:global(.rich-markdown .shiki) { :global(.rich-markdown .shiki),
:global(.rich-markdown .shiki-fallback) {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 0.65rem; border-radius: 0.65rem;
overflow: auto; overflow: auto;