Titles, highlights, and line numbers with next-mdx-remote

Titles, highlights, and line numbers with next-mdx-remote

I love the look of Josh W. Comeau's blog, especially his attention to detail in his code components. He makes them really friendly and approachable, but also usable, and fairly copy / pastable.

I wanted to try to emulate that on this blog, with titles, highlights and line-numbers.

I don't typically use line numbers in my code blocks, but it really helps with figuring out which line numbers to highlight.

When I started on implementing this stuff, I saw that Josh was using prism-react-renderer in a custom MDX component. Problem was, I'm using next-mdx-remote to render my MDX files, and I couldn't figure out how to get the two to work together.

After about a day of messing with it, I decided I would, at leat for a while, sacrifice the flexibility of a custom component for Go To Market speed 😉 and see if I could get next-mdx-remote working with plugins.

Here's how I did it.

Switch from @mapbox/rehype-prism to rehype-prism-plus

Most of the changes for this ended up being css or in my /src/pages/articles/[slug].jsx files getStaticProps function, which is where the MDX is processed.

Here was my original file.

src/pages/articles/[slug].jsx
import { MDXProvider } from '@mdx-js/react';
import { serialize } from 'next-mdx-remote/serialize';
import remarkGfm from 'remark-gfm'
import rehypePrism from '@mapbox/rehype-prism'
import remarkMdxCodeMeta from 'remark-mdx-code-meta';
import { MDXRemote } from 'next-mdx-remote'

import components from '@/components/MDXComponents';
import { ArticleLayout } from '@/components/ArticleLayout';
import { getAllContent, getContentItem } from '@/lib/getContent';

const custom = {
  ul: props => <ul className="list-disc ml-2" {...props} />,
  li: props => <li className="font-light my-1 leading-6" {...props} />,
  hr: props => <hr className="my-16 border-zinc-300" {...props} />,
  a: props => <a {...props} target="_blank" rel="noopener noreferrer" />,
}


export default function Article(props) {
  return (
    <MDXProvider components={components}>
      <ArticleLayout {...props}>
        <MDXRemote {...props.mdxSource} components={{...components, ...custom}} />
      </ArticleLayout>
    </MDXProvider>
  )
}

export async function getStaticPaths() {
  const articles = await getAllContent('articles');
  const paths = articles.map(a => `/articles/${a.slug}`);

  return {
    paths,
    fallback: false,
  };
}

export async function getStaticProps({ params: { slug } }) {
  const { content, data } = await getContentItem(slug, 'articles');

  const mdxSource = await serialize(content, {
    // Optionally pass remark/rehype plugins
    components,
    mdxOptions: {
      remarkPlugins: [remarkGfm, remarkMdxCodeMeta],
      rehypePlugins: [rehypePrism],
    },
    scope: data,
  });

  return {
    props: {
      raw: content,
      meta: { ...data, slug },
      mdxSource,
    },
  };
}

I did some reading, and found that rehype-prism-plus was a fork of @mapbox/rehype-prism that allowed enabling line numbers and line highlighting.

I also found rehype-code-titles which allowed adding titles to code blocks.

So I added them as plugins.

src/pages/articles/[slug].jsx
import { MDXProvider } from '@mdx-js/react';
import { serialize } from 'next-mdx-remote/serialize';
import remarkGfm from 'remark-gfm'
- import rehypePrism from '@mapbox/rehype-prism'
+ import rehypePrism from 'rehype-prism-plus'
+ import rehypeCodeTitles from 'rehype-code-titles'
import remarkMdxCodeMeta from 'remark-mdx-code-meta';
import { MDXRemote } from 'next-mdx-remote'

import components from '@/components/MDXComponents';
import { ArticleLayout } from '@/components/ArticleLayout';
import { getAllContent, getContentItem } from '@/lib/getContent';

const custom = {
  ul: props => <ul className="list-disc ml-2" {...props} />,
  li: props => <li className="font-light my-1 leading-6" {...props} />,
  hr: props => <hr className="my-16 border-zinc-300" {...props} />,
  a: props => <a {...props} target="_blank" rel="noopener noreferrer" />,
}


export default function Article(props) {
  return (
    <MDXProvider components={components}>
      <ArticleLayout {...props}>
        <MDXRemote {...props.mdxSource} components={{...components, ...custom}} />
      </ArticleLayout>
    </MDXProvider>
  )
}

export async function getStaticPaths() {
  const articles = await getAllContent('articles');
  const paths = articles.map(a => `/articles/${a.slug}`);

  return {
    paths,
    fallback: false,
  };
}

export async function getStaticProps({ params: { slug } }) {
  const { content, data } = await getContentItem(slug, 'articles');

  const mdxSource = await serialize(content, {
    // Optionally pass remark/rehype plugins
    components,
    mdxOptions: {
      remarkPlugins: [remarkGfm, remarkMdxCodeMeta],
-      rehypePlugins: [rehypePrism],
+      rehypePlugins: [rehypePrism, rehypeCodeTitles],
    },
    scope: data,
  });

  return {
    props: {
      raw: content,
      meta: { ...data, slug },
      mdxSource,
    },
  };
}

That produced HTML output that looked more correct.

HTML
<pre class="language-jsx">
  <code class="language-jsx code-highlight">
    <span class="code-line">
      <!-- tokens -->
    </span>
  </code>
</pre>

Notice the code-highlight and code-line classes have been added.

Adding the styles

I have a custom tailwind.css file that imports my prism styles like so:

/src/styles/tailwind.css
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import './prism.css';
@import 'tailwindcss/utilities';

And my custom prism.css file looks like this:

src/styles/prism.css
pre[class*='language-'] {
  color: theme('colors.zinc.100');
  margin-top: 1rem !important;
}

.token.tag,
.token.class-name,
.token.selector,
.token.selector .class,
.token.selector.class,
.token.function {
  color: theme('colors.pink.400');
}

.token.attr-name,
.token.keyword,
.token.rule,
.token.pseudo-class,
.token.important {
  color: theme('colors.blue.300');
}

.token.module {
  color: theme('colors.purple.300');
}

.token.attr-value {
  color: theme('colors.blue.300');
}

.token.string {
  color: theme('colors.orange.300')
}

.token.class {
  color: theme('colors.teal.300');
}

.token.punctuation,
.token.attr-equals {
  color: theme('colors.zinc.500');
}

.token.unit,
.language-css .token.function {
  color: theme('colors.sky.200');
}

.token.comment {
  color: theme('colors.emerald.400');
  opacity: 0.7;
}

.token.property,
.token.operator,
.token.combinator {
  color: theme('colors.zinc.400');
}

So I found the style guide for rehype-prism-plus and made some edits:

src/styles/prism.css
pre[class*='language-'] {
  color: theme('colors.zinc.100');
  margin-top: 1rem !important;
}

/* ... token CSS ... */

.token.property,
.token.operator,
.token.combinator {
  color: theme('colors.zinc.400');
}

.code-highlight {
  float: left; /* 1 */
  min-width: 100%; /* 2 */
}

.code-line {
  display: block;
  padding-left: 16px;
  padding-right: 16px;
  margin-left: -16px;
  margin-right: -16px;
  border-left: 4px solid rgba(0, 0, 0, 0); /* Set placeholder for highlight accent border color to transparent */
  line-height: 1.5rem;
}

.code-line.inserted {
  background-color: theme('colors.emerald.900'); /* Set inserted line (+) color */
}

.code-line.deleted {
  background-color: theme('colors.red.900'); /* Set deleted line (-) color */
}

.highlight-line {
  margin-left: -14px;
  margin-right: -16px;
  background-color: theme('colors.zinc.800'); /* Set highlight bg color */
  border-left: 2px solid theme('colors.amber.400'); /* Set highlight accent border color */
}

.line-number::before {
  display: inline-block;
  width: 1rem;
  text-align: right;
  margin-right: 16px;
  margin-left: -8px;
  color: theme('colors.zinc.500');
  content: attr(line);
}

.rehype-code-title {
  margin: 0 !important;
  display: inline-flex;
  position: relative;
  top: 1rem;
  left: 2rem;
  background: theme('colors.zinc.900');
  color: theme('colors.zinc.100');
  font-family: theme('fontFamily.mono');
  padding: 0.25rem 1.5rem;
  border-radius: 0.5rem 0.5rem 0 0;
  border-top: 4px solid theme('colors.indigo.600');
  font-size: 0.8rem;
}

And that's that! 😎✨

Using Code Block Titles in MDX

Adding a title to a code block is now really simple:

MDX
'''jsx:src/components/ArticleLayout.js

'''

The string after the : will be the title of the code block, and will appear at the top left.

Using Line Numbers in MDX

Adding line numbers to a code block is also really simple:

MDX
'''jsx showLineNumbers

'''

Easy peasy! 🍋🍋🍋

Using Line Highlights in MDX

Lastly, here's how you can add line highlights to a code block:

MDX
'''jsx {2}
Here is the highlighted line!
'''

You can even do multiple lines, and ranges:

MDX
'''jsx {1,3-4}
Here is the highlighted line!

And 2 more
highlighted lines.
'''

Putting it all together

Here's an example of using all 3 features in one code block:

index.jsx
'''jsx:index.jsx {2,4-5} showLineNumbers
Here is the highlighted line!

And 2 more
highlighted lines.
'''

Though, like I said, I don't typically use line numbers.

Bonus: File diffs

One thing I realized as I was pasting the CSS for our code blocks were these classes:

src/styles/prism.css
.code-line.inserted {
  background-color: theme('colors.emerald.900'); /* Set inserted line (+) color */
}

.code-line.deleted {
  background-color: theme('colors.red.900'); /* Set deleted line (-) color */
}

After doing a little digging, I figured out that there was a diff language type that I could to show diffs.

Here's how to use it:

DIFF
'''diff
+ Added line
- Deleted line
'''

You simply select diff as the syntax language, then add + or - to the beginning of the line to indicate whether it was added or deleted. The above block would result in:

+ Added line
- Deleted line

Pretty cool, eh? 😎✨

Removing Remark Plugins

One last thing I noticed in my getStaticProps function was the Remark plugins that I added when I copied and pasted sample code into this blog.

src/pages/articles/[slug].jsx
export async function getStaticProps({ params: { slug } }) {
  const { content, data } = await getContentItem(slug, 'articles');

  const mdxSource = await serialize(content, {
    // Optionally pass remark/rehype plugins
    components,
    mdxOptions: {
      remarkPlugins: [remarkGfm, remarkMdxCodeMeta],
      rehypePlugins: [rehypePrism, rehypeCodeTitles],
    },
    scope: data,
  });

  return {
    props: {
      raw: content,
      meta: { ...data, slug },
      mdxSource,
    },
  };
}

I got curious to see if they did anything when I removed them, and they didn't. So I removed them and the imports...

src/pages/articles/[slug].jsx
- import remarkGfm from 'remark-gfm'
import rehypePrism from 'rehype-prism-plus'
import rehypeCodeTitles from 'rehype-code-titles'
- import remarkMdxCodeMeta from 'remark-mdx-code-meta';


export async function getStaticProps({ params: { slug } }) {
  const { content, data } = await getContentItem(slug, 'articles');

  const mdxSource = await serialize(content, {
    // Optionally pass remark/rehype plugins
    components,
    mdxOptions: {
-      remarkPlugins: [remarkGfm, remarkMdxCodeMeta],
      rehypePlugins: [rehypePrism, rehypeCodeTitles],
    },
    scope: data,
  });

  return {
    props: {
      raw: content,
      meta: { ...data, slug },
      mdxSource,
    },
  };
}

Updating other serialize functions

Lastly, there were a couple of other places in my project that I needed to update my serialize function options:

next-config.js
import nextMDX from '@next/mdx'
import rehypePrism from 'rehype-prism-plus'
import rehypeCodeTitles from 'rehype-code-titles'

/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ['js', 'jsx', 'mdx'],
  reactStrictMode: true,
  experimental: {
    scrollRestoration: true,
  }
}

const withMDX = nextMDX({
  extension: /\.mdx?$/,
  options: {
    rehypePlugins: [rehypeCodeTitles, rehypePrism],
    providerImportSource: '@mdx-js/react',
  },
})

export default withMDX(nextConfig)

This ensures any pages that straight MDX in my /pages directory will also have the code block features.

And finally, I'm generating an RSS feed in my pages/index.jsx file, so I needed to update that method as well, otherwise, I was getting NextJS build errors.

/src/pages/index.jsx
export async function getStaticProps() {
  if (process.env.NODE_ENV === 'production') {
    await generateRssFeed()
  }

  const articles = (await getAllContent('articles'))
    .slice(0, 4)
    .map(({ component, ...meta }) => meta)

  return {
    props: {
      articles,
    },
  }
}

And the generateRssFeed function:

src/lib/generateRssFeed.js
import ReactDOMServer from 'react-dom/server'
import { Feed } from 'feed'
import { mkdir, writeFile } from 'fs/promises'
import { withRouter } from 'next/router'
import { MDXRemote } from 'next-mdx-remote'
import { serialize } from 'next-mdx-remote/serialize'
import rehypePrism from 'rehype-prism-plus'
import rehypeCodeTitles from 'rehype-code-titles'

import components from '@/components/MDXComponents'
import { getAllContent } from '@/lib/getContent'

export async function generateRssFeed() {
  let articles = await getAllContent('articles')
  let siteUrl = process.env.NEXT_PUBLIC_SITE_URL
  let author = {
    name: 'Ty Barho',
    email: 'ty@tybarho.com',
  }

  let feed = new Feed({
    title: author.name,
    description: 'Writing on technology, dad-hacking, and spiritual formation.',
    author,
    id: siteUrl,
    link: siteUrl,
    image: `${siteUrl}/favicon.ico`,
    favicon: `${siteUrl}/favicon.ico`,
    copyright: `All rights reserved ${new Date().getFullYear()}`,
    feedLinks: {
      rss2: `${siteUrl}/rss/feed.xml`,
      json: `${siteUrl}/rss/feed.json`,
    },
  })

  for (let article of articles) {
    let url = `${siteUrl}/articles/${article.slug}`
    const mdxSource = await serialize(article.content, {
      // Optionally pass remark/rehype plugins
      components,
      mdxOptions: {
        rehypePlugins: [rehypeCodeTitles, rehypePrism],
      },
      scope: article.data,
    })

    let html = ReactDOMServer.renderToStaticMarkup(
      <MDXRemote {...mdxSource} components={components} />
    )

    feed.addItem({
      title: article.data.title,
      id: url,
      link: url,
      description: article.data.description,
      content: html,
      author: [author],
      contributor: [author],
      date: new Date(article.data.date),
    })
  }

  await mkdir('./public/rss', { recursive: true })
  await Promise.all([
    writeFile('./public/rss/feed.xml', feed.rss2(), 'utf8'),
    writeFile('./public/rss/feed.json', feed.json1(), 'utf8'),
  ])
}

And that's that! 🎉 Hope you enjoyed!

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: