Syntax highlight with Shiki and Adding Copy Functionality to Codeblocks

Recently, I decided to add a code snippets section to my website. I realized two key features would greatly improve the user experience:

  1. Better syntax highlighting
  2. A copy code block button

Let's walk through how to implement these features step by step.

Implementing Syntax Highlighting

First, let's look at our current setup. We're using MDX with next-mdx-remote to render our content. Here's the basic structure of our MDX processing:

mdx.ts
const source = fs.readFileSync(contentPath);
const { data, content } = matter(source);
 
const mdxSource = await serialize(content, {
  mdxOptions: {
    format: "mdx",
    remarkPlugins: [remarkGfm],
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, { properties: { className: ["anchor"] } }],
      [rehypePrettyCode, shikiOptions],
      rehypeAccessibleEmojis,
    ],
  },
  scope: data,
});

This code reads the content file, processes it with various plugins, and prepares it for rendering. The key plugin for our syntax highlighting is rehypePrettyCode. rehype-pretty-code is a Rehype plugin powered by the shiki syntax highlighter that provides beautiful code blocks for Markdown or MDX. It works on both the server at build-time (avoiding runtime syntax highlighting) and on the client for dynamic highlighting.

To use a specific theme, we can configure shikiOptions like this:

const shikiOptions = {
  theme: "catppuccin-latte",
};

I'm using the "catppuccin-latte" theme, but you can explore more themes at https://shiki.style/themes.

Adding copy button to codeblocks

Now that we have syntax highlighting working, let's add a copy button to our code blocks. Instead of creating a new custom component for each code block in our MDX files, we'll modify how the code blocks are rendered on the UI. Here's how a code block typically looks in the DOM:

syntax-highlight-shiki.png

We'll create a custom Figure component that will be used by MDXRemote to render these elements. This approach doesn't require importing the component in each MDX file.

Figure.jsx
const Figure = (props) => {
  const { children, ...rest } = props;
  const figureRef = useRef(null);
 
  const isReactElement = (node) => {
    return React.isValidElement(node);
  };
 
  const childArray = React.Children.toArray(children);
  const figCaptionChild = childArray.find(
    (node) => isReactElement(node) && node.type === "figcaption"
  );
  const preChild = childArray.find(
    (node) => isReactElement(node) && node.type === "pre"
  );
 
  const handleCopyClick = async () => {
    const codeBlock = figureRef.current;
    if (codeBlock) {
      const codeNode = codeBlock.querySelector("code");
      if (codeNode) {
        navigator.clipboard.writeText(codeNode.textContent || "");
      }
    }
  };
 
  return (
    <figure ref={figureRef} {...rest}>
      {figCaptionChild && React.isValidElement(figCaptionChild) ? (
        <FigureCaption
          {...figCaptionChild.props}
          handleCopyClick={handleCopyClick}
        />
      ) : null}
      {preChild}
    </figure>
  );
};

This component does the following:

  1. Finds the figcaption and pre elements among its children
  2. Implements a handleCopyClick function to copy the code content
  3. Renders a custom FigureCaption component with the copy button
FigureCaption.jsx
const FigureCaption = ({ children, handleCopyClick, ...rest }) => {
  const [isCopied, setIsCopied] = useState(false);
 
  const onClick = () => {
    setIsCopied(true);
    handleCopyClick();
    setTimeout(() => setIsCopied(false), 1000);
  };
 
  return (
    <figcaption {...rest} className="flex items-center justify-between">
      {children}
      <button type="button" onClick={onClick}>
        {isCopied ? <CopiedSVG /> : <CopySVG />}
      </button>
    </figcaption>
  );
};

Usage

import { MDXRemote } from "next-mdx-remote";
import Figure from "./Figure";
 
const MDXComponents = {
  // ...other components
  figure: Figure,
};
 
const RenderContent = () => {
  return <MDXRemote {...snippet.source} components={MDXComponents} />;
};