Nime

Syntax highlighting codeblocks with theme-ui

A recent blog post by Prince about how to use syntax highlighting in a site that uses mdx made me want to add syntax highlighting for codeblocks using the same method.

I was using @theme-ui/prism, which did the same thing, making me postpone implementing it for myself.

His followup post about highlighting lines convinced me. I was going to follow that guide, and combine it with the goodness of @theme-ui/prism.

I wanted to keep using the awesome presets from @theme-ui/prism to use colors from popular syntax highlighting themes such as night owl, dracula, night owl light, and many more.

Using the @theme-ui/prism component

Even though I’m going to remove the @theme-ui/prism component immediately after this, the official docs for @theme-ui/prism have an excellent explanation for how to replace the codeblocks (triple backticks) in markdown/mdx with a custom component.

Replacing the component

The Prism component that those docs told us to use? I replaced it with my own.

I created a component called CodeBlock.js, and used that instead.

It uses the same internals as @theme-ui/prism. Only this time, I can tweak them and add cool stuff like line highlighting.

Those internals are powered by a package called prism-react-renderer.

To start, this file will look almost identical to the Prism component from @theme-ui/prism.

CodeBlock.js
/** @jsx jsx */
import React from "react";
import Highlight, { defaultProps } from "prism-react-renderer";
import { jsx, Themed } from "theme-ui";
const CodeBlock = ({ children, className: outerClassName, ...props }) => {
// MDX will pass the language as className
// className also includes className(s) theme-ui injected
const [language] = outerClassName.replace(/language-/, ``).split(` `);
if (typeof children !== `string`) {
// MDX will pass in the code string as children
return null;
}
return (
<Highlight
{...defaultProps}
{...props}
code={children.trim()}
language={language}
theme={undefined}
>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<Themed.pre className={`${outerClassName} ${className}`} style={style}>
{tokens.map((line, index) => (
<div key={index} {...getLineProps({ line, key: index })}>
{line.map((token, key) => (
<span
key={key}
{...getTokenProps({ token, key })}
// https://github.com/system-ui/theme-ui/pull/721
sx={token.empty ? { display: `inline-block` } : undefined}
/>
))}
</div>
))}
</Themed.pre>
)}
</Highlight>
);
};
export default CodeBlock;

Adding line highlighting

The tutorial I mentioned earlier applies a CSS-class to the lines that should be highlighted.

In theme-ui, a variant can be used to style those lines.

Getting relevant data into the component

After the three backtick followed by the language, optional key/value pairs may be passed. Those keys will be passed into the CodeBlock component as props.

eg. for ```javascript puppies=cute

The CodeBlock component will have a prop called puppies with a value of "cute".

I used this to pass a range of line numbers into the component under the hl prop (short for highlight-lines).

From that point, I followed Prince’s guide again. Inside the component, I created a function that returns a boolean signalling if a line should be highlighted.

CodeBlock.js
const CodeBlock = ({ children, className: outerClassName, hl, ...props }) => {
const shouldHighlightLine = getShouldHighlightLine(hl);
return (
<Highlight
{...defaultProps}
{...props}
code={children.trim()}
language={language}
theme={undefined}
>
// ...
</Highlight>
);
};

The logic to create that function lives outside of the component.

import rangeParser from "parse-numeric-range";
const getShouldHighlightLine = (hl) => {
if (hl) {
const lineNumbers = rangeParser(hl);
return (index) => lineNumbers.includes(index + 1);
}
return () => false;
};

Theme-ui variant

I added some CSS to the theme-ui file to the styles.CodeBlock.highlightLine variant and applied it to the lines that should be highlighted.

CodeBlock.js
// ...
tokens.map((line, index) => (
<div
key={index}
{...getLineProps({ line, key: index })}
sx={
shouldHighlightLine(index)
? { variant: `styles.CodeBlock.highlightLine` }
: undefined
}
>
{line.map((token, key) => (
// ...
))}
</div>
));
// ...

A wrapping element

A small caveat is that the background for highlighted lines doesn’t extend all the way if the user has to (horizontally) scroll the codeblock.

This can be solved by wrapping the entire component in another element, and applying some CSS.

CodeBlock.js
// ...
<div sx={{ variant: `styles.CodeBlock` }}>
<Highlight
{...defaultProps}
{...props}
code={children.trim()}
language={language}
theme={undefined}
>
// ...
</Highlight>
</div>

At this point I also moved the nightOwlLight preset to the styles.CodeBlock variant inside the theme-ui file.

The corresponding variant in the theme-ui file:

theme.js
import nightOwlLight from "@theme-ui/prism/presets/night-owl-light.json";
// ...
CodeBlock: {
...nightOwlLight,
overflow: `auto`,
pre: {
backgroundColor: `transparent`,
float: `left`,
minWidth: `100%`,
margin: 0,
},
},
// ...

Adding title support

The steps to add a section above the codeblock that can hold a title are very similar to the line highlighting ones.

Getting relevant data into the component

Pass a prop called title into the CodeBlock component.

eg. ```javascript title=CodeBlock.js

will make the title prop have a value of "Codeblock.js"

Own element

Above the existing outer <div>, create another one and conditionally render the title.

Theme-ui variant

A theme-ui variant, styles.CodeBlock.title makes sure the element can be styled via the theme-ui file.

CodeBlock.js
const CodeBlock = ({
children,
className: outerClassName,
title,
hl,
title,
...props
}) => {
return (
<React.Fragment>
{title && <div sx={{ variant: `styles.CodeBlock.title` }}>{title}</div>}
<div sx={{ variant: `styles.CodeBlock` }}>{/* ... */}</div>
</React.Fragment>
);
};

Final code

CodeBlock.js
/** @jsx jsx */
import React from "react";
import Highlight, { defaultProps } from "prism-react-renderer";
import rangeParser from "parse-numeric-range";
import { jsx, Themed } from "theme-ui";
const getShouldHighlightLine = (hl) => {
if (hl) {
const lineNumbers = rangeParser(hl);
return (index) => lineNumbers.includes(index + 1);
}
return () => false;
};
const CodeBlock = ({
children,
className: outerClassName,
title,
hl,
...props
}) => {
// MDX will pass the language as className
// className also includes className(s) theme-ui injected
const [language] = outerClassName.replace(/language-/, ``).split(` `);
if (typeof children !== `string`) {
// MDX will pass in the code string as children
return null;
}
const shouldHighlightLine = getShouldHighlightLine(hl);
return (
<React.Fragment>
{title && <div sx={{ variant: `styles.CodeBlock.title` }}>{title}</div>}
<div sx={{ variant: `styles.CodeBlock` }}>
<Highlight
{...defaultProps}
{...props}
code={children.trim()}
language={language}
theme={undefined}
>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<Themed.pre
className={`${outerClassName} ${className}`}
style={style}
>
{tokens.map((line, index) => (
<div
key={index}
{...getLineProps({ line, key: index })}
sx={
shouldHighlightLine(index)
? { variant: `styles.CodeBlock.highlightLine` }
: undefined
}
>
{line.map((token, key) => (
<span
key={key}
{...getTokenProps({ token, key })}
// https://github.com/system-ui/theme-ui/pull/721
sx={token.empty ? { display: `inline-block` } : undefined}
/>
))}
</div>
))}
</Themed.pre>
)}
</Highlight>
</div>
</React.Fragment>
);
};
export default CodeBlock;
theme.js
import nightOwlLight from "@theme-ui/prism/presets/night-owl-light.json";
const theme = {
styles: {
CodeBlock: {
...nightOwlLight,
overflow: `auto`,
pre: {
backgroundColor: `transparent`,
float: `left`,
minWidth: `100%`,
margin: 0,
},
highlightLine: {
backgroundColor: `#f0f0f0`,
borderLeftColor: `#49d0c5`,
borderLeftStyle: `solid`,
borderLeftWidth: `0.25em`,
display: `block`,
marginRight: `-1em`,
marginLeft: `-1em`,
paddingRight: `1em`,
paddingLeft: `0.75em`,
},
title: {
fontFamily: `mono`,
backgroundColor: nightOwlLight.backgroundColor,
borderBottomWidth: `2px`,
borderBottomStyle: `solid`,
borderBottomColor: `#f0f0f0`,
color: nightOwlLight.color,
},
},
},
};
export default theme;