feat(21-02): add ChatMarkdownMessage component with syntax highlighting and copy button

- Renders full GFM markdown (headings, bold, italic, links, lists, tables, images)
- Code blocks use rehype-highlight for syntax highlighting
- CodeBlock sub-component shows language label and copy-to-clipboard button
- Inline code renders without copy button
- 10 tests green (jsdom environment)
This commit is contained in:
Mikkel Georgsen 2026-04-01 13:01:41 +02:00
parent 0152d95865
commit c7e0d9361f
2 changed files with 263 additions and 0 deletions

View file

@ -0,0 +1,164 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ThemeProvider } from "../context/ThemeContext";
import { ChatMarkdownMessage } from "./ChatMarkdownMessage";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
// Mock clipboard API (not available in jsdom by default)
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
writable: true,
configurable: true,
});
describe("ChatMarkdownMessage", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
writeText.mockClear();
});
afterEach(() => {
container.remove();
});
it("renders markdown headings", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content="# Hello World" />
</ThemeProvider>,
);
});
const heading = container.querySelector("h1");
expect(heading).not.toBeNull();
expect(heading?.textContent).toBe("Hello World");
});
it("renders markdown bold and italic", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content="**bold** and _italic_" />
</ThemeProvider>,
);
});
expect(container.querySelector("strong")).not.toBeNull();
expect(container.querySelector("em")).not.toBeNull();
});
it("renders markdown lists", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content={"- item one\n- item two"} />
</ThemeProvider>,
);
});
expect(container.querySelector("ul")).not.toBeNull();
const items = container.querySelectorAll("li");
expect(items.length).toBe(2);
});
it("renders markdown links", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content="[Click here](https://example.com)" />
</ThemeProvider>,
);
});
const link = container.querySelector("a");
expect(link).not.toBeNull();
expect(link?.getAttribute("href")).toBe("https://example.com");
});
it("renders markdown tables (GFM)", () => {
const tableContent = "| A | B |\n|---|---|\n| 1 | 2 |";
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content={tableContent} />
</ThemeProvider>,
);
});
expect(container.querySelector("table")).not.toBeNull();
expect(container.querySelector("th")).not.toBeNull();
});
it("renders code block with copy button", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content={"```typescript\nconst x = 1;\n```"} />
</ThemeProvider>,
);
});
const copyBtn = container.querySelector('[aria-label="Copy code"]');
expect(copyBtn).not.toBeNull();
});
it("renders code block with language label when language is specified", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content={"```typescript\nconst x = 1;\n```"} />
</ThemeProvider>,
);
});
expect(container.textContent).toContain("typescript");
});
it("clicking copy button calls clipboard.writeText with code content", async () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content={"```\nconst y = 2;\n```"} />
</ThemeProvider>,
);
});
const copyBtn = container.querySelector('[aria-label="Copy code"]') as HTMLButtonElement | null;
expect(copyBtn).not.toBeNull();
await act(async () => {
copyBtn?.click();
});
expect(writeText).toHaveBeenCalledTimes(1);
expect(writeText).toHaveBeenCalledWith(expect.stringContaining("const y = 2;"));
});
it("inline code renders without copy button", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content="Here is `inline code` text" />
</ThemeProvider>,
);
});
const copyBtn = container.querySelector('[aria-label="Copy code"]');
expect(copyBtn).toBeNull();
expect(container.querySelector("code")).not.toBeNull();
});
it("renders inline images with img tag", () => {
act(() => {
createRoot(container).render(
<ThemeProvider>
<ChatMarkdownMessage content="![alt text](https://example.com/img.png)" />
</ThemeProvider>,
);
});
const img = container.querySelector("img");
expect(img).not.toBeNull();
expect(img?.getAttribute("src")).toBe("https://example.com/img.png");
expect(img?.getAttribute("alt")).toBe("alt text");
});
});

View file

@ -0,0 +1,99 @@
import { useCallback, useState, type ReactNode } from "react";
import Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import { Copy, Check } from "lucide-react";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
interface ChatMarkdownMessageProps {
content: string;
className?: string;
}
function extractText(node: ReactNode): string {
if (typeof node === "string") return node;
if (typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(extractText).join("");
if (node && typeof node === "object" && "props" in node) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return extractText((node as any).props.children);
}
return "";
}
function CodeBlock({
children,
className,
...props
}: {
children?: ReactNode;
className?: string;
[key: string]: unknown;
}) {
const [copied, setCopied] = useState(false);
const language = className?.replace(/^language-/, "") ?? null;
const codeText = extractText(children);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(codeText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [codeText]);
return (
<div className="relative bg-card border border-border my-2 rounded-sm">
{language && (
<span className="absolute top-2 left-3 text-xs text-muted-foreground select-none font-mono">
{language}
</span>
)}
<Button
variant="ghost"
size="icon"
className="absolute top-1.5 right-1.5 h-7 w-7"
onClick={handleCopy}
aria-label="Copy code"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
<pre
className={cn("overflow-x-auto p-4 pt-8 text-sm", language && "pb-4")}
{...props}
>
<code className={className}>{children}</code>
</pre>
</div>
);
}
const components: Partial<Components> = {
pre({ children, ...props }) {
// Check if child is a <code> element with language class (code block)
if (children && typeof children === "object" && "props" in (children as object)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const childProps = (children as any).props;
const childClassName: string = childProps?.className ?? "";
return (
<CodeBlock className={childClassName} {...props}>
{childProps?.children}
</CodeBlock>
);
}
return <pre {...props}>{children}</pre>;
},
};
export function ChatMarkdownMessage({ content, className }: ChatMarkdownMessageProps) {
return (
<div className={cn("prose prose-sm max-w-none dark:prose-invert", className)}>
<Markdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
components={components}
>
{content}
</Markdown>
</div>
);
}