Syntax highlighting is done using either highlight.js
or prism.js
. Once you’ve picked one, you’ll have to set it up for code blocks: 1. within MDX and 2. outside MDX. That’s how it works on all kinds of frameworks, not just Remix, although I’ll post examples for Remix.
This setup is mostly a trivial task; however, there are a few niggles worth mentioning.
MDX syntax highlighting
At the moment of writing, Remix.js 1.5.1 is not natively in ES modules but vast majority of MDX ecosystem packages are. This introduces extra hurdles because we have to import pure ESM packages asynchronously.
Remix documentation explains pretty well how to deal with ESM and MDX section explains how to asynchronously import MDX plugins which are in ESM, just you’ll need to glue all pieces together.
In essence, if you picked highlight.js
, you’ll use Rehype plugin rehype-highlight
(instructions).
You’ll notice that out of the box, the Remix config is exporting all settings as a plain object:
/**
* @type {import('@remix-run/dev/config').AppConfig}
*/
module.exports = {
appDirectory: "app",
assetsBuildDirectory: "public/build",
publicPath: "/build/",
serverBuildDirectory: "build",
devServerPort: 8002,
ignoredRouteFiles: [".*"],
};
Just like Remix docs MDX section hinted with export.mdx =
, it’s probably the cleanest way to separate all exports from now:
exports.mdx = async () => {};
exports.appDirectory = ...;
exports.assetsBuildDirectory = ...;
exports.publicPath = ...;
exports.serverBuildDirectory = ...;
exports.devServerPort = ...;
exports.ignoredRouteFiles = ...;
The Remix documentation didn’t mention this, and I had to reference the CommonJS exports API documentation.
From here on, it’s just a matter of assembling it all up. Here’s our current remix.config.js
for an example (beware it’s a Vercel deployment, mind the serverBuildTarget
below):
const { remarkMdxFrontmatter } = require("remark-mdx-frontmatter");
const rehypeFigure = require("rehype-figure");
// can be an sync / async function or an object
exports.mdx = async (filename) => {
const [
rehypeHighlight,
rehypeAutolinkHeadings,
rehypeSlug,
remarkGfm,
visit,
] = await Promise.all([
import("rehype-highlight").then((mod) => mod.default),
import("rehype-autolink-headings").then((mod) => mod.default),
import("rehype-slug").then((mod) => mod.default),
import("remark-gfm").then((mod) => mod.default),
import("unist-util-visit").then((mod) => mod.visit),
]);
const mappings = {
bash: "terminal",
console: "terminal",
ini: "jinja",
arduino: "any",
};
function rehypeRelCodeBlockTitles() {
return (tree) => {
visit(tree, "element", (node, i, parent) => {
let retrieved;
if (
node.tagName === "pre" &&
Array.isArray(node.children) &&
node.children.length &&
typeof node.children[0] === "object" &&
node.children[0].tagName === "code" &&
Array.isArray(node.children[0]?.properties?.className) &&
node.children[0]?.properties?.className.some((className) => {
if (
!retrieved &&
className.startsWith("language-") &&
className.length > 9
) {
retrieved = className;
return true;
}
return false;
})
) {
const extractedName = retrieved.slice(9);
node.properties["data-syntax"] =
mappings[extractedName] || extractedName;
const replacement = {
type: "element",
tagName: "div",
properties: {
class: "syntax-container",
},
children: [node],
};
parent.children[i] = replacement;
}
});
};
}
return {
remarkPlugins: [remarkGfm, remarkMdxFrontmatter],
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
properties: {
className: ["anchor"],
},
test: ["h2"],
},
],
[
rehypeFigure,
{
className: "image-container",
},
],
rehypeHighlight,
rehypeRelCodeBlockTitles,
],
};
};
exports.cacheDirectory = "./node_modules/.cache/remix";
exports.ignoredRouteFiles = ["**/.*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"];
// When running locally in development mode, we use the built in remix
// server. This does not understand the vercel lambda module format,
// so we default back to the standard build output.
exports.server =
process.env.NODE_ENV === "development" ? undefined : "./server.js";
exports.serverBuildTarget = "vercel";
exports.serverDependenciesToBundle = [
/^rehype-/,
/^remark-/,
/^unified-/,
/^micromark-/,
/^unist-/,
/^mdast-/,
/^hast-/,
/^vfile-/,
/^estree-/,
"@codsen/data",
"tsd-extract",
"marked",
"string-strip-html",
"string-unfancy",
"extract-search-index",
"is-language-code",
];
To sum up, notice separate parts:
- non-ESM MDX plugins are
require
d normally, like back in the day, they go at the very top. - ESM MDX plugins are imported asynchronously and assembled into
Promise.all
. - custom plugin
rehypeRelCodeBlockTitles()
is to wrappre
+code
blocks with adiv
and to read and setdata-syntax
attribute which gets content-written into a CSS pseudo element:before
(CSS-Tricks style) — better thanrehype-code-titles
plugin. serverDependenciesToBundle
at the bottom is maintained manually; all pure ESM packages we use need to be listed there.
Non-MDX syntax highlighting
Our code examples (including Quick Take) come from @codsen/data
package (npm page, source, generation script) because open source monorepo is a separate repository from this website’s repository.
The task becomes to syntax-highlight any arbitrary string, considering the challenge of keeping both MDX and non-MDX highlighting identical.
It’s probably not worth to use an npm package like react-highlight
because your own component will be leaner and better-typed. It’s a small component anyway:
// app/components/highlight/highlight.tsx
import hljs from "highlight.js";
import { useLoaded } from "~/utils/misc";
interface HighlightProps {
language: "js" | "ts" | "shell";
children: React.ReactNode;
}
export const Highlight = ({ language, children }: HighlightProps) => {
const loaded = useLoaded();
return loaded ? (
<div className="syntax-container">
<pre data-syntax={language}>
<code
className={`hljs language-${language}`}
dangerouslySetInnerHTML={{
__html: hljs.highlight(String(children), {
language,
ignoreIllegals: true,
}).value,
}}
/>
</pre>
</div>
) : null;
};
The useLoaded()
React hook is an essential part:
export const useLoaded = () => {
let [loaded, setLoaded] = useState(false);
useEffect(() => {
setLoaded(true);
}, []);
return loaded;
};
You must ensure you dangerously set inner HTML after React’s render has finished (“been committed to the screen”); otherwise, code boxes will be empty.
TIP #1: It’s possible to trim down the highlight.js
to load only the languages you need. Unrecognised languages would trigger TS alerts, reminding us.
TIP #2: It’s a challenge to DRY both highlighting implementations, MDX and non-MDX. We have to ensure that:
- General HTML markup is the same (this wrapper
div
is custom, added on the fly in MDX viarehypeRelCodeBlockTitles()
you saw above):
<div class="syntax-container">
<pre data-syntax="ts">
<code class="hljs language-ts">
<span class="hljs-*">...</span>
</code>
</pre>
</div>
- Ensure the label overriding is identical — for example, we display the
terminal
label, but actually, the syntax is calledshell
. - Also, probably not that common in Remix projects, but theoretically, if CSS-in-JS was used, for example, Styled Components, the non-MDX component would be using JS-based CSS, yet the MDX components would still reference the global CSS stylesheet. You’d have to find ways to DRY both.
- Also, tackle the no-name code blocks. They might be processed separately if auto-detection is on.
The fun part
Once you have set up the syntax highlighting, the most fun part starts — customising your own colour scheme. But that’s for another article.