DEV Community

Vaishnav
Vaishnav

Posted on • Originally published at vaishnavs.xyz

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:

  • Better syntax highlighting
  • 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:

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,
});
Enter fullscreen mode Exit fullscreen mode

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",
};
Enter fullscreen mode Exit fullscreen mode

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:

Codeblock DOM Node

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.

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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
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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Usages

import { MDXRemote } from "next-mdx-remote";
import Figure from "./Figure";

const MDXComponents = {
  // ...other components
  figure: Figure,
};

const RenderContent = () => {
  return <MDXRemote {...snippet.source} components={MDXComponents} />;
};
Enter fullscreen mode Exit fullscreen mode

Finally rendered codeblock will look like this.

Final Codeblock

Try demo here

Top comments (0)