When there is a markdown style codeblock within an MDX file it will be processed like markdown, and will result in an html structure like this:
<figure>
<figcaption>caption text</figcaption>
<pre>
<code>
some code...
</code>
</pre>
</figure>
The basic outline to create a copy button is:
- First, store a copy of the code string in the 'pre' html element using an attribute called 'rawstring'. This will be done by some code in the MDX processing pipeline. Mine looks this:
import rehypePrettyCode, { type Options } from 'rehype-pretty-code'
import { visit } from 'unist-util-visit'
// MDX processing configuration
mdx: {
rehypePlugins: [
() => (tree) => {
visit(tree, (node) => {
if (node?.type === 'element' && node?.tagName === 'pre') {
const [codeEl] = node.children
// skip if `pre` element doesn't have a `code` child element
if (codeEl.tagName !== 'code') return
// save the code string value in node attribute `__rawstring__`
// before we run `rehypePrettyCode` which chops up the
// string into colored, styled spans
node.__rawstring__ = codeEl.children?.[0].value
}
})
},
[
rehypePrettyCode,
{
theme: "one-dark-pro",
keepBackground: false,
onVisitLine(node: any) {
if (node.children.length === 0) {
node.children = [{ type: "text", value: " " }];
}
},
},
],
() => (tree) => {
visit(tree, (node) => {
if (node?.type === 'element' && node?.tagName === 'figure') {
// skip if not a rehypePrettyCode added `figure`
if (!('data-rehype-pretty-code-figure' in node.properties)) {
return
}
// select the `pre` element (if there is one)
const preElement = node.children.at(-1)
if (preElement.tagName !== 'pre') {
return
}
// add the '__rawstring__' property to the `pre` element
preElement.properties['__rawstring__'] = node.__rawstring__
}
})
},
],
},
The results after processing a real example code block will look like this (notice the __rawstring__
property added?):
<figure data-rehype-pretty-code-figure="">
<figcaption
data-rehype-pretty-code-title=""
data-language="bash"
data-theme="one-dark-pro"
>
settings.sh
</figcaption>
<pre
tabindex="0"
data-language="bash"
data-theme="one-dark-pro"
__rawstring__='sh -c "$(curl -fsSL \
https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"'
>
<code data-language="bash" data-theme="one-dark-pro" style="display: grid;">
<span data-line="">
<span style="color: rgb(97, 175, 239);">sh</span>
<span style="color: rgb(209, 154, 102);"> -c</span>
<span style="color: rgb(152, 195, 121);"> "$(</span>
<span style="color: rgb(97, 175, 239);">curl</span>
<span style="color: rgb(209, 154, 102);"> -fsSL</span>
<span style="color: rgb(86, 182, 194);"> \</span>
</span>
<span data-line="">
<span style="color: rgb(152, 195, 121);">
https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
</span>
</span>
</code>
</pre>
</figure>
- Next, inside a custom 'pre' component send the data in the 'rawstring' attribute to the copy button component. My custom "pre" component looks like this:
import { CopyButton } from '@/components/CopyButton'
interface PreProps extends React.HTMLProps<HTMLPreElement> {
__rawstring__?: string
['data-language']?: string
}
export function CustomPre(props: PreProps) {
const {
children,
__rawstring__ = '',
['data-language']: dataLanguage = 'Shell',
} = props
return (
<div className="relative flex flex-col space-y-2 rounded-lg bg-gray-200 p-4 dark:bg-gray-700">
<div className="flex items-center space-x-2">
<span className="h-3 w-3 rounded-full bg-[#ff605c]"></span>
<span className="h-3 w-3 rounded-full bg-[#ffbd44]"></span>
<span className="h-3 w-3 rounded-full bg-[#00ca4e]"></span>
<span className="text-sm">Language: {dataLanguage}</span>
</div>
<div className="not-prose rounded bg-gray-800 dark:bg-black">
<pre
// className="relative overflow-auto text-nowrap rounded-xl p-8"
className="m-3 overflow-auto text-nowrap text-sm font-medium"
{...props}
>
<CopyButton text={__rawstring__} className="absolute right-1 top-1" />
{children}
</pre>
</div>
</div>
)
}
- Finally, within the copy button component, copy the string to the clipboard when the button is clicked. My CopyButton component looks like this:
'use client'
import { useCallback, useState } from 'react'
import { cn } from '@/lib/utils'
import { Button, ButtonProps } from '@/components/ui/button'
import { Icons } from '@/components/Icons'
interface CopyButtonProps extends ButtonProps {
text: string
className?: string
}
export function CopyButton({ text, className, ...props }: CopyButtonProps) {
const [isCopied, setIsCopied] = useState(false)
const copy = useCallback(() => {
navigator.clipboard.writeText(text).then(() => {
setIsCopied(true)
setTimeout(() => setIsCopied(false), 700)
})
}, [text])
return (
<Button
variant="ghost" // ghost, outline
size="icon"
className={cn('hover:bg-gray-100 dark:hover:bg-gray-800', className)}
// disabled={isCopied}
onClick={copy}
aria-label={isCopied ? 'Copied' : 'Copy to clipboard'}
{...props}
>
<span className="sr-only">{isCopied ? 'Copied' : 'Copy'}</span>
{!isCopied ? (
<Icons.copy className="h-4 w-4" />
) : (
<Icons.check className="h-4 w-4 text-green-600 dark:text-green-400" />
)}
</Button>
)
}
Rendering MDX Content
There is a little more to it though - your custom components need to be passed to your MDX rendering process. Here's some example code of how to do that. First we will create a const containing our new custom components and then we will setup MDX rendering using the custom components.
import { CustomCode } from './CustomCode'
import { CustomPre } from './CustomPre'
// NOTE: MDX Bundler will "bundle" these components for the MDX files to use.
export const MDXComponents = {
// Override standard HTML Tags
pre: CustomPre,
code: CustomCode,
}
Creating a "MDXContent" rendering function:
import * as runtime from 'react/jsx-runtime'
import { MDXComponents } from '@/components/MDXComponents'
interface MdxProps {
code: string
components?: Record<string, React.ComponentType>
}
const useMDXComponent = (code: string) => {
const fn = new Function(code)
return fn({ ...runtime }).default
}
export function MDXContent({ code, components }: MdxProps) {
const Component = useMDXComponent(code)
return <Component components={{ ...MDXComponents, ...components }} />
}
Rendering a page in Next.js using MDXContent:
import { posts } from 'velite/generated'
import { MDXContent } from '@/lib/mdx-content'
export const generateStaticParams = async () => {
return posts.map((post) => ({ slug: post.slug }))
}
const PostLayout = async (props: { params: Promise<{ slug: string }> }) => {
const params = await props.params;
// Find the post for the current slug.
const post = posts.find((post) => post.slug === params.slug)
return (
<div className="converted-html">
<MDXContent code={post.content} />
</div>
)
}
export default PostLayout