diff options
Diffstat (limited to 'src/features/markdown')
| -rw-r--r-- | src/features/markdown/index.ts | 4 | ||||
| -rw-r--r-- | src/features/markdown/logic/markdown.test.ts | 106 | ||||
| -rw-r--r-- | src/features/markdown/logic/markdown.ts | 174 | ||||
| -rw-r--r-- | src/features/markdown/ui/Markdown.svelte | 106 | ||||
| -rw-r--r-- | src/features/markdown/ui/markdown.test.ts | 52 |
5 files changed, 221 insertions, 221 deletions
diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts index f5406b2..ff3aefa 100644 --- a/src/features/markdown/index.ts +++ b/src/features/markdown/index.ts @@ -3,6 +3,6 @@ export { default as Markdown } from "./ui/Markdown.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "markdown", - description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)", + name: "markdown", + description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)", } as const; diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts index 7dbb878..54b0086 100644 --- a/src/features/markdown/logic/markdown.test.ts +++ b/src/features/markdown/logic/markdown.test.ts @@ -2,57 +2,57 @@ import { describe, expect, it } from "vitest"; import { renderMarkdown } from "./markdown"; describe("renderMarkdown", () => { - it("renders GFM markdown (headings, emphasis)", () => { - const html = renderMarkdown("# Title\n\nSome **bold** text."); - expect(html).toContain("<h1"); - expect(html).toContain("Title"); - expect(html).toContain("<strong>bold</strong>"); - }); - - it("highlights fenced code for a known language", () => { - const html = renderMarkdown("```javascript\nconst x = 1;\n```"); - expect(html).toContain("language-javascript"); - expect(html).toContain("hljs-keyword"); // `const` got highlighted - }); - - it("resolves language aliases (js -> javascript)", () => { - const html = renderMarkdown("```js\nconst x = 1;\n```"); - expect(html).toContain("hljs-keyword"); - }); - - it("escapes code for an unknown language without throwing", () => { - const html = renderMarkdown("```nope\n<b>x</b>\n```"); - expect(html).toContain("<b>"); - }); - - it("sanitizes dangerous HTML", () => { - const html = renderMarkdown("Hi <script>alert(1)</script> there"); - expect(html).not.toContain("<script>"); - expect(html).toContain("Hi"); - }); - - it("balances dangling bold emphasis while streaming", () => { - expect(renderMarkdown("a **bold", { streaming: true })).toContain("<strong>bold</strong>"); - }); - - it("does not balance delimiters when not streaming", () => { - expect(renderMarkdown("a **bold")).not.toContain("<strong>"); - }); - - it("wraps fenced code blocks with a copy button", () => { - const html = renderMarkdown("```js\nconst x = 1;\n```"); - expect(html).toContain("code-block"); - expect(html).toContain("data-copy"); - expect(html).toContain("<pre>"); - }); - - it("does not add a copy button to inline code", () => { - const html = renderMarkdown("use `npm run dev` please"); - expect(html).not.toContain("data-copy"); - expect(html).toContain("<code>npm run dev</code>"); - }); - - it("returns an empty string for empty input", () => { - expect(renderMarkdown("")).toBe(""); - }); + it("renders GFM markdown (headings, emphasis)", () => { + const html = renderMarkdown("# Title\n\nSome **bold** text."); + expect(html).toContain("<h1"); + expect(html).toContain("Title"); + expect(html).toContain("<strong>bold</strong>"); + }); + + it("highlights fenced code for a known language", () => { + const html = renderMarkdown("```javascript\nconst x = 1;\n```"); + expect(html).toContain("language-javascript"); + expect(html).toContain("hljs-keyword"); // `const` got highlighted + }); + + it("resolves language aliases (js -> javascript)", () => { + const html = renderMarkdown("```js\nconst x = 1;\n```"); + expect(html).toContain("hljs-keyword"); + }); + + it("escapes code for an unknown language without throwing", () => { + const html = renderMarkdown("```nope\n<b>x</b>\n```"); + expect(html).toContain("<b>"); + }); + + it("sanitizes dangerous HTML", () => { + const html = renderMarkdown("Hi <script>alert(1)</script> there"); + expect(html).not.toContain("<script>"); + expect(html).toContain("Hi"); + }); + + it("balances dangling bold emphasis while streaming", () => { + expect(renderMarkdown("a **bold", { streaming: true })).toContain("<strong>bold</strong>"); + }); + + it("does not balance delimiters when not streaming", () => { + expect(renderMarkdown("a **bold")).not.toContain("<strong>"); + }); + + it("wraps fenced code blocks with a copy button", () => { + const html = renderMarkdown("```js\nconst x = 1;\n```"); + expect(html).toContain("code-block"); + expect(html).toContain("data-copy"); + expect(html).toContain("<pre>"); + }); + + it("does not add a copy button to inline code", () => { + const html = renderMarkdown("use `npm run dev` please"); + expect(html).not.toContain("data-copy"); + expect(html).toContain("<code>npm run dev</code>"); + }); + + it("returns an empty string for empty input", () => { + expect(renderMarkdown("")).toBe(""); + }); }); diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts index 3a6e5a6..ad8a8bd 100644 --- a/src/features/markdown/logic/markdown.ts +++ b/src/features/markdown/logic/markdown.ts @@ -39,88 +39,88 @@ import { markedHighlight } from "marked-highlight"; // Hot set: registered eagerly so common code blocks highlight on first paint. const HOT_LANGUAGES: Record<string, LanguageFn> = { - bash, - c, - cpp, - csharp, - css, - go, - java, - javascript, - json, - markdown: markdownLang, - php, - plaintext, - python, - ruby, - rust, - shell, - sql, - typescript, - xml, - yaml, + bash, + c, + cpp, + csharp, + css, + go, + java, + javascript, + json, + markdown: markdownLang, + php, + plaintext, + python, + ruby, + rust, + shell, + sql, + typescript, + xml, + yaml, }; for (const [name, lang] of Object.entries(HOT_LANGUAGES)) { - hljs.registerLanguage(name, lang); + hljs.registerLanguage(name, lang); } // Normalize common fence aliases to canonical highlight.js names. const ALIASES: Record<string, string> = { - js: "javascript", - jsx: "javascript", - mjs: "javascript", - cjs: "javascript", - ts: "typescript", - tsx: "typescript", - py: "python", - py3: "python", - rb: "ruby", - sh: "bash", - zsh: "bash", - yml: "yaml", - "c++": "cpp", - cxx: "cpp", - "c#": "csharp", - cs: "csharp", - htm: "xml", - html: "xml", - svg: "xml", - md: "markdown", - mdx: "markdown", - golang: "go", - rs: "rust", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + ts: "typescript", + tsx: "typescript", + py: "python", + py3: "python", + rb: "ruby", + sh: "bash", + zsh: "bash", + yml: "yaml", + "c++": "cpp", + cxx: "cpp", + "c#": "csharp", + cs: "csharp", + htm: "xml", + html: "xml", + svg: "xml", + md: "markdown", + mdx: "markdown", + golang: "go", + rs: "rust", }; function normalizeLang(lang: string): string { - const lower = lang.toLowerCase().trim(); - return ALIASES[lower] ?? lower; + const lower = lang.toLowerCase().trim(); + return ALIASES[lower] ?? lower; } function escapeHtml(s: string): string { - return s - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } const md = new Marked( - markedHighlight({ - emptyLangClass: "hljs", - langPrefix: "hljs language-", - highlight(code: string, lang: string): string { - if (!lang) return escapeHtml(code); - const name = normalizeLang(lang); - if (!hljs.getLanguage(name)) return escapeHtml(code); - try { - return hljs.highlight(code, { language: name, ignoreIllegals: true }).value; - } catch { - return escapeHtml(code); - } - }, - }), - { gfm: true, breaks: true }, + markedHighlight({ + emptyLangClass: "hljs", + langPrefix: "hljs language-", + highlight(code: string, lang: string): string { + if (!lang) return escapeHtml(code); + const name = normalizeLang(lang); + if (!hljs.getLanguage(name)) return escapeHtml(code); + try { + return hljs.highlight(code, { language: name, ignoreIllegals: true }).value; + } catch { + return escapeHtml(code); + } + }, + }), + { gfm: true, breaks: true }, ); /** @@ -128,14 +128,14 @@ const md = new Marked( * partial text renders cleanly instead of flashing raw markers. */ function closeOpenDelimiters(src: string): string { - let out = src; - const fenceCount = (out.match(/^```/gm) ?? []).length; - if (fenceCount % 2 !== 0) out += "\n```"; - const boldCount = (out.match(/\*\*/g) ?? []).length; - if (boldCount % 2 !== 0) out += "**"; - const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length; - if (inlineCode % 2 !== 0) out += "`"; - return out; + let out = src; + const fenceCount = (out.match(/^```/gm) ?? []).length; + if (fenceCount % 2 !== 0) out += "\n```"; + const boldCount = (out.match(/\*\*/g) ?? []).length; + if (boldCount % 2 !== 0) out += "**"; + const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length; + if (inlineCode % 2 !== 0) out += "`"; + return out; } // Wrap each fenced code block (`<pre>…</pre>`) in a positioned container with a @@ -144,22 +144,22 @@ function closeOpenDelimiters(src: string): string { // `data-copy` is the delegation hook the component listens for; DOMPurify keeps // `<button>` + `data-*` by default. Inline `<code>` has no `<pre>`, so it's untouched. const COPY_BUTTON = - '<button type="button" data-copy aria-label="Copy code"' + - ' class="copy-btn btn btn-xs absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">Copy</button>'; + '<button type="button" data-copy aria-label="Copy code"' + + ' class="copy-btn btn btn-xs absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">Copy</button>'; function addCopyButtons(html: string): string { - return html - .replace(/<pre>/g, `<div class="code-block group relative">${COPY_BUTTON}<pre>`) - .replace(/<\/pre>/g, "</pre></div>"); + return html + .replace(/<pre>/g, `<div class="code-block group relative">${COPY_BUTTON}<pre>`) + .replace(/<\/pre>/g, "</pre></div>"); } /** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */ export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string { - const src = opts?.streaming === true ? closeOpenDelimiters(text) : text; - try { - const raw = md.parse(src) as string; - return DOMPurify.sanitize(addCopyButtons(raw)); - } catch { - return ""; - } + const src = opts?.streaming === true ? closeOpenDelimiters(text) : text; + try { + const raw = md.parse(src) as string; + return DOMPurify.sanitize(addCopyButtons(raw)); + } catch { + return ""; + } } diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte index b828ab9..72b892b 100644 --- a/src/features/markdown/ui/Markdown.svelte +++ b/src/features/markdown/ui/Markdown.svelte @@ -1,58 +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); - }); + 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} + <!-- {@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 index e34a4af..d65b3d1 100644 --- a/src/features/markdown/ui/markdown.test.ts +++ b/src/features/markdown/ui/markdown.test.ts @@ -3,38 +3,38 @@ 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**" } }); + 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"); - }); + 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" }, - }); + 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"); - }); + 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 }); + 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 { 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"); + 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); + await fireEvent.click(button); - expect(writeText).toHaveBeenCalledTimes(1); - expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;"); - }); + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;"); + }); }); |
