174 lines
5.9 KiB
TypeScript
174 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import { Check, Copy } from "lucide-react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import type { PluggableList } from "unified";
|
|
import remarkGfm from "remark-gfm";
|
|
import rehypeRaw from "rehype-raw";
|
|
import rehypeSanitize from "rehype-sanitize";
|
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
|
import { extractMarkdownHeadings } from "@/lib/markdown-headings";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type MarkdownSize = "sm" | "base" | "lg";
|
|
|
|
type MarkdownProps = {
|
|
content?: string;
|
|
allowHtml?: boolean;
|
|
className?: string;
|
|
dir?: "rtl" | "ltr";
|
|
justify?: boolean;
|
|
size?: MarkdownSize;
|
|
};
|
|
|
|
function CodeBlock({
|
|
className,
|
|
children,
|
|
}: {
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const [copied, setCopied] = useState(false);
|
|
const language = /language-([\w-]+)/.exec(className || "")?.[1] || "text";
|
|
const code = String(children).replace(/\n$/, "");
|
|
|
|
const copyCode = async () => {
|
|
if (!navigator.clipboard) return;
|
|
await navigator.clipboard.writeText(code);
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1600);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
dir="ltr"
|
|
className="my-5 overflow-hidden rounded-2xl border border-slate-700/70 bg-[#0f172a] text-left shadow-xl shadow-slate-950/10"
|
|
>
|
|
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-4 py-2 text-xs text-slate-300">
|
|
<span className="font-mono uppercase tracking-wide">{language}</span>
|
|
<button
|
|
type="button"
|
|
onClick={copyCode}
|
|
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-slate-300 transition hover:bg-white/10 hover:text-white"
|
|
>
|
|
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
|
{copied ? "Copied" : "Copy"}
|
|
</button>
|
|
</div>
|
|
<SyntaxHighlighter
|
|
language={language}
|
|
style={oneDark}
|
|
PreTag="div"
|
|
customStyle={{
|
|
margin: 0,
|
|
background: "transparent",
|
|
direction: "ltr",
|
|
padding: "1rem",
|
|
}}
|
|
codeTagProps={{
|
|
dir: "ltr",
|
|
style: {
|
|
direction: "ltr",
|
|
textAlign: "left",
|
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
|
},
|
|
}}
|
|
>
|
|
{code}
|
|
</SyntaxHighlighter>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Markdown({
|
|
content = "",
|
|
allowHtml = false,
|
|
className = "",
|
|
dir = "rtl",
|
|
justify = false,
|
|
size = "sm",
|
|
}: MarkdownProps) {
|
|
const rehypePlugins: PluggableList | undefined = allowHtml ? [rehypeRaw, rehypeSanitize] : undefined;
|
|
const headings = extractMarkdownHeadings(content);
|
|
let headingIndex = 0;
|
|
|
|
const baseSizeClass =
|
|
size === "sm" ? "text-sm" : size === "lg" ? "text-lg" : "text-base";
|
|
|
|
const hScale =
|
|
size === "sm"
|
|
? { h1: "text-xl", h2: "text-lg", h3: "text-base", h4: "text-base" }
|
|
: size === "base"
|
|
? { h1: "text-3xl", h2: "text-2xl", h3: "text-xl", h4: "text-lg" }
|
|
: { h1: "text-4xl", h2: "text-3xl", h3: "text-2xl", h4: "text-xl" };
|
|
|
|
const justifyStyle: React.CSSProperties | undefined = justify
|
|
? { textAlign: "justify", textJustify: "inter-word" }
|
|
: undefined;
|
|
|
|
const nextHeadingId = (level: 1 | 2 | 3) => {
|
|
while (headingIndex < headings.length) {
|
|
const heading = headings[headingIndex];
|
|
headingIndex += 1;
|
|
if (heading.level === level) return heading.id;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
return (
|
|
<div
|
|
dir={dir}
|
|
className={cn("markdown-body break-words text-right leading-8", baseSizeClass, className)}
|
|
style={justifyStyle}
|
|
>
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
rehypePlugins={rehypePlugins}
|
|
components={{
|
|
h1: (p) => <h1 id={nextHeadingId(1)} className={cn("scroll-mt-28 pt-2 font-bold", hScale.h1)} {...p} />,
|
|
h2: (p) => <h2 id={nextHeadingId(2)} className={cn("scroll-mt-28 pt-2 font-bold", hScale.h2)} {...p} />,
|
|
h3: (p) => <h3 id={nextHeadingId(3)} className={cn("scroll-mt-28 pt-2 font-semibold", hScale.h3)} {...p} />,
|
|
h4: (p) => <h4 className={cn("mt-4 font-semibold", hScale.h4)} {...p} />,
|
|
p: (p) => <p className="my-4" {...p} />,
|
|
a: (p) => <a className="break-all underline decoration-primary hover:opacity-90" target="_blank" rel="noopener noreferrer" {...p} />,
|
|
ul: (p) => <ul className="my-4 list-disc space-y-1.5 pe-0 ps-6" {...p} />,
|
|
ol: (p) => <ol className="my-4 list-decimal space-y-1.5 pe-0 ps-6" {...p} />,
|
|
li: (p) => <li className="[&>ol]:my-1.5 [&>ul]:my-1.5" {...p} />,
|
|
hr: (p) => <hr className="my-6 border-muted" {...p} />,
|
|
blockquote: (p) => (
|
|
<blockquote className="my-4 rounded-2xl border-r-4 border-primary bg-muted/40 py-3 pr-4 italic text-muted-foreground" {...p} />
|
|
),
|
|
code: ({ className, children, node, ...p }) => {
|
|
const isInline =
|
|
node?.tagName === "code" &&
|
|
!/language-/.test(className || "") &&
|
|
!String(children).includes("\n");
|
|
|
|
if (isInline) {
|
|
return (
|
|
<code dir="ltr" className="rounded bg-muted px-1.5 py-0.5 text-[0.9em]" {...p}>
|
|
{children}
|
|
</code>
|
|
);
|
|
}
|
|
|
|
return <CodeBlock className={className}>{children}</CodeBlock>;
|
|
},
|
|
pre: ({ children }) => <>{children}</>,
|
|
table: (p) => (
|
|
<div className="my-4 overflow-x-auto">
|
|
<table className="w-full border-collapse" {...p} />
|
|
</div>
|
|
),
|
|
th: (p) => <th className="border-b p-2 text-right font-semibold" {...p} />,
|
|
td: (p) => <td className="border-b p-2 align-top" {...p} />,
|
|
}}
|
|
>
|
|
{content}
|
|
</ReactMarkdown>
|
|
</div>
|
|
);
|
|
}
|