Build-Time Syntax Highlighting: Zero Client-Side JS, Support for 100+ Languages and Any VSCode Theme

Aug 24
·
views
·
likes

Note: This post is not finished. I sometimes publish early in case it will be helpful to someone.

If you're writing about code, your blog probably needs...

Sound good? Let's get started.

Example

Here is an example of what we will be creating today:

pages/blog/[slug].tsx
import { allPosts, type Post } from "contentlayer/generated"
import { type GetStaticProps, type InferGetStaticPropsType } from "next"
 
export const getStaticPaths = () => ({
  paths: allPosts.map((post) => ({ params: { slug: post.slug } })),
  fallback: false,
})
 
export const getStaticProps: GetStaticProps<{
  post: Post
}> = ({ params }) => ({
  props: { post: allPosts.find((post) => post.slug === params?.slug) },
})
 
export default function SinglePostPage(
  props: InferGetStaticPropsType<typeof getStaticProps>,
) {
  return (
    <div>
      <h1>{props.post.title}</h1>
    </div>
  )
}

How It Works

As discussed in a previous post, we're using Contentlayer to integrate MDX and manage our content. Today we'll use the Pretty Code plugin to add syntax highlighting to code blocks in our markdown posts.

Here is how it works:

Before syntax highlighting:

const multiply = (a, b) => a * b

After syntax highlighting:

const multiply = (a, b) => a * b

The code block above generates the following HTML:

<div>
  <pre>
    <code>
      <span class="line">
        <span style="color:#C678DD">const</span>
        <span style="color:#ABB2BF"> </span>
        <span style="color:#61AFEF">multiply</span>
        <span style="color:#ABB2BF"> </span>
        <span style="color:#56B6C2">=</span>
        <span style="color:#ABB2BF"> (</span>
        <span style="color:#E06C75;font-style:italic">a</span>
        <span style="color:#ABB2BF">, </span>
        <span style="color:#E06C75;font-style:italic">b</span>
        <span style="color:#ABB2BF">) </span>
        <span style="color:#C678DD">=&gt;</span>
        <span style="color:#ABB2BF"> </span>
        <span style="color:#E06C75">a</span>
        <span style="color:#ABB2BF"> </span>
        <span style="color:#56B6C2">*</span>
        <span style="color:#ABB2BF"> </span>
        <span style="color:#E06C75">b</span>
      </span>
    </code>
  </pre>
</div>

Why Is This a Big Deal?

Install Pretty Code

Terminal
npm install rehype-pretty-code shiki

Setup Pretty Code and Contentlayer

./contentlayer.config.js
import { makeSource } from "contentlayer/source-files"
import rehypePrettyCode from "rehype-pretty-code"
import { Post } from "./content/definitions/Post"
 
export default makeSource({
  contentDirPath: "content",
  documentTypes: [Post],
  mdx: {
    rehypePlugins: [rehypePrettyCode],
  },
})

Create a Code Block

Let's create a post to preview our progress so far.

./content/posts/syntax-highlighting.mdx
# Code Block
 
This is my first code block:
 
```js
const multiply = (a, b) => a * b
```

If you're using VS Code to author your post, you may notice you're code blocks are automatically highlighted when you add the language annotation. Also, Prettier formats them on save. Pretty cool!

Customize Theme

We can customize the VS Code theme used to highlight our code.

./lib/rehypePrettyCode.ts
import { type Options } from "rehype-pretty-code"
import vercelLightTheme from "./lib/themes/vercel-light.json"
 
export const rehypePrettyCodeOptions: Partial<Options> = {
  // use a prepackaged theme
  theme: "one-dark-pro",
  // or import a custom theme
  theme: JSON.parse(vercelLightTheme),
}
./contentlayer.config.js
import { makeSource } from "contentlayer/source-files"
import rehypePrettyCode from "rehype-pretty-code"
import { Post } from "./content/definitions/Post"
+ import { rehypePrettyCodeOptions } from "./lib/rehyePrettyCode"
 
export default makeSource({
  contentDirPath: "content",
  documentTypes: [Post],
  mdx: {
    rehypePlugins: [
-     rehypePrettyCode
+     [rehypePrettyCode, rehypePrettyCodeOptions]
    ],
  },
})

Customizing Code Blocks

Using Annotations

We can enable features per code block using annotations. For example, we can set the language, enable line numbers, add a title, and highlight lines.

The following annotations:

```js showLineNumbers title="multiply.js" {3}
const multiply = (a, b) => a * b

multiply(2, 2) // 4
```

Become:

multiply.js
const multiply = (a, b) => a * b
 
multiply(2, 2) // 4

Using Styling

Outside of the syntax highlighting itself, Pretty Code doesn't come with any styling. We can use the data attributes it adds to code blocks to style elements.

The HTML structure and data attributes can be found by inspecting a code block in your browser's developers tools:

Example HTML output
<div data-rehype-pretty-code-fragment>
  <pre data-language="js" data-theme="default">
    <code data-language="js" data-theme="default">
      <span class="line">
        <span style="color:#C678DD">a</span>
        <span style="color:#ABB2BF">b</span>
        <span style="color:#61AFEF">c</span>
        <!-- [...] -->
      </span>
    </code>
  </pre>
</div>

Create a new css file:

./styles/syntax-highlighting.css
div[data-rehype-pretty-code-fragment] {
  /* ... */
}

And import it in your main Tailwind file:

./styles/globals.css
@tailwind base;
@import "./syntax-highlighting.css";
@tailwind components;
@tailwind utilities;

Code Block Container

Let's start by creating a styled wrapper for our code blocks.

./styles/syntax-highlighting.css
div[data-rehype-pretty-code-fragment] {
  overflow: hidden;
 
  /* stylist preferences */
  background-color: rgb(255 255 255 / 0.1);
  border-radius: 0.5rem;
}
 
div[data-rehype-pretty-code-fragment] pre {
  overflow-x: auto;
 
  /* stylist preferences */
  padding-top: 0.5rem;
  padding-bottom: 0.5rem;
  font-size: 0.875rem;
  line-height: 1.5rem;
}

Block Titles

We can add title annotations to our code blocks using the title="..." syntax:

./content/posts/syntax-highlighting.mdx
```js title="multiply.js"
const multiply = (a, b) => a * b
```

And we can style those titles using the data-rehype-pretty-code-title data attribute:

./styles/syntax-highlighting.css
div[data-rehype-pretty-code-title] {
  /* stylistic preferences */
  margin-bottom: 0.125rem;
  border-radius: 0.375rem;
  background-color: rgb(255 228 230 / 0.1);
  padding-left: 0.75rem;
  padding-right: 0.75rem;
  padding-top: 0.25rem;
  padding-bottom: 0.25rem;
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
    "Liberation Mono", "Courier New", monospace;
  font-size: 0.75rem;
  line-height: 1rem;
  color: rgb(255 228 230 / 0.7);
}

Lines

Pretty Code adds the .line class to each line of code. We can use this to add styling to our lines.

./styles/syntax-highlighting.css
div[data-rehype-pretty-code-fragment] .line {
  /* stylistic preferences */
  padding-left: 0.75rem;
  padding-right: 0.75rem;
}

Line Highlights

We can highlight different lines using the {<line number>} annotation e.g. {3}. Multiple lines and ranges are supported e.g {3,4-8}.

./content/posts/syntax-highlighting.mdx
```js title="multiply.js"
const multiply = (a, b) => a * b
```

We can change our .line styles to accommodation highlighted lines:

./styles/syntax-highlighting.css
div[data-rehype-pretty-code-fragment] .line {
  /* stylistic preferences */
  padding-left: 0.5rem
  padding-right: 0.75rem;
 
  border-left-width: 4px;
  border-left-color: transparent;
}
 
div[data-rehype-pretty-code-fragment] .line--highlighted {
  border-left-color: rgb(253 164 175 / 0.7);
  background-color: rgb(254 205 211 / 0.1);
}

While Pretty Code automatically adds a .line class to each line of code, it doesn't automatically add classes for highlighted lines. We can push line--highlighted to the list of line classes using the onVisitHighlightedLine() method.

./lib/rehypePrettyCode.ts
import { type Options } from "rehype-pretty-code"
 
export const rehypePrettyCodeOptions: Partial<Options> = {
  theme: "one-dark-pro",
  onVisitHighlightedLine(node) {
    node.properties.className.push("line--highlighted")
  },
}

If you preview your changes you will notice the highlighted line doesn't span the full width of the code block. This is because lines are wrapped in a span (inline) and not a div (block). I'd imagine this is not to interfere with the intrinsic indentation of pre elements).

Converting the code element to a grid layout ensures the spans fill the full width of a horizontally-scrollable code block.

./styles/syntax-highlighting.css
div[data-rehype-pretty-code-fragment] code {
  display: grid;
}

Line Numbers

Similarly, we can enable line numbers in a code block using showLineNumbers:

./content/posts/syntax-highlighting.mdx
```js title="multiply.js" showLineNumbers
const multiply = (a, b) => a * b
```

However, this simply adds a data attribute to indicate the option should be enabled and doesn't add any HTML elements to the code block.

Instead, we can make clever use of the special counter() CSS function to add line numbers to our code blocks.

./styles/syntax-highlighting.css
code[data-line-numbers] {
  counter-reset: lineNumber;
}
 
code[data-line-numbers] .line::before {
  counter-increment: lineNumber;
  content: counter(lineNumber);
  display: inline-block;
  text-align: right;
 
  /* stylistic preferences */
  margin-right: 0.75rem;
  width: 1rem;
  color: rgb(255 255 255 / 0.2);
}

Dark and Light Mode

There is a performance compromise with supporting theme switching. We need to generate and send two themes for every code block because syntax highlighting is generated at build-time, and theme switching happens on the client at run-time.

Another way to add theme switching involves adding global CSS variables to a page and using a special theme that sets CSS variables instead of hard-coded colors. I like the idea of this approach, but right now the theme only supports a few tokens, making it much less granular than most VSCode themes.

First, change the theme option from a value to an object with dark and light options:

./lib/rehypePrettyCode.ts
import { type Options } from "rehype-pretty-code"
 
export const rehypePrettyCodeOptions: Partial<Options> = {
  theme: {
    dark: "one-dark-pro",
    light: "solarized-light",
  },
}

Then use CSS to show and hide the light or dark theme depending on the user's selection.

./styles/syntax-highlighting.css
pre[data-theme="dark"] {
  color-scheme: dark;
}
 
@media (prefers-color-scheme: dark) {
  pre[data-theme="light"] {
    display: none;
  }
}
 
@media (prefers-color-scheme: light), (prefers-color-scheme: no-preference) {
  pre[data-theme="dark"] {
    display: none;
  }
}

We will discuss adding the actual functionality of a theme switcher in a future post.

Closing Thoughts

...

Series
Build a Developer Blog with Next.js