Files
mcpedia/apps/web/app/components/Markdown.tsx
T
asepharyana d62c5d33a4
CI / typecheck + build (push) Canceled after 0s
CI / deploy to VPS (push) Canceled after 0s
feat: enhance Markdown component with syntax highlighting and improve code snippet display
2026-08-21 15:17:09 +07:00

146 lines
6.2 KiB
TypeScript

"use client";
import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeSlug from "rehype-slug";
import rehypeHighlight from "rehype-highlight";
function extractText(node: React.ReactNode): string {
if (node === null || node === undefined || typeof node === "boolean") {
return "";
}
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(extractText).join("");
}
if (React.isValidElement(node)) {
return extractText((node.props as { children?: React.ReactNode })?.children);
}
return "";
}
function PreBlock({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) {
const [copied, setCopied] = useState(false);
const textContent = extractText(children).replace(/\n$/, "");
let language = "text";
if (React.isValidElement(children)) {
const className = (children.props as { className?: string })?.className || "";
const match = /language-(\w+)/.exec(className);
if (match) {
language = match[1];
}
}
function handleCopy() {
navigator.clipboard.writeText(textContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="relative group my-5 rounded-xl overflow-hidden border border-[var(--border-code)] bg-[var(--code-block-bg)] shadow-xs">
{/* Code block header bar */}
<div className="flex items-center justify-between px-4 py-2 bg-[var(--code-header-bg)] border-b border-[var(--border-code)] text-xs font-mono select-none">
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-1.5 opacity-75 group-hover:opacity-100 transition-opacity">
<span className="w-2.5 h-2.5 rounded-full bg-rose-400/80 dark:bg-rose-500/60 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-amber-400/80 dark:bg-amber-500/60 inline-block" />
<span className="w-2.5 h-2.5 rounded-full bg-emerald-400/80 dark:bg-emerald-500/60 inline-block" />
</div>
<span className="text-[11px] font-medium uppercase tracking-wider text-[var(--text-muted)] group-hover:text-[var(--text-secondary)] transition-colors">
{language}
</span>
</div>
<button
onClick={handleCopy}
type="button"
aria-label="Copy code to clipboard"
className="flex items-center gap-1.5 text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] bg-[var(--code-button-bg)] hover:bg-[var(--code-button-hover-bg)] px-2.5 py-1 rounded-md border border-[var(--border-code)] transition-all font-sans shadow-xs cursor-pointer"
>
{copied ? (
<>
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-emerald-500 font-medium">Copied!</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>Copy</span>
</>
)}
</button>
</div>
<pre className="!m-0 !bg-transparent p-4 overflow-x-auto text-[13.5px] text-[var(--pre-text)] font-mono leading-relaxed" {...props}>
{children}
</pre>
</div>
);
}
export default function Markdown({ source }: { source: string }) {
return (
<div
className="prose max-w-none
prose-headings:text-[var(--text-primary)] prose-headings:font-semibold prose-headings:tracking-tight
prose-h1:text-2xl sm:prose-h1:text-3xl prose-h1:mb-6 prose-h1:mt-8 prose-h1:pb-3 prose-h1:border-b prose-h1:border-[var(--border-color)]
prose-h2:text-xl sm:prose-h2:text-2xl prose-h2:mb-4 prose-h2:mt-8 prose-h2:pb-2 prose-h2:border-b prose-h2:border-[var(--border-subtle)]
prose-h3:text-lg prose-h3:mb-3 prose-h3:mt-6
prose-p:text-[var(--text-secondary)] prose-p:leading-relaxed prose-p:mb-4
prose-a:text-[var(--accent)] prose-a:font-medium prose-a:no-underline hover:prose-a:underline hover:prose-a:text-[var(--accent-hover)]
prose-ul:text-[var(--text-secondary)] prose-ul:my-4 prose-li:my-1.5
prose-ol:text-[var(--text-secondary)] prose-ol:my-4
prose-strong:text-[var(--text-primary)] prose-strong:font-semibold
prose-hr:border-[var(--border-color)] prose-hr:my-8"
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSlug, rehypeHighlight]}
components={{
pre: PreBlock as any,
code: ({ children, className, ...props }: React.HTMLAttributes<HTMLElement>) => {
if (className?.includes("hljs") || className?.includes("language-")) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
return (
<code
className="px-1.5 py-0.5 rounded-md bg-[var(--code-inline-bg)] text-[var(--code-inline-text)] border border-[var(--code-inline-border)] font-mono text-[0.875em] font-medium"
{...props}
>
{children}
</code>
);
},
table: ({ node, ...props }) => (
<div className="overflow-x-auto my-6 border border-[var(--border-color)] rounded-xl shadow-xs">
<table className="w-full border-collapse" {...props} />
</div>
),
blockquote: ({ children }) => {
return (
<blockquote className="border-l-4 border-[var(--brand)] bg-[var(--brand)]/5 dark:bg-[var(--brand)]/10 pl-4 py-2.5 my-5 rounded-r-lg text-[var(--text-secondary)] not-italic">
{children}
</blockquote>
);
},
}}
>
{source}
</ReactMarkdown>
</div>
);
}