Correspondents/src/components/markdown-renderer.tsx
2025-11-13 22:33:26 -07:00

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 ""
}