Torchlight + Statamic Bard: syntax highlighting without custom sets

Statamic
.

If you’ve ever tried to set up Torchlight syntax highlighting in Statamic’s Bard field, you’ve probably hit a roadblock.

The official Torchlight docs show how to extend Statamic’s Markdown parser, which works beautifully for Markdown fields. But if you’re using Bard, you’ll notice—it won’t work out of the box.

I wanted a solution that:

  • Works directly inside Bard.

  • Doesn’t require creating a custom Bard set.

  • Supports a copy button and language tag.

After some digging, I found a solid approach using Bard Mutator by Jack Sleight. This addon is fantastic if you want to add niceties to Bard (I also use it on this blog for linkable headings). With it, we can hook into Bard’s output and inject Torchlight highlighting.

Let’s walk through the setup step by step.

1. Extend Statamic’s markdown parser

The first step is to follow the Torchlight docs and extend the Markdown parser.

// AppServiceProvider.php
 
use Torchlight\Commonmark\V2\TorchlightExtension;
use Statamic\Facades\Markdown;
 
public function boot()
{
Markdown::addExtension(function () {
return new TorchlightExtension();
});
}
php

Now, any Markdown code blocks will be passed through Torchlight for highlighting.

2. Enable Torchlight inside Bard with Bard Mutator

Bard stores code blocks differently than Markdown. So we need to capture them and re-process them through Torchlight.

// AppServiceProvider.php
 
use Statamic\Facades\Markdown;
use JackSleight\StatamicBardMutator\Facades\Mutator;
 
Mutator::html('codeBlock', function ($value, $item) {
// Bard stores the language on the node; default to 'text'
$lang = $item->attrs->language ?? 'text';
 
// Pull the raw code string out of the node
$code = collect($item->content ?? [])->implode('text', '');
 
// Build a code block so Markdown + Torchlight can process it
$md = "```{$lang}\n{$code}\n```";
 
// Render Markdown → HTML using Statamic’s Markdown (with Torchlight active)
$html = Markdown::parse($md);
 
// Replace the codeBlock node’s HTML with highlighted HTML
return ['content' => $html];
});
php

Mutator::html() comes from Bard Mutator addon. It lets you intercept Bard nodes and return the HTML you want. Here we grab the node’s language, collect the raw text content, wrap it in a code block, and render via Markdown::parse()—which now runs through Torchlight thanks to step 1.

3. Add copy button and language tag

Highlighting alone is great, but let’s make it even better with:

  • A copy button.

  • A language tag overlay.

Update your Torchlight config

First, enable Torchlight’s built-in copyable option:

// config/torchlight.php
 
return [
'options' => [
'copyable' => true, // Adds a hidden unformatted block for easy copying
],
];
php

This enables a hidden .torchlight-copy-target that contains the original source. Credit to Austen Cameron for this approach.

Enhanced codeBlock Mutator

Now update your mutator to insert a copy button and language tag:

// AppServiceProvider.php
 
use JackSleight\BardMutator\Facades\Mutator;
use Statamic\Facades\Markdown;
 
Mutator::html('codeBlock', function ($value, $item) {
// Bard stores the language on the node; default to 'text'
$lang = $item->attrs->language ?? 'text';
 
// Pull the raw code string out of the node
$code = collect($item->content ?? [])->implode('text', '');
 
// Build a code block so the Markdown + Torchlight extension can process it
$md = "```{$lang}\n{$code}\n```";
 
// Render Markdown → HTML using Statamic’s Markdown facade (Torchlight extension is active)
$html = Markdown::parse($md);
 
// Render the view for the copy button and trim any trailing whitespace
// to prevent introducing an extra preserved newline inside the <pre>.
$copyButton = rtrim((string) view('components/copy-code-button'));
 
$languageTag = '<span class="language-tag">' . e($lang) . '</span>';
 
// Inject the button and language tag inside the first <pre> tag
$content = preg_replace('/(<\/pre>)/i', $languageTag . $copyButton . '$1', $html, 1);
 
// Replace the codeBlock node’s HTML with our fully highlighted HTML, copy button and language tag
return ['content' => $content];
});
php

This injects both a language tag and a copy button into the first <pre>.

Language note for Bard

Statamic stores the code block language on the node. Pasting code often auto-detects it. If not, check your entry's .md file and set it manually:

-
type: codeBlock
attrs:
language: yaml
yaml

Copy button partial

{{# resources/views/components/copy-code-button.antlers.html #}}
 
<button x-data="copyCode" type="button"
class="hidden md:flex absolute top-6 right-6 text-xs items-center gap-x-1"
:class="copied ? 'text-green-400 hover:text-green-400' : 'text-gray-300 hover:text-gray-100'"
@click="copyToClipboard()">
 
<span x-show="!copied" class="flex items-center gap-x-1">
{{ svg src="document-duplicate" class="size-4" }}
<span>Copy</span>
</span>
 
<span x-cloak x-show="copied" class="flex items-center gap-x-1">
{{ svg src="document-check" class="size-4" }}
<span>Copied!</span>
</span>
</button>
antlers

This renders a lightweight UI for copying code with Alpine.

Alpine component

// resources/js/copyCode.js
 
document.addEventListener('alpine:init', () => {
Alpine.data('copyCode', () => ({
copied: false,
messageDuration: 2000,
copyToClipboard() {
navigator.clipboard.writeText(
this.$el.closest('pre').querySelector('.torchlight-copy-target').textContent
)
this.copied = true
setTimeout(() => (this.copied = false), this.messageDuration)
},
}))
})
js

This reads from Torchlight’s .torchlight-copy-target for clean, unformatted copies.

Load the Alpine component only where needed

{{# resources/views/posts/show.antlers.html #}}
 
{{ push:scripts }}
{{ once }}
{{ vite src="resources/js/copyCode.js" }}
{{ /once }}
{{ /push:scripts }}
antlers

In your layout, render the stack near </body>:

{{# resources/views/layout.antlers.html #}}
 
<body>
{{# ... #}}
 
{{# Push scripts when various components are added #}}
{{ stack:scripts }}
{{ vite src="resources/js/site.js" }}
</body>
antlers

Also add resources/js/copyCode.js to your Vite input array so it’s discoverable at build time.

This is my Alpine component loading technique so I can load components only where they’re used, instead of bundling them sitewide in site.js.

4. Minimal CSS

Here’s the Tailwind CSS I used for styling code blocks:

/* Positions the button and language tag correctly. */
pre {
@apply relative;
}
 
/* Torchlight code blocks */
pre code.torchlight {
@apply block min-w-max pb-6 pt-16 relative;
}
 
pre code.torchlight .line {
@apply px-6;
}
 
pre code.torchlight .line-number,
pre code.torchlight .summary-caret {
@apply mr-4;
}
 
pre .language-tag {
@apply absolute left-6 top-6 select-none font-mono text-xs uppercase tracking-wide text-gray-300;
}
css

Debug checklist

  • No highlighting? Ensure the Torchlight extension is registered in AppServiceProvider before Mutator runs, and your Torchlight API key is set.

  • Wrong language? Verify attrs.language on the Bard node inside your entry.md.

  • Copy button does nothing? Confirm torchlight.options.copyable = true and that .torchlight-copy-target exists in the rendered HTML.

  • Extra newline inside <pre>? Keep rtrim() around your injected partial to avoid preserved whitespace.

  • Bundling issues? Make sure copyCode.js is in Vite’s input and that you push the script stack on pages with code.

  • Copy button position looks off? Make sure your pre tags have position: relative.

Why this approach?

It reuses Statamic’s existing Markdown + Torchlight pipeline instead of inventing a custom Bard set or renderer. Bard Mutator turns codeBlock nodes into Markdown and Torchlight does the rest with zero authoring friction.

If you’ve found a different or cleaner way to hook into Bard for Torchlight highlighting, let me know on X.

Jason Baciulis's avatar

Jason Baciulis

I’m a Laravel/Statamic dev and certified Statamic partner. I share practical tips from client work and Bedrock starter-kit. If this helped, say hi on X. Need a hand with Laravel or Statamic? Hire me.