Add syntax highlighting, line numbers, and copy buttons to Ghost code blocks

Syntax highlighting, line numbers, and copy buttons for Ghost code blocks through Code Injection. No theme edits, no language files to manage.

Share

My dark mode post covered getting Ghost's Source theme to switch palettes. This post covers the other thing a technical blog wants: code blocks that look like code. Syntax highlighting, line numbers, and a copy button, all through Code Injection, no theme edits. The recipe is verified on Source as of this writing and pairs with the dark mode toggle from that post.

The tool is Prism.js, the same library Ghost's own code snippets tutorial uses. What that tutorial doesn't cover is the parts that broke for me, which is what this post spends its time on: line numbers need a nudge because Ghost doesn't emit the markup Prism expects, the default copy button is invisible to mobile users, and a handful of Prism defaults clash with dark mode. Each fix is small, but finding them cost me an evening.

Step 1: Add the styles (Site Header)

  • Go to Settings > Advanced > Code Injection.
  • Paste this into the Site Header block:
<!-- Loads the core Prism theme plus toolbar and line-number plugin styles. -->

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/toolbar/prism-toolbar.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/line-numbers/prism-line-numbers.min.css">

<!-- Custom code-block surface, copy button, and line-number gutter,
     replacing the default theme/Prism appearance with an explicit palette. -->

<style>
  /* Code block surface */
  pre[class*="language-"] {
    background: #f4f5f6;
    border: 1px solid #e0e0e0;
    border-radius: 6px;
    padding: 1rem 1rem 1rem 3rem; /* left padding doubles as gutter room */
    margin: 1.5rem 0;
  }

  pre[class*="language-"] code {
    background: transparent;
    text-shadow: none;
    color: #15171a;
  }

  /* Copy button: always visible (mobile taps never trigger hover) */
  div.code-toolbar > .toolbar {
    opacity: 1;
    top: 2rem;
    right: 1rem;
  }

  div.code-toolbar > .toolbar button {
    background: #f4f5f6;
    color: #747880;
    border: 1px solid #e0e0e0;
    border-radius: 4px;
    font-family: inherit;
    font-size: 11px;
    padding: 3px 10px;
    cursor: pointer;
    transition: all 0.2s ease;
  }

  div.code-toolbar > .toolbar button:hover {
    background: #ffffff;
    color: #15171a;
  }

  /* Line-number gutter */
  .line-numbers .line-numbers-rows {
    border-right: 1px solid #e0e0e0;
  }

  .line-numbers-rows > span:before {
    color: #a8aeb4;
  }

  /* Dark mode: code block surfaces */
  html.dark-mode pre[class*="language-"] {
    background: #202327;
    border-color: #373a40;
  }

  html.dark-mode div.code-toolbar > .toolbar button {
    background: #202327;
    color: #a8aeb4;
    border-color: #373a40;
  }

  html.dark-mode div.code-toolbar > .toolbar button:hover {
    background: #373a40;
    color: #ffffff;
  }

  html.dark-mode .line-numbers .line-numbers-rows {
    border-right: 1px solid #373a40;
  }

  html.dark-mode .line-numbers-rows > span:before {
    color: #747880;
  }

  html.dark-mode :not(pre) > code {
    background: #202327;
    border-color: #373a40;
    color: #e8eaed;
  }

  /* Dark mode base code text: covers un-highlighted text and
     embedded-language wrappers that carry no color of their own */
  html.dark-mode pre[class*="language-"] code {
    background: transparent;
    text-shadow: none;
    color: #e8eaed;
  }

  /* Dark mode: text selection in code blocks */
  html.dark-mode pre[class*="language-"] code ::selection,
  html.dark-mode pre[class*="language-"] ::selection {
    background: rgba(157, 140, 255, 0.35);
    text-shadow: none;
  }

  /* Dark mode: syntax token palette */
  html.dark-mode .token.comment,
  html.dark-mode .token.prolog,
  html.dark-mode .token.doctype { color: #747880; }
  html.dark-mode .token.punctuation { color: #a8aeb4; }
  html.dark-mode .token.tag,
  html.dark-mode .token.keyword { color: #9d8cff; }
  html.dark-mode .token.attr-name { color: #e8eaed; }
  html.dark-mode .token.string,
  html.dark-mode .token.attr-value { color: #a8d8b9; }
  html.dark-mode .token.selector,
  html.dark-mode .token.property { color: #d8b4a0; }

  html.dark-mode .token.operator,
  html.dark-mode .token.boolean,
  html.dark-mode .token.entity,
  html.dark-mode .token.url {
    background: transparent;
    color: #a8aeb4;
  }
</style>

Load order matters here. The copy button depends on the toolbar plugin, so toolbar comes first. The line-numbers plugin activates on <pre class="line-numbers">, but Ghost's code cards only emit language classes, so the last script tags every block before Prism's highlighting kicks in:

  1. Scroll down to the Site Footer block in Code Injection.
  2. Paste the following:
<!-- Loads Prism core plus autoloader (language grammars), toolbar, line-numbers, and copy-to-clipboard plugins,
     then tags every code block with .line-numbers (Ghost only emits language-* classes). -->

<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/toolbar/prism-toolbar.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/line-numbers/prism-line-numbers.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/copy-to-clipboard/prism-copy-to-clipboard.min.js"></script>

<script>
  // The line-numbers plugin activates on <pre class="line-numbers">;
  // Ghost's code cards only emit language-* classes, so tag every block.
  document.querySelectorAll('pre').forEach(pre => pre.classList.add('line-numbers'));
</script>
  1. Click Save.

What each script buys you: the core bundle highlights markup, CSS, and JavaScript out of the box. The autoloader fetches grammars for anything else (bash, YAML, Dockerfiles) on demand, so you never manage language files. The toolbar is the positioning scaffolding the copy button sits in. The copy button copies the block's contents with whitespace trimmed, so readers get clean pasteable text.

Tag your code blocks

None of this does anything without a language. Prism doesn't guess. In the Ghost editor, every code block has a language picker: set it to HTML, CSS, Bash, or whatever the block contains. Blocks left untagged render as plain text with no copy button, because the toolbar only attaches to highlighted blocks.

For code that mixes languages, the outer block wins. An html block containing both a <style> and a <script> highlights the markup plus the CSS and JavaScript inside it. That's most of what a Ghost code injection post contains, which is why html covers nearly everything on this site.

Why so much CSS for dark mode

The Prism theme stylesheet assumes light mode everywhere, and four of its defaults break against a dark palette. Each fix in Step 1 addresses a real failure I hit, in the order I hit it:

Base text stays black. Prism wraps embedded languages (CSS inside <style>, JavaScript inside <script>) in token spans that carry no color of their own, and any untokenized text inherits the block's base color, which the default theme sets to black. In dark mode, black on #202327 is unreadable. The fix is the explicit base color rules on pre code in both modes: #15171a light, #e8eaed dark. Anything without a token rule now defaults to your text color instead of Prism's.

Operators get a white chip. The default theme paints operators, booleans, entities, and URLs with a translucent white background, hsla(0, 0%, 100%, 0.5), meant for light surfaces. In a bash block, every =, &&, and &> grows a milky box. The background: transparent rule on those token classes fixes the whole family at once.

Selected text becomes unreadable. The theme ships a ::selection rule with a light blue background, designed for dark token colors. Select a range of code in dark mode and the light tokens vanish into it. The override replaces it with a translucent purple wash, which lets token colors show through the selection instead of flattening them.

The copy button hides on mobile. By default the toolbar fades in on hover. Touch devices don't hover. The opacity: 1 rule keeps the button permanently visible, which is the honest choice for anything with a mobile layout.

The token palette itself, comments in grey, keywords in purple, strings in green, matches the greys and accents from the dark mode post, so code blocks read as part of the theme rather than a foreign object bolted on.

Known limitations

  • The copy button text flashes default styling after copying. Prism swaps the button text to "Copied!" briefly and doesn't apply your button styles to that state. It's cosmetic and brief.
  • The line-numbers script is easy to lose. It's a bare one-liner at the end of the footer injection, not inside a tag or style block. When editing the footer, count your Prism scripts. If line numbers vanish from every block at once, that script is what went missing. I know because I did this.
  • Everything depends on cdnjs. If the CDN is blocked or slow, code blocks degrade to plain text. Nothing breaks structurally, but check whether 1.29.0 is still the current version on cdnjs.com/libraries/prism when you revisit this.
  • Inline code isn't touched. This only styles block code. If your theme's inline code styling clashes with dark mode, that's a separate fix, and it's in the dark mode post's territory, not Prism's.

Design note: the CSS uses fixed hex values throughout rather than Source's variables, same reasoning as the dark mode post. Prism's class names (token, code-toolbar, line-numbers) are its public API and quite stable, but the surface colors are mine, chosen to match the theme. The tradeoff is the code blocks won't track future Source accent or palette settings.

If you came from the dark mode post: everything here is additive. No changes to the palette or toggle scripts. The only interaction is intentional: the html.dark-mode rules expect the toggle's class, and if you skipped that post, delete those rules and you get light-mode styling only.