Ghost Doesn't Do Code. Fix That.
Ghost is a beautiful publishing platform. But paste a code block into a post and you get monospace text on a grey background — no colors, no language awareness, no way to copy. For a tech blog, that's a problem. Your readers will judge your code examples, and "grey blob of text" doesn't inspire confidence.
The fix: Prism.js for syntax highlighting, a custom copy-to-clipboard button, and Mermaid.js for diagrams. All self-hosted — no CDN dependencies, no third-party requests. Everything served from your own box.
Here's how to set it up in Ghost's Code Injection, without touching your theme files.
What You Get
graph LR
Ghost[Ghost Post] -->|markdown| CodeBlock[Code Block]
CodeBlock --> Prism[Prism.js]
CodeBlock --> CopyBtn[Copy Button]
CodeBlock --> Mermaid[Mermaid.js]
Prism -->|colored syntax| Reader((Reader))
CopyBtn -->|clipboard| Reader
Mermaid -->|SVG diagram| Reader
| Feature | What it does |
|---|---|
| Prism.js | Language-aware syntax highlighting |
| Copy button | One-click clipboard copy, hover to reveal |
| Mermaid.js | Renders diagrams from code blocks |
| Self-hosted | Zero external dependencies |
Prerequisites
- A running Ghost blog (see The 10-Minute Website Setup)
- Shell access to your server
- A Docker volume mount for custom assets (we'll set that up)
Step 1: Mount a Custom Assets Directory
Ghost themes live inside the Docker container. Modifying them directly means losing changes on every update. Instead, mount a host directory into the theme's asset path.
mkdir -p /srv/ghost-blog/custom-assets
Add the volume to your Ghost service in docker-compose.yml:
volumes:
- ./custom-assets:/var/lib/ghost/content/themes/YOUR_THEME/assets/custom
Replace YOUR_THEME with your theme name — casper, journal, or whatever you're running. Restart:
docker compose up -d
Verify it works:
echo "test" > /srv/ghost-blog/custom-assets/test.txt
curl -s https://yourblog.example.com/assets/custom/test.txt
If you see test, the mount is live. Remove the test file.
Step 2: Download Prism.js
Self-hosted means downloading the files. Prism is modular — a core plus language components:
cd /srv/ghost-blog/custom-assets
# Core
wget -4 https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css
wget -4 https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js
# Languages — add what you need
wget -4 https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js
wget -4 https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js
wget -4 https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js
wget -4 https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-nginx.min.js
wget -4 https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-docker.min.js
The -4 flag forces IPv4 — some CDNs have flaky IPv6 SSL on certain servers.
Need more languages? Browse the Prism CDN directory — each language is ~0.3-0.5KB.
Step 3: Download Mermaid.js
wget -4 -O mermaid.min.js https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js
Step 4: Create the Copy Button
This is a small vanilla JS script — no dependencies, no Prism plugin overhead. The button appears on hover, copies the code block contents, and shows a checkmark for feedback.
Create copy-code.js:
/* copy-code.js — copy button for <pre><code> blocks
Zero dependencies. Hover to reveal, click to copy. */
(function () {
// SVG: two overlapping rectangles (classic copy icon)
const ICON_COPY = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
const ICON_CHECK = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
// Inject styles
const css = document.createElement('style');
css.textContent = `
pre { position: relative; }
pre .copy-btn {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 6px;
border: none;
border-radius: 4px;
background: rgba(255,255,255,0.1);
color: rgba(80,80,80,0.6);
cursor: pointer;
opacity: 0 !important;
transition: opacity .2s, background .2s, color .2s;
line-height: 0;
}
pre:hover .copy-btn { opacity: 1 !important; }
pre .copy-btn:hover { background: rgba(255,255,255,0.2); color: #555; }
pre .copy-btn.copied { color: #4ade80; }
`;
document.head.appendChild(css);
// Add buttons
document.querySelectorAll('pre > code, pre').forEach(function (el) {
var pre = el.tagName === 'PRE' ? el : el.parentNode;
if (pre._hasCopyBtn) return;
pre._hasCopyBtn = true;
var btn = document.createElement('button');
btn.className = 'copy-btn';
btn.innerHTML = ICON_COPY;
btn.title = 'Copy';
btn.addEventListener('click', function () {
var code = pre.querySelector('code') || pre;
navigator.clipboard.writeText(code.innerText).then(function () {
btn.innerHTML = ICON_CHECK;
btn.classList.add('copied');
setTimeout(function () {
btn.innerHTML = ICON_COPY;
btn.classList.remove('copied');
}, 2000);
});
});
pre.appendChild(btn);
});
})();
Save it to your custom-assets directory.
Why JS hover events? Safari has issues with pre:hover .copy-btn when Prism restructures the DOM inside code blocks. The CSS rule still works in Chrome and Firefox, but the JS mouseenter/mouseleave ensures cross-browser compatibility.
Why !important? Prism's CSS selectors (pre[class*="language-"], code[class*="language-"]) are more specific than pre .copy-btn and will override your opacity rules without it. Welcome to specificity wars.
Step 5: Wire It Up in Ghost
Ghost Admin → Settings → Code Injection.
Site Header:
<link rel="stylesheet" href="/assets/custom/prism.min.css">
<style>
/* Overrides for Ghost "Journal" theme — adjust colors to match your theme */
pre[class*="language-"],
code[class*="language-"] {
background: #f6f6f6 !important;
border-radius: 8px;
font-size: 0.9em !important;
}
</style>
The style overrides fix two things: Prism forces its own background color (#f4f2f0) and strips border-radius from code blocks. The #f6f6f6 matches the Journal theme — adjust this to your theme's code block background. The font-size is optional but Prism's default feels slightly too large in most Ghost themes.
Site Footer:
<script src="/assets/custom/prism.min.js"></script>
<script src="/assets/custom/prism-bash.min.js"></script>
<script src="/assets/custom/prism-yaml.min.js"></script>
<script src="/assets/custom/prism-json.min.js"></script>
<script src="/assets/custom/prism-nginx.min.js"></script>
<script src="/assets/custom/prism-docker.min.js"></script>
<script src="/assets/custom/mermaid.min.js" defer></script>
<script src="/assets/custom/copy-code.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('pre code.language-mermaid').forEach(function(code) {
var pre = code.parentElement;
var div = document.createElement('div');
div.className = 'mermaid';
div.textContent = code.textContent;
pre.replaceWith(div);
});
mermaid.initialize({startOnLoad: true, theme: 'dark'});
});
</script>
The Mermaid script converts fenced code blocks tagged as language-mermaid into rendered SVG diagrams. Prism auto-highlights on load — no initialization call needed.
Writing Posts with Syntax Highlighting
Ghost's markdown editor creates the right HTML when you use fenced code blocks with language tags:
```bash
docker compose up -d
```
```yaml
services:
myapp:
image: nginx:alpine
```
```mermaid
graph LR
A --> B --> C
```
Prism requires these language-xxx classes — without a language tag, you get no highlighting. Ghost adds them automatically from the fenced code block syntax.
The File Layout
After setup, your custom-assets directory looks like this:
/srv/ghost-blog/custom-assets/
├── copy-code.js
├── mermaid.min.js
├── prism.min.css
├── prism.min.js
├── prism-bash.min.js
├── prism-docker.min.js
├── prism-json.min.js
├── prism-nginx.min.js
└── prism-yaml.min.js
Everything self-hosted. No external requests. No CDN outages. No third-party tracking. For a security-focused blog, that matters.
Licenses
Everything used here is MIT or ISC licensed — free for personal and commercial use, no attribution required in production.
| Component | License |
|---|---|
| Prism.js | MIT |
| Mermaid.js | MIT |
| Lucide Icons (copy button SVG) | ISC |
| copy-code.js | Do whatever you want |
Resources
- Prism.js — Lightweight syntax highlighter
- Mermaid.js — Diagrams from text
- Ghost Code Injection — Adding scripts without editing themes
- Lucide Icons — The SVG icon set used for the copy button
Previously: Webserver Hardening with CrowdSec — Real-time attack detection and community-powered blocking.