Improving HTML code blocks readability with a syntax highlighter
A code block written by hand is pretty simple: use the 'pre' and 'code' HTML elements and the browser gives you a monospace font and keeps your line breaks, but it stops there. So every keyword, string, comment and variable comes out in the exact same color, which is fine for a few lines of YAML, but for a few hundred lines of TypeScript painted in a single flat tone, maybe not. Improving readability for those cases doesn't require shipping a highlighting library to the reader's browser (unless you want to) and is quite simple to do.
Highlighting code blocks with ShikiJS
ShikiJS uses the same TextMate grammars and themes that VS Code uses, which means the code on your page ends up looking like the code in your editor. And because it can run anywhere, the highlighting may happen while the page is being built, so the reader downloads plain HTML with the colors already baked in, or may happen in the browser, your choice.
The rehype plugin is the piece that wires ShikiJS into a unified pipeline. So, if you write your posts in Markdown, the plugin sits between the Markdown to HTML conversion and the stringifier.
import fs from 'node:fs/promises'
import rehypeShiki from '@shikijs/rehype'
import rehypeStringify from 'rehype-stringify'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import { unified } from 'unified'
const file = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeShiki, {
themes: {
light: 'vitesse-light',
dark: 'vitesse-dark',
},
})
.use(rehypeStringify)
.process(await fs.readFile('./post.md'))
Now, if you write your posts directly in HTML, like I do, there's no Markdown
step at all. The plugin works on the syntax tree, not on Markdown, so you can
parse the HTML with rehype-parse and hand it the same
tree.
import fs from 'node:fs/promises'
import rehypeParse from 'rehype-parse'
import rehypeShiki from '@shikijs/rehype'
import rehypeStringify from 'rehype-stringify'
import { unified } from 'unified'
const file = await unified()
.use(rehypeParse, { fragment: true })
.use(rehypeShiki, { theme: 'vitesse-dark' })
.use(rehypeStringify)
.process(await fs.readFile('./post.html'))
Either way, what the plugin looks for is a code element
carrying a specific class, which is exactly what the
remark-rehype plugin produces and what you should be
writing when you're not using it.
<pre><code class="language-typescript">const answer = 42</code></pre>
Worth noting
-
Use
themefor a single theme andthemesfor a light and dark pair. The pair version emits CSS variables, so switching between them is a matter of toggling a couple of custom properties instead of highlighting everything twice; -
The default export shares one highlighter instance across processes, but
if you care about bundle size, import
rehypeShikiFromHighlighterfrom@shikijs/rehype/coreand pass your own highlighter loaded with only the languages you actually use; -
The
postprocesstransformer hook never runs here. The plugin operates on the syntax tree rather than on HTML strings, and that's on purpose, so if you rely on a transformer that rewrites the final HTML, you'll need a workaround to do that.
Highlighting inline code
Code blocks are the obvious win, but there's always inline code sitting in the middle of a sentence, and by default that stays gray while everything around it is colored. ShikiJS can handle those too, it just needs to be told which language each one is.
That's what the tailing curly colon syntax is for. It's off by default, so you enable it explicitly.
.use(rehypeShiki, {
inline: 'tailing-curly-colon',
theme: 'vitesse-dark',
})
From there, you append a marker to the end of the snippet with the language you want, right before the closing tag.
<!-- HTML -->
<p>Call <code>console.log("Hello World"){:javascript}</code> and move on.</p>
<!-- Markdown -->
This snippet `console.log("Hello World"){:javascript}` will be highlighted.
The marker itself is stripped from the output, so the reader never sees it. It's a bit of noise in the source, but it's the price to pay for highlighting code in the middle of a sentence.
Worth noting
- The marker has to be the very last thing inside the code element;
- The language has to be one that ShikiJS knows about and, if you went with a fine-grained bundle, it must be one you actually loaded;
- Snippets without a marker are left completely alone, which is convenient. File names, flags and environment variables usually read better plain than syntax highlighted as something they're not.
Wrap-up
Two options in a config object and a small marker convention, and your posts go from walls of gray monospace to something people can actually scan. No client-side library, no flash of unstyled code, no extra requests (unless you want them). Your readers won't notice it, and that's kind of the point, they'll just stop bouncing off your code blocks.