Adding Server-Side Syntax Highlighting to Ghost

I recently moved this site from a custom Astro-based site to use Ghost. I found that having to write my posts in a code editor really adds a barrier to start writing for me. Also, I prefer to keep my drafts private until I'm ready to share them, but it's hard to do that in a Git repo.

Ghost provides a lot of features out of the box, but it does lose out on customizability compared to writing an Astro site, especially for any server-side processing that you might want to do.

One thing that I really miss from my Astro site is the server side, JS-free syntax highlighting. In Ghost, the regular way to do this is to add Prism to your site. But this will increase the amount of JS that needs to be downloaded. Also, if JavaScript is disabled or fails to run, your code will not be highlighted.

<style>
  .test {
    color: red;
  }
</style>

<p class="test">
  Writing some code &amp; text.
    <span style="color: #f00;">C</span
   ><span style="color: #ff0;">o</span
   ><span style="color: #0f0;">o</span
   ><span style="color: #0ff;">l</span
   ><span style="color: #00f;">!</span>
</p>

<script>
  alert('test');
</script>

But if you did disable JS to check this, you'll find that on my site, the code above is still highlighted!

Since Ghost is open-source, I knew it should be possible to add syntax highlighting. All I need to do is to post-process the HTML that they generate. After digging through their code, I found that they render the HTML after saving in the editor. This is actually perfect for us, since we only need highlight the code once per post, we can use tools that are focused on quality instead of speed.

I decided to use rehype, which is a tool for transforming HTML. Other than syntax highlighting, I can also add other plugins to rehype, which might be useful in the future.

For the syntax highlighting itself, I tried a few tools, but finally settled on Shiki. Unlike other highlighting tools, Shiki adds the colors using inline styles instead of relying on a separate CSS theme. This makes it more convenient to use, though that does mean I need to update the code to change the theme.

Compared to Prism and Highlight.js, Shiki can highlight the code more accurately. The tradeoff is its bundle size. While Prism and Highlight.js are competing in kilobytes, Shiki is measured in megabytes. Since we're running it on the server, it's not a big problem.

With the code above, Prism does not highlight the inside of the <script> and <style> tags.
Highlight.js is able to highlight the <script> and <style> tags, but it had some issues with the strange (but valid) HTML tags.

The code to process the HTML is very simple:

import { rehype } from 'rehype'
import rehypeShiki from '@shikijs/rehype'

const pipeline = rehype()
    .data('settings', { fragment: true })
    .use(rehypeShiki, {
        themes: {
            light: 'github-light',
            dark: 'dark-plus',
        }
    })

export async function postprocess(html) {
    const file = await pipeline.process(html)

    return String(file)
}

I added this in the lexical and mobiledoc renderers. I needed to add some simple glue code to run the ES modules code in Ghost's CommonJS code base.

To deploy this, I ran the archive script to generate a .tgz file that can be installed using the Ghost CLI. Strangely, the official .tgz files include the yarn.lock file, while mine doesn't; I suspect it's caused by different npm versions or other tools. So I also added a simple command to include the lockfile.

You can check out all my changes here. I also have another change to increase the webhook timeout to let it call a server in a different continent, but that's a story for another time.

Since I'm running Ghost in Docker, I also need to create a Docker image that uses my custom zip to install. I did this by modifying the original Dockerfile to use my .tgz file.

 ...
 
 ENV GHOST_INSTALL /var/lib/ghost
 ENV GHOST_CONTENT /var/lib/ghost/content
+ENV GHOST_ARCHIVE /var/lib/ghost.tgz
 
 ENV GHOST_VERSION 5.84.0
 
+COPY ghost.tgz "$GHOST_ARCHIVE"

 ...

-installCmd='gosu node ghost install "$GHOST_VERSION" ...
+installCmd='gosu node ghost install --archive "$GHOST_ARCHIVE" ...

 ...

Once this is deployed, we need to deal with the old posts. Since the HTML is only generated when we save a post, older posts won't have the syntax highlighting. Luckily, the Ghost developers provided an API to forcibly re-render posts and a script to run it for all posts in your site. So we can just run this script.

This is actually a lot of effort for something that is built-in to Astro! In the future, I hope Ghost adds an easier way to do server-side transformation of HTML directly. For now, the other solution for more advanced users is to only use Ghost as a headless CMS and implement the entire front end using another framework.

But that is a lot of effort if you only want to do simple things. If your requirements are not so extensive, modifying the Ghost code seems to be a decent solution in between "vanilla Ghost" and "JAMstack".