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.
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.
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.
<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:
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import './prism.css';
@import 'tailwindcss/utilities';
And my custom prism.css
file looks like this:
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:
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:
'''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:
'''jsx showLineNumbers
'''
Easy peasy! 🍋🍋🍋
Using Line Highlights in MDX
Lastly, here's how you can add line highlights to a code block:
'''jsx {2}
Here is the highlighted line!
'''
You can even do multiple lines, and ranges:
'''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:
'''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:
.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
+ 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.
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...
- 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:
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.
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:
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