340 lines
7.9 KiB
JavaScript
340 lines
7.9 KiB
JavaScript
import { cn } from "@/lib/utils";
|
|
import { marked } from "marked";
|
|
import { Suspense, isValidElement, memo, useMemo } from "react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
|
|
const DEFAULT_PRE_BLOCK_CLASS =
|
|
"my-4 overflow-x-auto w-fit rounded-xl bg-zinc-950 text-zinc-50 dark:bg-zinc-900 border border-border p-4";
|
|
|
|
const extractTextContent = node => {
|
|
if (typeof node === "string") {
|
|
return node;
|
|
}
|
|
if (Array.isArray(node)) {
|
|
return node.map(extractTextContent).join("");
|
|
}
|
|
if (isValidElement(node)) {
|
|
return extractTextContent(node.props.children);
|
|
}
|
|
return "";
|
|
};
|
|
|
|
const HighlightedPre = memo(async ({
|
|
children,
|
|
className,
|
|
language,
|
|
...props
|
|
}) => {
|
|
const { codeToTokens, bundledLanguages } = await import("shiki");
|
|
const code = extractTextContent(children);
|
|
|
|
if (!(language in bundledLanguages)) {
|
|
return (
|
|
(<pre {...props} className={cn(DEFAULT_PRE_BLOCK_CLASS, className)}>
|
|
<code className="whitespace-pre-wrap">{children}</code>
|
|
</pre>)
|
|
);
|
|
}
|
|
|
|
const { tokens } = await codeToTokens(code, {
|
|
lang: language,
|
|
themes: {
|
|
light: "github-dark",
|
|
dark: "github-dark",
|
|
},
|
|
});
|
|
|
|
return (
|
|
(<pre {...props} className={cn(DEFAULT_PRE_BLOCK_CLASS, className)}>
|
|
<code className="whitespace-pre-wrap">
|
|
{tokens.map((line, lineIndex) => (
|
|
<span
|
|
key={`line-${
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: Needed for react key
|
|
lineIndex
|
|
}`}>
|
|
{line.map((token, tokenIndex) => {
|
|
const style =
|
|
typeof token.htmlStyle === "string"
|
|
? undefined
|
|
: token.htmlStyle;
|
|
|
|
return (
|
|
(<span
|
|
key={`token-${
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: Needed for react key
|
|
tokenIndex
|
|
}`}
|
|
style={style}>
|
|
{token.content}
|
|
</span>)
|
|
);
|
|
})}
|
|
{lineIndex !== tokens.length - 1 && "\n"}
|
|
</span>
|
|
))}
|
|
</code>
|
|
</pre>)
|
|
);
|
|
});
|
|
|
|
HighlightedPre.displayName = "HighlightedPre";
|
|
|
|
const CodeBlock = ({
|
|
children,
|
|
language,
|
|
className,
|
|
...props
|
|
}) => {
|
|
return (
|
|
(<Suspense
|
|
fallback={
|
|
<pre {...props} className={cn(DEFAULT_PRE_BLOCK_CLASS, className)}>
|
|
<code className="whitespace-pre-wrap">{children}</code>
|
|
</pre>
|
|
}>
|
|
<HighlightedPre language={language} {...props}>
|
|
{children}
|
|
</HighlightedPre>
|
|
</Suspense>)
|
|
);
|
|
};
|
|
|
|
CodeBlock.displayName = "CodeBlock";
|
|
|
|
const components = {
|
|
h1: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<h1 className="mt-2 scroll-m-20 text-4xl font-bold" {...props}>
|
|
{children}
|
|
</h1>
|
|
),
|
|
h2: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<h2
|
|
className="mt-8 scroll-m-20 border-b pb-2 text-2xl font-semibold tracking-tight first:mt-0"
|
|
{...props}>
|
|
{children}
|
|
</h2>
|
|
),
|
|
h3: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<h3
|
|
className="mt-4 scroll-m-20 text-xl font-semibold tracking-tight"
|
|
{...props}>
|
|
{children}
|
|
</h3>
|
|
),
|
|
h4: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<h4
|
|
className="mt-4 scroll-m-20 text-lg font-semibold tracking-tight"
|
|
{...props}>
|
|
{children}
|
|
</h4>
|
|
),
|
|
h5: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<h5
|
|
className="mt-4 scroll-m-20 text-lg font-semibold tracking-tight"
|
|
{...props}>
|
|
{children}
|
|
</h5>
|
|
),
|
|
h6: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<h6
|
|
className="mt-4 scroll-m-20 text-base font-semibold tracking-tight"
|
|
{...props}>
|
|
{children}
|
|
</h6>
|
|
),
|
|
p: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<p className="leading-6 [&:not(:first-child)]:mt-4" {...props}>
|
|
{children}
|
|
</p>
|
|
),
|
|
strong: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<span className="font-semibold" {...props}>
|
|
{children}
|
|
</span>
|
|
),
|
|
a: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<a
|
|
className="font-medium underline underline-offset-4"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
{...props}>
|
|
{children}
|
|
</a>
|
|
),
|
|
ol: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<ol className="my-4 ml-6 list-decimal" {...props}>
|
|
{children}
|
|
</ol>
|
|
),
|
|
ul: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<ul className="my-4 ml-6 list-disc" {...props}>
|
|
{children}
|
|
</ul>
|
|
),
|
|
li: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<li className="mt-2" {...props}>
|
|
{children}
|
|
</li>
|
|
),
|
|
blockquote: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<blockquote className="mt-4 border-l-2 pl-6 italic" {...props}>
|
|
{children}
|
|
</blockquote>
|
|
),
|
|
hr: (props) => (
|
|
<hr className="my-4 md:my-8" {...props} />
|
|
),
|
|
table: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<div className="my-6 w-full overflow-y-auto">
|
|
<table
|
|
className="relative w-full overflow-hidden border-none text-sm"
|
|
{...props}>
|
|
{children}
|
|
</table>
|
|
</div>
|
|
),
|
|
tr: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<tr className="last:border-b-none m-0 border-b" {...props}>
|
|
{children}
|
|
</tr>
|
|
),
|
|
th: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<th
|
|
className="px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right"
|
|
{...props}>
|
|
{children}
|
|
</th>
|
|
),
|
|
td: ({
|
|
children,
|
|
...props
|
|
}) => (
|
|
<td
|
|
className="px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"
|
|
{...props}>
|
|
{children}
|
|
</td>
|
|
),
|
|
img: ({
|
|
alt,
|
|
...props
|
|
}) => (
|
|
// biome-ignore lint/a11y/useAltText: alt is not required
|
|
(<img className="rounded-md" alt={alt} {...props} />)
|
|
),
|
|
code: ({ children, node, className, ...props }) => {
|
|
const match = /language-(\w+)/.exec(className || "");
|
|
if (match) {
|
|
return (
|
|
(<CodeBlock language={match[1]} className={className} {...props}>
|
|
{children}
|
|
</CodeBlock>)
|
|
);
|
|
}
|
|
return (
|
|
(<code
|
|
className={cn("rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm", className)}
|
|
{...props}>
|
|
{children}
|
|
</code>)
|
|
);
|
|
},
|
|
pre: ({ children }) => <>{children}</>,
|
|
};
|
|
|
|
function parseMarkdownIntoBlocks(markdown) {
|
|
if (!markdown) {
|
|
return [];
|
|
}
|
|
const tokens = marked.lexer(markdown);
|
|
return tokens.map((token) => token.raw);
|
|
}
|
|
|
|
const MemoizedMarkdownBlock = memo(({
|
|
content,
|
|
className
|
|
}) => {
|
|
return (
|
|
(<ReactMarkdown remarkPlugins={[remarkGfm]} components={components} className={className}>
|
|
{content}
|
|
</ReactMarkdown>)
|
|
);
|
|
}, (prevProps, nextProps) => {
|
|
if (prevProps.content !== nextProps.content) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock";
|
|
|
|
export const MarkdownContent = memo(({
|
|
content,
|
|
id,
|
|
className
|
|
}) => {
|
|
const blocks = useMemo(() => parseMarkdownIntoBlocks(content || ""), [content]);
|
|
|
|
return blocks.map((block, index) => (
|
|
<MemoizedMarkdownBlock
|
|
content={block}
|
|
className={className}
|
|
key={`${id}-block_${
|
|
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
|
index
|
|
}`} />
|
|
));
|
|
});
|
|
|
|
MarkdownContent.displayName = "MarkdownContent";
|