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:
parent
0152d95865
commit
c7e0d9361f
2 changed files with 263 additions and 0 deletions
164
ui/src/components/ChatMarkdownMessage.test.tsx
Normal file
164
ui/src/components/ChatMarkdownMessage.test.tsx
Normal 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="" />
|
||||
</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");
|
||||
});
|
||||
});
|
||||
99
ui/src/components/ChatMarkdownMessage.tsx
Normal file
99
ui/src/components/ChatMarkdownMessage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue