Rendering Markdown and code on the server
How this site turns Markdown from the CMS into safe, highlighted HTML before it reaches the browser, with unified, rehype-sanitize and Shiki.
Dahmane Abdelmalek
Oct 27, 20262 min readPart 3 of 3
Case studies, job descriptions and articles like this one are written in Markdown in the CMS. Turning Markdown into HTML is easy. Doing it safely, with highlighted code, and without making every visitor download a parser and a highlighter takes a little more thought.
Do the work once, on the server
The browser never sees Markdown. Server functions fetch the content from Strapi, render it to HTML and send the result. Fields that hold rendered HTML end in Html (achievementsHtml, descriptionHtml), so a component can tell at a glance that a string is ready to display.
The benefits add up: no Markdown parser or highlighter in the JavaScript bundle, nothing to compute on a slow phone, and a single place where content becomes HTML.
Do the expensive work once, where it's cheapest, and send the result.
The pipeline
Rendering is a unified pipeline: parse the Markdown, add GitHub-flavoured extras like tables, convert it to HTML, sanitize it, highlight the code, and turn it back into a string.
function createMarkdownProcessor(highlighter: Highlighter) {
return (
unified()
.use(remarkParse)
.use(remarkGfm)
// Raw HTML in the Markdown is dropped, not passed through.
.use(remarkRehype)
// Defence in depth: strips unsafe URLs (javascript:…) and attributes.
.use(rehypeSanitize)
// After sanitizing, which would otherwise strip Shiki's colours.
.use(rehypeShikiFromHighlighter, highlighter, {
...SHIKI_OPTIONS,
defaultLanguage: 'text',
fallbackLanguage: 'text',
})
.use(rehypeStringify)
)
}Safe by default
The content comes from a CMS I control, so why sanitize at all? Because "I control it" holds until it doesn't: a pasted snippet, a compromised account, a guest author one day. Two layers protect every page:
- Raw HTML inside the Markdown is dropped during conversion, never passed through.
rehype-sanitizethen removes anything unsafe that's left, such asjavascript:links.
Light and dark code with no re-render
Shiki can write both the light and the dark colour of every token into the HTML at once, as CSS variables. The stylesheet picks one from the current theme, so code changes colour the instant the theme does: no second request, no re-render, no flash.
// Light and dark colours both go into the HTML as CSS variables; app.css picks
// one from the site theme, so highlighted code switches theme with no re-render.
const SHIKI_OPTIONS = {
themes: { light: 'github-light', dark: 'github-dark' },
defaultColor: false,
} as const.shiki,
.shiki span {
color: var(--shiki-light);
}
.dark .shiki,
.dark .shiki span {
color: var(--shiki-dark);
}Create the expensive things once
Loading Shiki's grammars takes a while, so the highlighter and the processor are created once per server and shared by every request after that.
// Loading grammars is slow, so the highlighter and processor are created once per server.
let highlighter: Promise<Highlighter> | undefined
function getHighlighter() {
highlighter ??= createHighlighter({
themes: Object.values(SHIKI_OPTIONS.themes),
langs: LANGUAGES,
})
return highlighter
}That's the series for now. One last detail: this article went through the same pipeline, so the code above was highlighted by the code above.
Newsletter
Get new articles by email
One email per new article. Unsubscribe anytime.
Prefer a feed reader? RSS feed
Responses
Contact
Have a project in mind?
Let's build it.
Open to remote roles and freelance projects.