feat: enhance MDX rendering with code copy functionality

- Add code copy button to code blocks with internationalized label
- Modify MDX component to support raw code string extraction
- Update content collections to inject raw code string into pre elements
- Improve code block styling and add hover-based copy button
- Remove unnecessary prose wrapper from various components
- Enhance code block presentation with better overflow and border handling
This commit is contained in:
javayhu 2025-03-11 00:33:14 +08:00
parent 9a6ce6b7c3
commit 805a871146
8 changed files with 79 additions and 56 deletions

View File

@ -8,6 +8,7 @@ import remarkGfm from 'remark-gfm';
import { createHighlighter } from 'shiki';
import path from "path";
import { LOCALES, DEFAULT_LOCALE } from "@/i18n/routing";
import { visit } from 'unist-util-visit';
/**
* Content Collections documentation
@ -100,17 +101,7 @@ export const posts = defineCollection({
locale: z.enum(LOCALES as [string, ...string[]]).optional()
}),
transform: async (data, context) => {
const body = await compileMDX(context, data, {
remarkPlugins: [
remarkGfm,
codeImport
],
rehypePlugins: [
rehypeSlug,
rehypeAutolinkHeadings,
[rehypePrettyCode, prettyCodeOptions]
]
});
const body = await compileWithCodeCopy(context, data);
// Determine the locale from the file path or use the provided locale
const pathParts = data._meta.path.split(path.sep);
@ -190,17 +181,7 @@ export const pages = defineCollection({
locale: z.enum(LOCALES as [string, ...string[]]).optional()
}),
transform: async (data, context) => {
const body = await compileMDX(context, data, {
remarkPlugins: [
remarkGfm,
codeImport
],
rehypePlugins: [
rehypeSlug,
rehypeAutolinkHeadings,
[rehypePrettyCode, prettyCodeOptions]
]
});
const body = await compileWithCodeCopy(context, data);
// Determine the locale from the file path or use the provided locale
const pathParts = data._meta.path.split(path.sep);
@ -258,17 +239,7 @@ export const releases = defineCollection({
locale: z.enum(LOCALES as [string, ...string[]]).optional()
}),
transform: async (data, context) => {
const body = await compileMDX(context, data, {
remarkPlugins: [
remarkGfm,
codeImport
],
rehypePlugins: [
rehypeSlug,
rehypeAutolinkHeadings,
[rehypePrettyCode, prettyCodeOptions]
]
});
const body = await compileWithCodeCopy(context, data);
// Determine the locale from the file path or use the provided locale
const pathParts = data._meta.path.split(path.sep);
@ -327,6 +298,40 @@ const prettyCodeOptions: Options = {
}
};
const compileWithCodeCopy = async (
context: any,
data: any,
options: {
remarkPlugins?: any[];
rehypePlugins?: any[];
} = {}
) => {
return await compileMDX(context, data, {
...options,
remarkPlugins: [
remarkGfm,
codeImport,
...(options.remarkPlugins || [])
],
rehypePlugins: [
rehypeSlug,
[rehypePrettyCode, prettyCodeOptions],
// add __rawString__ to pre element
() => (tree) => {
visit(tree, (node) => {
if (node?.type === "element" && node?.tagName === "pre") {
const [codeEl] = node.children;
if (codeEl.tagName !== "code") return;
node.__rawString__ = codeEl.children?.[0]?.value;
}
});
},
rehypeAutolinkHeadings,
...(options.rehypePlugins || [])
]
});
};
export default defineConfig({
collections: [authors, categories, posts, pages, releases]
});

View File

@ -13,7 +13,8 @@
"language": "Switch language",
"light": "Light",
"dark": "Dark",
"system": "System"
"system": "System",
"copy": "Copy"
},
"HomePage": {
"title": "next-intl example"

View File

@ -13,7 +13,8 @@
"language": "切换语言",
"light": "浅色模式",
"dark": "深色模式",
"system": "跟随系统"
"system": "跟随系统",
"copy": "复制"
},
"HomePage": {
"title": "next-intl 示例"

View File

@ -137,11 +137,9 @@ export default async function BlogPostPage(props: NextPageProps) {
</div>
{/* blog post content */}
<div className="prose prose-gray dark:prose-invert max-w-none">
<Mdx code={post.body.code} />
</div>
<Mdx code={post.body.code} />
<div className="flex items-center justify-start mt-16">
<div className="flex items-center justify-start my-16">
<AllPostsButton />
</div>
</div>

View File

@ -39,9 +39,7 @@ export function CustomPage({
{/* Content */}
<Card className="mb-8">
<CardContent>
<div className="prose prose-gray dark:prose-invert max-w-none">
<Mdx code={content} />
</div>
<Mdx code={content} />
</CardContent>
</Card>
</div>

View File

@ -40,9 +40,7 @@ export function ReleaseCard({
<Separator />
</CardHeader>
<CardContent>
<div className="prose prose-gray dark:prose-invert max-w-none">
<Mdx code={content} />
</div>
<Mdx code={content} />
</CardContent>
</Card>
);

View File

@ -3,6 +3,7 @@
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { CheckIcon, CopyIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import React, { useEffect } from 'react';
interface CopyButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
@ -11,8 +12,8 @@ interface CopyButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
export function CopyButton({ value, className, ...props }: CopyButtonProps) {
const [hasCopied, setHasCopied] = React.useState(false);
const t = useTranslations('Common');
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
setTimeout(() => {
setHasCopied(false);
@ -35,7 +36,7 @@ export function CopyButton({ value, className, ...props }: CopyButtonProps) {
onClick={() => handleCopyValue(value)}
{...props}
>
<span className="sr-only">Copy</span>
<span className="sr-only">{t('copy')}</span>
{hasCopied ? (
<CheckIcon className="size-4" />
) : (

View File

@ -13,6 +13,7 @@ import {
} from '@/components/ui/accordion';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { CopyButton } from './copy-button';
const components = {
h1: ({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => (
@ -135,14 +136,29 @@ const components = {
{...props}
/>
),
pre: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
<pre
className={cn(
'relative mt-4 max-w-[calc(100vw-64px)] overflow-x-auto rounded bg-muted px-1 py-2 font-mono text-sm',
className
pre: ({
className,
__rawString__,
...props
}: React.HTMLAttributes<HTMLPreElement> & { __rawString__?: string }) => (
<div className="my-4 group relative w-full overflow-hidden">
<pre
className={cn(
"max-h-[650px] overflow-x-auto rounded-lg border bg-zinc-900 py-4 dark:bg-zinc-900",
className,
)}
{...props}
/>
{__rawString__ && (
<CopyButton
value={__rawString__}
className={cn(
"absolute right-4 top-4 z-20",
"duration-250 opacity-0 transition-all group-hover:opacity-100",
)}
/>
)}
{...props}
/>
</div>
),
code: ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
<code
@ -163,7 +179,12 @@ const components = {
AccordionItem,
AccordionTrigger,
Callout,
Image,
Image: ({ className, ...props }: React.ComponentProps<typeof Image>) => (
<Image
className={cn('my-4 w-full rounded-lg', className)}
{...props}
/>
),
Tabs: ({ className, ...props }: React.ComponentProps<typeof Tabs>) => (
<Tabs className={cn('relative mt-6 w-full', className)} {...props} />
),
@ -207,7 +228,7 @@ type MdxProps = {
};
/**
* TODO: update
* render mdx content with custom components
*/
export function Mdx({ code }: MdxProps) {
const Component = useMDXComponent(code);