247 lines
8.0 KiB
TypeScript
247 lines
8.0 KiB
TypeScript
"use client"
|
|
|
|
import ReactMarkdown from "react-markdown"
|
|
import remarkGfm from "remark-gfm"
|
|
import rehypeHighlight from "rehype-highlight"
|
|
import "highlight.js/styles/github-dark.css"
|
|
import { DiffTool } from "./diff-tool"
|
|
import { useState, isValidElement, type ReactNode } from "react"
|
|
import { cn } from "@/lib/utils"
|
|
import { Copy } from "lucide-react"
|
|
|
|
interface MarkdownRendererProps {
|
|
content: string
|
|
className?: string
|
|
tone?: "default" | "bubble"
|
|
}
|
|
|
|
// Parse diff tool calls from markdown content
|
|
function parseDiffTools(content: string) {
|
|
const diffToolRegex = /```diff-tool\n([\s\S]*?)\n```/g
|
|
const tools: Array<{ match: string; props: any }> = []
|
|
let match
|
|
|
|
while ((match = diffToolRegex.exec(content)) !== null) {
|
|
try {
|
|
const props = JSON.parse(match[1])
|
|
tools.push({ match: match[0], props })
|
|
} catch (e) {
|
|
console.error('Failed to parse diff tool:', e)
|
|
}
|
|
}
|
|
|
|
return tools
|
|
}
|
|
|
|
export function MarkdownRenderer({ content, className = "", tone = "default" }: MarkdownRendererProps) {
|
|
// Parse diff tools from content
|
|
const diffTools = parseDiffTools(content)
|
|
let processedContent = content
|
|
|
|
// Replace diff tool calls with placeholders
|
|
diffTools.forEach((tool, index) => {
|
|
processedContent = processedContent.replace(tool.match, `__DIFF_TOOL_${index}__`)
|
|
})
|
|
|
|
const baseTone = tone === "bubble"
|
|
? "text-charcoal dark:text-white"
|
|
: "text-charcoal dark:text-foreground"
|
|
|
|
const mutedTone = tone === "bubble"
|
|
? "text-charcoal/80 dark:text-white/80"
|
|
: "text-charcoal/80 dark:text-foreground/75"
|
|
|
|
return (
|
|
<div className={cn("markdown-glass space-y-3 text-sm leading-relaxed", baseTone, className)}>
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
rehypePlugins={[rehypeHighlight]}
|
|
components={{
|
|
// Custom component for diff tool placeholders
|
|
p: ({ children }) => {
|
|
const text = typeof children === 'string' ? children : children?.toString() || ''
|
|
const diffToolMatch = text.match(/^__DIFF_TOOL_(\d+)__$/)
|
|
|
|
if (diffToolMatch) {
|
|
const index = parseInt(diffToolMatch[1])
|
|
const tool = diffTools[index]
|
|
if (tool) {
|
|
return (
|
|
<DiffTool
|
|
oldCode={tool.props.oldCode}
|
|
newCode={tool.props.newCode}
|
|
title={tool.props.title}
|
|
language={tool.props.language}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<p className={cn("mb-2 text-sm leading-relaxed last:mb-0", baseTone)}>
|
|
{children}
|
|
</p>
|
|
)
|
|
},
|
|
// Custom styling for different elements
|
|
h1: ({ children }) => (
|
|
<h1 className={cn("text-[2rem] font-semibold tracking-tight", baseTone)}>
|
|
{children}
|
|
</h1>
|
|
),
|
|
h2: ({ children }) => (
|
|
<h2 className={cn("text-[1.75rem] font-semibold tracking-tight", baseTone)}>
|
|
{children}
|
|
</h2>
|
|
),
|
|
h3: ({ children }) => (
|
|
<h3 className={cn("text-[1.5rem] font-semibold", baseTone)}>
|
|
{children}
|
|
</h3>
|
|
),
|
|
ul: ({ children }) => (
|
|
<ul className={cn("mb-2 list-disc space-y-1 pl-4 text-sm", mutedTone)}>
|
|
{children}
|
|
</ul>
|
|
),
|
|
ol: ({ children }) => (
|
|
<ol className={cn("mb-2 list-decimal space-y-1 pl-4 text-sm", mutedTone)}>
|
|
{children}
|
|
</ol>
|
|
),
|
|
li: ({ children }) => (
|
|
<li className={cn("text-sm", mutedTone)}>
|
|
{children}
|
|
</li>
|
|
),
|
|
code: ({ children, className }) => {
|
|
// Check if this is inline code (no language class) or block code
|
|
const isInline = !className
|
|
if (isInline) {
|
|
return (
|
|
<code className="rounded bg-white/60 px-1.5 py-0.5 font-mono text-xs text-charcoal dark:bg-white/10 dark:text-foreground">
|
|
{children}
|
|
</code>
|
|
)
|
|
}
|
|
return (
|
|
<code className={className}>
|
|
{children}
|
|
</code>
|
|
)
|
|
},
|
|
pre: ({ children, className }) => (
|
|
<PreWithCopy className={className}>{children}</PreWithCopy>
|
|
),
|
|
blockquote: ({ children }) => (
|
|
<blockquote className="border-l-2 border-burnt/70 pl-4 text-sm italic text-muted-foreground dark:text-foreground/80">
|
|
{children}
|
|
</blockquote>
|
|
),
|
|
a: ({ children, href }) => (
|
|
<a
|
|
href={href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="underline decoration-burnt/40 decoration-2 underline-offset-4 text-burnt hover:text-terracotta dark:text-white dark:hover:text-burnt"
|
|
>
|
|
{children}
|
|
</a>
|
|
),
|
|
strong: ({ children }) => (
|
|
<strong className="font-semibold text-charcoal dark:text-white">
|
|
{children}
|
|
</strong>
|
|
),
|
|
em: ({ children }) => (
|
|
<em className={cn("italic", mutedTone)}>
|
|
{children}
|
|
</em>
|
|
),
|
|
table: ({ children }) => (
|
|
<div className="overflow-x-auto mb-3">
|
|
<table className="min-w-full rounded-lg border border-border/50">
|
|
{children}
|
|
</table>
|
|
</div>
|
|
),
|
|
thead: ({ children }) => (
|
|
<thead className="bg-white/70 text-charcoal dark:bg-white/10 dark:text-foreground">
|
|
{children}
|
|
</thead>
|
|
),
|
|
tbody: ({ children }) => (
|
|
<tbody className="bg-white/40 text-charcoal dark:bg-white/5 dark:text-foreground">
|
|
{children}
|
|
</tbody>
|
|
),
|
|
tr: ({ children }) => (
|
|
<tr className="border-b border-border/40">
|
|
{children}
|
|
</tr>
|
|
),
|
|
th: ({ children }) => (
|
|
<th className="px-4 py-2 text-left text-sm font-semibold text-charcoal dark:text-foreground">
|
|
{children}
|
|
</th>
|
|
),
|
|
td: ({ children }) => (
|
|
<td className="px-4 py-2 text-sm text-charcoal dark:text-foreground">
|
|
{children}
|
|
</td>
|
|
),
|
|
}}
|
|
>
|
|
{content}
|
|
</ReactMarkdown>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PreWithCopy({ children, className }: { children?: ReactNode; className?: string }) {
|
|
const [copied, setCopied] = useState(false)
|
|
const text = extractCodeText(children)
|
|
|
|
const handleCopy = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(text.trimEnd())
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 1200)
|
|
} catch (error) {
|
|
console.error("[markdown] Code copy failed", error)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="relative mb-3">
|
|
<pre className={cn("overflow-x-auto rounded-xl border border-border/50 p-4 text-sm text-charcoal shadow-sm dark:border-white/10 dark:text-foreground", className)}>
|
|
{children}
|
|
</pre>
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
className={cn(
|
|
"absolute right-3 top-3 inline-flex h-5 w-5 items-center justify-center rounded border border-white/25 bg-white/8 text-white/70 shadow-[0_2px_4px_rgba(0,0,0,0.06)] backdrop-blur transition-transform duration-150 hover:bg-white/18",
|
|
copied && "scale-90 bg-white/30 text-white"
|
|
)}
|
|
aria-label="Copy code"
|
|
>
|
|
<Copy className="h-2.5 w-2.5" />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function extractCodeText(node: ReactNode): string {
|
|
if (typeof node === "string") {
|
|
return node
|
|
}
|
|
if (Array.isArray(node)) {
|
|
return node.map(extractCodeText).join("")
|
|
}
|
|
if (isValidElement(node)) {
|
|
return extractCodeText(node.props.children)
|
|
}
|
|
return ""
|
|
}
|