summaryrefslogtreecommitdiffhomepage
path: root/src/features/markdown
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/markdown')
-rw-r--r--src/features/markdown/index.ts4
-rw-r--r--src/features/markdown/logic/markdown.test.ts106
-rw-r--r--src/features/markdown/logic/markdown.ts174
-rw-r--r--src/features/markdown/ui/Markdown.svelte106
-rw-r--r--src/features/markdown/ui/markdown.test.ts52
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("&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("");
- });
+ 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
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, "&amp;")
- .replace(/</g, "&lt;")
- .replace(/>/g, "&gt;")
- .replace(/"/g, "&quot;")
- .replace(/'/g, "&#39;");
+ 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 },
+ 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;");
+ });
});