test(workspace): preserve markdown compatibility boundaries
This commit is contained in:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<a {href} {title} target="_blank" rel="noreferrer">
|
||||
{#if safeHref}
|
||||
<a href={safeHref} {title} target="_blank" rel="noreferrer">
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -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)",
|
||||
"",
|
||||
'<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 () => {
|
||||
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)",
|
||||
"",
|
||||
'<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, {
|
||||
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<SvelteMarkdownProps["options"]>;
|
||||
|
||||
const renderers = {
|
||||
code: ShikiCode,
|
||||
html: buildUnsupportedHTML(),
|
||||
@@ -43,7 +49,7 @@
|
||||
</script>
|
||||
|
||||
<div class={`rich-markdown ${className}`}>
|
||||
<SvelteMarkdown source={text} {streamId} {renderers} streaming />
|
||||
<SvelteMarkdown source={text} {streamId} {options} {renderers} streaming />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -98,7 +104,8 @@
|
||||
color: var(--tui-cyan);
|
||||
}
|
||||
|
||||
:global(.rich-markdown .shiki) {
|
||||
:global(.rich-markdown .shiki),
|
||||
:global(.rich-markdown .shiki-fallback) {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.65rem;
|
||||
overflow: auto;
|
||||
|
||||
Reference in New Issue
Block a user