summaryrefslogtreecommitdiffhomepage
path: root/src/features/markdown
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/markdown')
-rw-r--r--src/features/markdown/index.ts8
-rw-r--r--src/features/markdown/logic/markdown.test.ts58
-rw-r--r--src/features/markdown/logic/markdown.ts165
-rw-r--r--src/features/markdown/ui/Markdown.svelte58
-rw-r--r--src/features/markdown/ui/markdown.test.ts40
5 files changed, 329 insertions, 0 deletions
diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts
new file mode 100644
index 0000000..f5406b2
--- /dev/null
+++ b/src/features/markdown/index.ts
@@ -0,0 +1,8 @@
+export { renderMarkdown } from "./logic/markdown";
+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)",
+} as const;
diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts
new file mode 100644
index 0000000..7dbb878
--- /dev/null
+++ b/src/features/markdown/logic/markdown.test.ts
@@ -0,0 +1,58 @@
+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("&lt;b&gt;");
+ });
+
+ 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
new file mode 100644
index 0000000..3a6e5a6
--- /dev/null
+++ b/src/features/markdown/logic/markdown.ts
@@ -0,0 +1,165 @@
+/**
+ * Pure Markdown → sanitized-HTML renderer for assistant messages.
+ *
+ * Mirrors old Dispatch's stack (marked + marked-highlight + highlight.js +
+ * DOMPurify; GFM + line breaks; streaming delimiter-closing), but kept fully
+ * SYNCHRONOUS and pure: `input → output`, no effects, no `$effect`. Languages
+ * are a fixed "hot set" registered at module load (no lazy dynamic import), so a
+ * single `renderMarkdown(text)` call is deterministic and unit-testable.
+ *
+ * The only ambient dependency is DOMPurify, which sanitizes against the DOM —
+ * present in the browser and in the jsdom test env.
+ */
+
+import DOMPurify from "dompurify";
+import type { LanguageFn } from "highlight.js";
+import hljs from "highlight.js/lib/core";
+import bash from "highlight.js/lib/languages/bash";
+import c from "highlight.js/lib/languages/c";
+import cpp from "highlight.js/lib/languages/cpp";
+import csharp from "highlight.js/lib/languages/csharp";
+import css from "highlight.js/lib/languages/css";
+import go from "highlight.js/lib/languages/go";
+import java from "highlight.js/lib/languages/java";
+import javascript from "highlight.js/lib/languages/javascript";
+import json from "highlight.js/lib/languages/json";
+import markdownLang from "highlight.js/lib/languages/markdown";
+import php from "highlight.js/lib/languages/php";
+import plaintext from "highlight.js/lib/languages/plaintext";
+import python from "highlight.js/lib/languages/python";
+import ruby from "highlight.js/lib/languages/ruby";
+import rust from "highlight.js/lib/languages/rust";
+import shell from "highlight.js/lib/languages/shell";
+import sql from "highlight.js/lib/languages/sql";
+import typescript from "highlight.js/lib/languages/typescript";
+import xml from "highlight.js/lib/languages/xml";
+import yaml from "highlight.js/lib/languages/yaml";
+import { Marked } from "marked";
+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,
+};
+for (const [name, lang] of Object.entries(HOT_LANGUAGES)) {
+ 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",
+};
+
+function normalizeLang(lang: string): string {
+ const lower = lang.toLowerCase().trim();
+ return ALIASES[lower] ?? lower;
+}
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#39;");
+}
+
+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 },
+);
+
+/**
+ * While a message is still streaming, balance dangling fences / emphasis so the
+ * 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;
+}
+
+// Wrap each fenced code block (`<pre>…</pre>`) in a positioned container with a
+// copy button. marked emits exactly one `<pre>`/`</pre>` pair per block and
+// escapes `<`/`>` inside code, so these literal tags only ever delimit blocks.
+// `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>';
+
+function addCopyButtons(html: string): string {
+ 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 "";
+ }
+}
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;");
+ });
+});