Add a copy button to your Rehype (NextJS / MDX) code snippets
The blog is coming along nicely, especially as a ground up build, but I've been missing one important thing: The Copy Button.
# copy and run in terminal
open https://www.twitter.com/hostmonk
So, let's not stand on ceremony... let's build it! 🚀
tldr;
There are really only 3 major concepts we need to hit to build this:
- Getting the
<pre>
content from Rehype via a plugin. - A
<CopyButton>
component that copies the raw snippet content. - A custom
<Pre>
component that renders the<CopyButton>
.
For those of you that just want the the code, here you go:
The Rehype Content Plugin
These plugin functions snag the content from your Rehype processor.
import { visit } from 'unist-util-visit'
export const preProcess = () => (tree) => {
visit(tree, (node) => {
if (node?.type === 'element' && node?.tagName === 'pre') {
const [codeEl] = node.children
if (codeEl.tagName !== 'code') return
node.raw = codeEl.children?.[0].value
}
})
}
export const postProcess = () => (tree) => {
visit(tree, 'element', (node) => {
if (node?.type === 'element' && node?.tagName === 'pre') {
node.properties['raw'] = node.raw
// console.log(node) here to see if you're getting the raw text
}
})
}
preProcess
is first in your array of plugins, and postProcess
is last, like so: import { serialize } from 'next-mdx-remote/serialize'
const mdxSource = await serialize(content, {
// ...
mdxOptions: {
rehypePlugins: [
preProcess,
rehypeCodeTitles,
rehypePrism,
rehypeBlockquote,
postProcess,
],
},
// ...
})
The Copy Button Component
Here is the <CopyButton />
component, that handles the copy of our raw code.
import { useState } from 'react'
import clsx from 'clsx'
import { ClipboardIcon, CheckIcon } from '@heroicons/react/20/solid'
const buttonClasses = 'flex items-center text-xs font-medium text-white rounded'
export function CopyButton({ text, className }) {
const [isCopied, setIsCopied] = useState(false)
const copy = async () => {
await navigator.clipboard.writeText(text)
setIsCopied(true)
setTimeout(() => {
setIsCopied(false)
}, 2500)
}
const Icon = isCopied ? CheckIcon : ClipboardIcon
return (
<button
disabled={isCopied}
onClick={copy}
className={clsx(buttonClasses, className)}
>
<Icon className="mr-1 h-4 w-4" />
<span>{isCopied ? 'Copied!' : 'Copy'}</span>
</button>
)
}
The Custom Pre Component
Here is the <Pre />
component, which will render our <CopyButton />
.
import { CopyButton } from '@/components/CopyButton'
import clsx from 'clsx'
export function Pre({
children,
raw,
buttonClasses = 'absolute top-3 right-3 bg-zinc-900',
...props
}) {
return (
<pre {...props} className={clsx('relative', props.className)}>
{children}
<CopyButton text={raw} className={buttonClasses} />
</pre>
)
}
Et voila! 🍟
If you want, keep reading for a deeper dive & explanation into what the code is doing and how to wire it up in your NextJS MDX blog.
A Deeper Dive / Explanation
From the get go, I figured the main difficulty in getting this button to render is getting the raw processed code out of our Rehype setup, and into a component props.
To accomplish this first step, we need to create our own Rehype tree visitor.
There are AST's for language, programming languages, network interfaces like GraphQL, and even XML (or HTML).
When parsing / changing HTML on the fly, using an AST is typically much safer than trying to use something like regular expressions.
To start, we'll make a preProcess
function that will take the raw value of the underlying code
element, and apply / set it to the node, so we can carry it through the rest of the plugins.
import { visit } from 'unist-util-visit'
export const preProcess = () => (tree) => {
visit(tree, (node) => {
if (node?.type === 'element' && node?.tagName === 'pre') {
const [codeEl] = node.children
if (codeEl.tagName !== 'code') return
node.raw = codeEl.children?.[0].value
}
})
}
Second, we'll add a postProcess
function that adds the raw data to our pre
node
properties.
import { visit } from 'unist-util-visit'
export const preProcess = () => (tree) => {
visit(tree, (node) => {
if (node?.type === 'element' && node?.tagName === 'pre') {
const [codeEl] = node.children
if (codeEl.tagName !== 'code') return
node.raw = codeEl.children?.[0].value
}
})
}
export const postProcess = () => (tree) => {
visit(tree, 'element', (node) => {
if (node?.type === 'element' && node?.tagName === 'pre') {
node.properties['raw'] = node.raw
// console.log(node) here to see if you're getting the raw text
}
})
}
Now, we simply need to install our plugin to our Rehype configuration in our getStaticProps
handler for our blog post page.
import { MDXProvider } from '@mdx-js/react'
import { serialize } from 'next-mdx-remote/serialize'
import rehypePrism from 'rehype-prism-plus'
import rehypeCodeTitles from 'rehype-code-titles'
import rehypeBlockquote from '@/lib/rehype-blockquote-meta'
import { preProcess, postProcess } from '@/lib/rehype-pre-raw'
//... rest of file
export async function getStaticProps({ params: { slug } }) {
const { content, data } = await getContentBySlug(slug, 'articles')
const mdxSource = await serialize(content, {
// Optionally pass remark/rehype plugins
components,
mdxOptions: {
rehypePlugins: [
preProcess,
rehypeCodeTitles,
rehypePrism,
rehypeBlockquote,
postProcess,
],
},
scope: data,
})
return {
props: {
raw: content,
meta: { ...data, slug },
mdxSource,
},
}
}
Great! Now let's build a custom <Pre />
component to wrap our copy button.
Building a React <Pre />
tag with a copy button
Let's start with some fairly boilerplate code, just to see if we're getting the raw text from our Rehype plugins.
export function Pre({ children, raw, ...props }) {
console.log(raw);
return (
<pre {...props}>
{children}
</pre>
);
}
At this point, you should be seeing your raw text logged in the console on both client and server.
Now, let's make a copy button.
import { useState } from 'react'
export function CopyButton ({ text }) {
const [isCopied, setIsCopied] = useState(false)
const copy = async () => {
await navigator.clipboard.writeText(text)
setIsCopied(true)
setTimeout(() => {
setIsCopied(false)
}, 5000)
}
return (
<button disabled={isCopied} onClick={copy}>
{isCopied ? 'Copied!' : 'Copy'}
</button>
)
}
We're simply using a setTimeout
with useEffect
to manage a state piece, then using the MDX navigator
to write text to the browser clipboard.
Now, let's add our CopyButton component to our Pre component...
import { CopyButton } from '@/components/CopyButton'
export function Pre({ children, raw, ...props }) {
return (
<pre {...props}>
{children}
<CopyButton text={raw} />
</pre>
)
}
... and add some basic Tailwind styling to it...
import { useState } from 'react'
import clsx from 'clsx'
const buttonClasses = 'p-2 text-xs font-medium text-white'
export function CopyButton({ text, className }) {
const [isCopied, setIsCopied] = useState(false)
const copy = async () => {
await navigator.clipboard.writeText(text)
setIsCopied(true)
setTimeout(() => {
setIsCopied(false)
}, 5000)
}
return (
<button
disabled={isCopied}
onClick={copy}
className={clsx(buttonClasses, className)}
>
{isCopied ? 'Copied!' : 'Copy'}
</button>
)
}
Here, we're adding clsx
to allow for appended styles, and adding some very basic Tailwind styling to the
button. To get it looking right in our Code editor, however, we are going to want to add some positioning
and more specific styles, where we're actually enabling the Pre tag in our MDX Components.
To do this, we're going to add a buttonClasses
prop to the <Pre />
tag, that we can then pass to the
instance of our <CopyButton />
component.
import { CopyButton } from '@/components/CopyButton'
export function Pre({
children,
raw,
buttonClasses = 'absolute top-3 right-3 bg-zinc-900',
...props
}) {
return (
<pre {...props}>
{children}
<CopyButton text={raw} className={buttonClasses} />
</pre>
)
}
Lastly, We will also need to safely add the relative
Tailwind class to our new Pre
component...
import { CopyButton } from '@/components/CopyButton'
import clsx from 'clsx'
export function Pre({
children,
raw,
buttonClasses = 'absolute top-3 right-3 bg-zinc-900',
...props
}) {
return (
<pre {...props} className={clsx('relative', props.className)}>
{children}
<CopyButton text={raw} className={buttonClasses} />
</pre>
)
}
This makes the absolute positioning of our CopyButton work correctly.
Here's the finished code for the <Pre />
and <CopyButton />
components:
import { CopyButton } from '@/components/CopyButton'
import clsx from 'clsx'
export function Pre({
children,
raw,
buttonClasses = 'absolute top-3 right-3 bg-zinc-900',
...props
}) {
return (
<pre {...props} className={clsx('relative', props.className)}>
{children}
<CopyButton text={raw} className={buttonClasses} />
</pre>
)
}
import { useState } from 'react'
import clsx from 'clsx'
import { ClipboardIcon, CheckIcon } from '@heroicons/react/20/solid'
const buttonClasses = 'flex items-center text-xs font-medium text-white rounded'
export function CopyButton({ text, className }) {
const [isCopied, setIsCopied] = useState(false)
const copy = async () => {
await navigator.clipboard.writeText(text)
setIsCopied(true)
setTimeout(() => {
setIsCopied(false)
}, 2500)
}
const Icon = isCopied ? CheckIcon : ClipboardIcon
return (
<button
disabled={isCopied}
onClick={copy}
className={clsx(buttonClasses, className)}
>
<Icon className="mr-1 h-4 w-4" />
<span>{isCopied ? 'Copied!' : 'Copy'}</span>
</button>
)
}
And with that, we have a couple of nice components to add to our MDX Components.
Adding the Pre component to MDX Components
The last step is enabling the custom <Pre />
component in our MDX Components. To do this,
just find where you're creating your custom components to your Markdown processor.
I'm using MDX remote, and I'm customizing components specifically for my /articles/[slug].jsx
page, like so...
import { MDXProvider } from '@mdx-js/react'
// ... other imports
import { MDXRemote } from 'next-mdx-remote'
import { preProcess, postProcess } from '@/lib/rehype-pre-raw'
import components from '@/components/MDXComponents'
import { ArticleLayout } from '@/components/ArticleLayout'
import { getAllContent, getContentBySlug } from '@/lib/getContent'
const { Note, Pre } = components
const custom = {
ul: (props) => <ul className="ml-2 list-disc" {...props} />,
li: (props) => <li className="my-1 font-light leading-6" {...props} />,
hr: (props) => <hr className="my-16 border-zinc-300" {...props} />,
a: (props) => <a {...props} target="_blank" rel="noopener noreferrer" />,
h3: (props) => (
<h3
className="mb-10 mt-24 text-3xl font-bold tracking-tight sm:mt-36"
{...props}
/>
),
blockquote: (props) => <Note {...props} />,
pre: (props) => <Pre {...props} />,
}
export default function Article(props) {
return (
<MDXProvider components={components}>
<ArticleLayout {...props}>
<MDXRemote
{...props.mdxSource}
components={{ ...components, ...custom }}
/>
</ArticleLayout>
</MDXProvider>
)
}
And with that, we have successfully integrated a Copy Button to our Rehype code snippets on our blog!
Hope this was helpful, and happy coding! 😎🚀
If you enjoyed this article, please consider following me on Twitter