70 %

Build-time code blocks with rehype, prism, and MDX

After I moved my site to Toast I decided to redo the design of my codeblocks. My original implementation was to include prism-react-renderer at runtime by replacing the pre tag using an MDXProvider.

This worked, but it was a bit heavy, coming in at something between 30kb gzipped and 100kb uncompressed. I wanted to move the syntax highlighting to build-time. This led to two requirements.

  1. Build-time syntax highlighting, thus removing prism from the runtime
  2. The ability to also swap between syntax highlighting and a live repl (or other implementation) at runtime

The second requirement is why I couldn't use any of the pre-existing solutions, as they would replace the codestring completely with HTML, making it hard to implement copy buttons and other niceties.

You can see the result as of the writing of this post, in this tweet.

How

I use MDX for all of my blog posts, between some local files for old posts and a Sector source for all of my newer posts. Thus we can use a rehype plugin to do build-time processing before MDX is compiled into JSX. Then we can use a component at runtime to render the appropriate props how we want.

Result

We will end up with a codeblock that can be used like this, with language, line highlighting, and a title prop

```js {2,4-9} title=something
// some code
```

rehype processing

This is the full code for the rehype processing step. There are two critical parts of functionality.

  1. prism-react-renderer
  2. parse-numeric-range
JS
const renderToString = require("preact-render-to-string");
const preact = require("preact");
const { h } = preact;
const Highlight = require("prism-react-renderer");
const visit = require("unist-util-visit");
const rangeParser = require("parse-numeric-range");
const prismTheme = {
plain: {
color: "#d6deeb",
backgroundColor: "#011627"
},
styles: []
};
const RE = /{([\d,-]+)}/;
const calculateLinesToHighlight = meta => {
if (RE.test(meta)) {
const strlineNumbers = RE.exec(meta)[1];
const lineNumbers = rangeParser(strlineNumbers);
console.log(lineNumbers);
return index => lineNumbers.includes(index + 1);
} else {
return () => false;
}
};
module.exports = options => ast => {
visit(ast, "element", tree => {
if (tree.tagName === "code") {
// store codestring for later
tree.properties.codestring = tree.children[0].value;
const shouldHighlightLine = calculateLinesToHighlight(
tree.properties.metastring
);
const lang =
tree.properties.className &&
tree.properties.className[0] &&
tree.properties.className[0].split("-")[1];
const highlightedCode = renderToString(
h(
Highlight.default,
{
...Highlight.defaultProps,
...{
code: tree.children[0].value.trim(),
language: lang,
theme: prismTheme
}
},
({ className, style, tokens, getLineProps, getTokenProps }) =>
h(
"pre",
{
className: className,
style: { ...style, "background-color": "transparent" }
},
tokens.map((line, i) =>
h(
"div",
getLineProps({
line,
key: i,
style: shouldHighlightLine(i)
? {
borderLeft: "1px solid red",
backgroundColor: "hsla(220, 26%, 13%, 1)",
margin: "0 -2rem",
padding: "0 2rem",
borderLeft: "1px solid rgba(51,183,255,.41)"
}
: {}
}),
line.map((token, key) =>
h(
"span",
getTokenProps({
token,
key
})
)
)
)
)
)
)
);
// render code to string
tree.children = [
{
value: highlightedCode,
type: "text"
}
];
}
});
};

The line highlighting is enabled by the regex matching and a call to rangeParser.

range-parsing
JS
const RE = /{([\d,-]+)}/;
const calculateLinesToHighlight = meta => {
if (RE.test(meta)) {
const strlineNumbers = RE.exec(meta)[1];
const lineNumbers = rangeParser(strlineNumbers);
console.log(lineNumbers);
return index => lineNumbers.includes(index + 1);
} else {
return () => false;
}
};
<Aside>

The highlighting regex code is directly taken from Prince's Highlight with React post. I'm planning to change it slightly in the future to be lines=2,5-9 instead of {2,5-9} because MDX already handles the parsing.

</Aside>

Then we make sure we operate only on the code elements (remember we're in HTML AST land now, because rehype) and store the original codestring as a property for later. This will enable us to receive the raw codestring value as a prop in our MDX component later.

JS
visit(ast, "element", tree => {
if (tree.tagName === "code") {
// store codestring for later
tree.properties.codestring = tree.children[0].value;
...
}
...
})

I use Preact for my current site, so we can use preact-render-to-string to render our Highlight component to a string and replace the children for the code element. This means that if we don't do anything special in our rendering, we get the syntax highlighted version of the code by default.

// render code to string
tree.children = [
{
value: highlightedCode,
type: "text"
}
];

Applying the plugin

To use a rehype plugin in MDX, we add the plugin to the rehypePlugins list when processing our MDX content.

JS
const mdx = require("@mdx-js/mdx");
const compiledMDX = await mdx(file, {
rehypePlugins: [rehypePrism],
});

MDXProvider

In the object that is passed to the MDXProvider components lets us overwrite the pre element at runtime. This code will run in the browser. You can see that we can take the props and grab the language and title from it to render in different places. We use the codestring from earlier in a CopyButton implementation so the visitor can copy the codeblock at will. Finally we dangerouslySetInnerHTML the syntax highlighted string. I chose to not serialize these children through the rehype AST but we could have if we wanted to, in which case we wouldn't need to sethtml here.

JS
{
pre: (props) => {
const lang =
props.children.props.class && props.children.props.class.split("-")[1];
const langMap = {
graphql: "GraphQL",
js: "JS",
};
return (
<div
css={{
gridColumn: 2,
background: "#11151d",
overflow: "auto",
borderRadius: 10,
padding: "0 2rem 2rem",
marginTop: "1rem",
position: "relative",
border: "1px solid rgba(51,183,255,.21)",
boxShadow: `inset 0 2.8px 2.2px rgba(0,0,0,0.02),
inset 0 6.7px 5.3px rgba(0,0,0,0.028),
inset 0 12.5px 10px rgba(0,0,0,0.035),
inset 0 22.3px 17.9px rgba(0,0,0,0.042),
inset 0 41.8px 33.4px rgba(0,0,0,0.05),
inset 0 100px 80px rgba(0,0,0,0.07)`,
}}
>
<div
css={{
fontSize: `12px`,
display: `flex`,
justifyContent: `space-between`,
position: `sticky`,
left: 0,
margin: "0 -2rem",
borderBottom: "1px solid rgba(51,183,255,.21)",
}}
>
<span css={{ padding: "1rem" }}>{props.children.props.title}</span>
<div css={{ display: "flex" }}>
<span css={{ padding: "1rem" }}>{langMap[lang] || lang || ""}</span>
<CopyButton content={props.children.props.codestring} />
</div>
</div>
<div
css={{ marginTop: "1rem" }}
dangerouslySetInnerHTML={{
__html: props.children.props.children,
}}
/>
</div>
);
};
}

If you enjoyed this post, take a look at the resulting codeblocks that I tweeted and reply telling me!