Add a copy button to your Rehype (NextJS / MDX) code snippets

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:

  1. Getting the <pre> content from Rehype via a plugin.
  2. A <CopyButton> component that copies the raw snippet content.
  3. 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.

src/lib/rehype-pre-raw.js
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
    }
  })
}
Note
When you add these plugins to your serializer, you'll need to ensure that your 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.

src/components/CopyButton/index.jsx
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 />.

src/components/Pre/index.jsx
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.

About AST's
Rehype is built on top of an HTML AST (Abstract Syntax Tree). AST's are typically large, nested objects that describe a thing.

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.

src/lib/rehype-pre-raw.js
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.

src/lib/rehype-pre-raw.js
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.

src/pages/articles/[slug].js
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.

src/components/Pre/index.jsx
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.

src/components/CopyButton/index.jsx
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.

src/components/Pre/index.jsx
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...

src/components/Pre/index.jsx
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:

src/components/Pre/index.jsx
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>
  )
}

src/components/CopyButton/index.jsx
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...

src/pages/articles/[slug].jsx
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

Subscribe to the Newsletter

Subscribe for exclusive tips, strategies, and resources to launch, grow, & build your SaaS team.

Share this article on: