diff options
Diffstat (limited to 'src/features/markdown/ui')
| -rw-r--r-- | src/features/markdown/ui/Markdown.svelte | 58 | ||||
| -rw-r--r-- | src/features/markdown/ui/markdown.test.ts | 40 |
2 files changed, 98 insertions, 0 deletions
diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte new file mode 100644 index 0000000..b828ab9 --- /dev/null +++ b/src/features/markdown/ui/Markdown.svelte @@ -0,0 +1,58 @@ +<script lang="ts"> + import { renderMarkdown } from "../logic/markdown"; + + let { + text, + streaming = false, + }: { + text: string; + /** Balance dangling delimiters while the message is still generating. */ + streaming?: boolean; + } = $props(); + + // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown. + const html = $derived(renderMarkdown(text, { streaming })); + + let container: HTMLElement; + + // One delegated listener on the stable container handles every code block's + // copy button — including blocks re-created when `html` changes (streaming), + // since the listener lives on the container, not the buttons. Clipboard is the + // edge effect; absent (insecure context) → no-op. + $effect(() => { + const el = container; + if (el === undefined) return; + + const onClick = (event: Event): void => { + const target = event.target; + if (!(target instanceof Element)) return; + const button = target.closest<HTMLButtonElement>("[data-copy]"); + if (button === null) return; + + const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? ""; + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + + void clipboard + .writeText(code) + .then(() => { + const prev = button.textContent; + button.textContent = "Copied"; + setTimeout(() => { + button.textContent = prev; + }, 1200); + }) + .catch(() => { + // Clipboard denied — leave the button as-is. + }); + }; + + el.addEventListener("click", onClick); + return () => el.removeEventListener("click", onClick); + }); +</script> + +<div class="markdown-body" bind:this={container}> + <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. --> + {@html html} +</div> diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts new file mode 100644 index 0000000..e34a4af --- /dev/null +++ b/src/features/markdown/ui/markdown.test.ts @@ -0,0 +1,40 @@ +import { fireEvent, render, screen } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; +import Markdown from "./Markdown.svelte"; + +describe("Markdown", () => { + it("renders markdown into a .markdown-body container", () => { + const { container } = render(Markdown, { props: { text: "# Hello\n\n**hi**" } }); + + expect(container.querySelector(".markdown-body")).not.toBeNull(); + expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); + expect(container.querySelector("strong")?.textContent).toBe("hi"); + }); + + it("strips dangerous markup", () => { + const { container } = render(Markdown, { + props: { text: "before <script>alert(1)</script> after" }, + }); + + expect(container.querySelector("script")).toBeNull(); + expect(container.textContent).toContain("before"); + }); + + it("renders a copy button on a code block that copies the code to the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + + const { container } = render(Markdown, { + props: { text: "```js\nconst x = 1;\n```" }, + }); + + const button = container.querySelector<HTMLElement>("[data-copy]"); + expect(button).not.toBeNull(); + if (button === null) throw new Error("expected a copy button"); + + await fireEvent.click(button); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;"); + }); +}); |
