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: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;
|
||||||
|
|||||||
Reference in New Issue
Block a user