Highlight your website's code blocks with VSCode

October 05, 20204 min read

When it comes to highlighting code blocks on your website, it may seem there aren't many options available. I'd bet a 90% of websites use either Prism or HighlightJS. While these options are good, they often miss the mark for more niche use-cases, like supporting new PHP syntax or correctly highlighting Laravel Blade views, to the point where some users opt to highlight the code manually.

When I decided to improve the code blocks on this website, my first idea was to create a custom Prism theme, taking inspiration from the VSCode Community Material theme. After almost 100 lines of hot-fixing Prism however, I gave up and started looking for alternative solutions. I didn't have much hope, but ended up finding the holy grail of syntax highlighting: a way of using the same highlighter that VSCode uses, on your website, with support for any VSCode theme and extension.

Before I get started on how you can add this to your site too, there's a catch. The package used to achieve this needs to be run on the server, which means you need some sort of build process (althrough there's an RFC open for browser support). I'll be showing how to make this work with a simple node script, but I've had success getting it to work with Next.js and Gatsby, and I'm sure there's a way to get it working with other frameworks too. With that out of the way, let's get into it!

First, you'll need to create a node script that takes your markdown and turns it into HTML (which you can later echo from your website). For this example, we'll assume there's a posts directory full of markdown files and a build directory where our website expects html files to be.

js
const fs = require('fs')
const path = require('path')
const shiki = require('shiki')
const markdown = require('markdown-it')

shiki.getHighlighter().then(highlighter => {
    const md = markdown({
        html: true,
        highlight: (code, lang) => highlighter.codeToHtml(code, lang)
    })

    fs.readdirSync(path.join(process.cwd(), 'posts')).filter(slug => /\.mdx?$/.test(slug)).forEach(slug => {
        const content = fs.readFileSync(path.join(process.cwd(), 'posts', slug), 'utf-8')

        fs.writeFileSync(path.join(process.cwd(), 'build', slug.replace(/\.mdx?$/, '.html')), md.render(content))

        console.log(`${slug} => ${slug.replace(/\.mdx?$/, '.html')}`)
    })
})

Now you just need to run that script during your build process and you'll get some awesome VSCode-powered code blocks!

Shiki comes with support for plenty of languages by default, but you can also add custom languages from any VSCode extension. You just need to find a .tmLanguage or .tmLanguage.json on the extension source, and copy it to your project. Then, you pass an array of additional languages to shiki when creating the highlighter.

js
const langs = [
    {
        id: 'blade',
        scopeName: 'text.html.php.blade',
        path: path.join(process.cwd(), 'vendor/blade.tmLanguage.json'),
        embeddedLanguages: {
            'source.php': 'php',
            'source.css': 'css',
            'source.js': 'javascript',
        },
    },
    {
        language: 'dotenv',
        scopeName: 'source.env',
        path: path.join(process.cwd(), 'vendor/env.tmLanguage'),
        aliases: ['env'],
    },
]

shiki.getHighlighter({ langs }).then(highlighter => {
    //
})

Finally, what about custom themes? Again, shiki includes many themes by default, but you can bring your own by finding the JSON file for that theme in its source.

js
const theme = shiki.loadTheme(path.join(process.cwd(), 'vendor/material-theme.json')),

shiki.getHighlighter({ theme }).then(highlighter => {
    //
})

Those are the basics, which you can then apply to your specific setup. Here's how I'm doing the highlighting with Next.js and next-mdx-remote.

js
import matter from 'gray-matter'
import visit from 'unist-util-visit'
import renderToString from 'next-mdx-remote/render-to-string'

export const getStaticProps = async ({ params: { slug } }) => {
    const source = fs.readFileSync(path.join(process.cwd(), 'content/posts', `${slug}.mdx`))
    const { content, data } = matter(source)

    const shiki = await import('shiki')
    const highlighter = await shiki.getHighlighter({
        theme: shiki.loadTheme(path.join(process.cwd(), 'vendor/material-theme.json')),
        langs: [{
            id: 'blade',
            scopeName: 'text.html.php.blade',
            path: path.join(process.cwd(), 'vendor/blade.tmLanguage.json'),
            embeddedLanguages: { 'source.php': 'php', 'source.css': 'css', 'source.js': 'javascript' },
        },],
    })

    const mdxSource = await renderToString(content, {
        mdxOptions: { remarkPlugins: [[remarkPlugin, { highlighter }]] },
        scope: data,
    })

    return { props: { slug, mdxSource, ...data } }
}

export const remarkPlugin = options => async tree => {
    visit(tree, 'code', node => {
        node.type = 'html'
        node.children = undefined
        node.value = options.highlighter.codeToHtml(node.value, node.lang).replace('<pre class="shiki"', `<pre class="shiki" language="${node.lang}" meta="${node.meta}"`)
    })
}

Did you add shiki to your blog? Are you using something different to highlight your code? Reach out on Twitter and let me know!