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.
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.
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.
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```
This is the full code for the rehype processing step. There are two critical parts of functionality.
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 latertree.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 stringtree.children = [{value: highlightedCode,type: "text"}];}});};
The line highlighting is enabled by the regex matching and a call to rangeParser
.
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;}};
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.
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.
visit(ast, "element", tree => {if (tree.tagName === "code") {// store codestring for latertree.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 stringtree.children = [{value: highlightedCode,type: "text"}];
To use a rehype plugin in MDX, we add the plugin to the rehypePlugins
list when processing our MDX content.
const mdx = require("@mdx-js/mdx");const compiledMDX = await mdx(file, {rehypePlugins: [rehypePrism],});
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.
{pre: (props) => {const lang =props.children.props.class && props.children.props.class.split("-")[1];const langMap = {graphql: "GraphQL",js: "JS",};return (<divcss={{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)`,}}><divcss={{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><divcss={{ 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!