Enhancing Torchlight Syntax Highlighting in Statamic with a Copy to Clipboard Feature

Published January 7th, 2023
13 minute read

Torchlight is a syntax highlighting service created by the legendary Aaron Francis (Thank you Aaron, it's awesome!). It generates beautiful code blocks and I love how they look. However, I wanted to take it a step further and add a "Copy to Clipboard" button to make sharing code snippets even easier. Here's how we can do it!

There's a problem

Most of the code blocks are generated from markdown content via Statamic's markdown parser, so it's not as simple as using <x-torchlight-code>. Instead, we have to extend Torchlight's base markdown parser to add the button.

The Markdown Parser Extension

To do this, we'll create a file in app/Extensions called TorchlightWithCopyButtonExtension.php and add an extension of the defaultBlockRenderer function. This function returns an HTML string that includes the Copy to Clipboard button and all the necessary HTML, AlpineJS and Tailwind CSS code to make it functional.

The key part is that we're wrapping the default Torchlight block renderer $torchlight($block) in an Alpine component (x-data="codeBlock"). Also included is a button which calls the copyToClipboard method from the Alpine component when clicked.

1<?php
2 
3namespace App\Extensions;
4 
5use Torchlight\Block;
6use Torchlight\Commonmark\V2\TorchlightExtension;
7 
8class TorchlightWithCopyExtension extends TorchlightExtension
9{
10 public function defaultBlockRenderer()
11 {
12 return function (Block $block) {
13 $torchlight = parent::defaultBlockRenderer();
14 
15 return <<<HTML
16 <div x-data="codeBlock" class="relative">
17 <div class="hidden lg:flex lg:space-x-2 lg:items-center absolute top-0 right-0 mr-3 mt-3">
18 <div x-cloak x-show="showMessage" x-transition class="animate-bounce transition duration-300 mt-1 text-teal-400 text-xs">Copied!</div>
19 <button
20 type="button"
21 title="Copy to clipboard"
22 class="hidden md:block transition duration-300"
23 @click.prevent="copyToClipboard"
24 :class="{
25 'text-white/30 hover:text-white/80': !showMessage,
26 'text-teal-400 hover:text-teal-400': showMessage,
27 }">
28 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="block w-6 h-6">
29 <path stroke-linecap="round" stroke-linejoin="round" d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m8.9-4.414c.376.023.75.05 1.124.08 1.131.094 1.976 1.057 1.976 2.192V16.5A2.25 2.25 0 0118 18.75h-2.25m-7.5-10.5H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V18.75m-7.5-10.5h6.375c.621 0 1.125.504 1.125 1.125v9.375m-8.25-3l1.5 1.5 3-3.75" />
30 </svg>
31 </button>
32 </div>
33 {$torchlight($block)}
34 </div>
35 HTML;
36 };
37 }
38}
1<?php
2 
3namespace App\Extensions;
4 
5use Torchlight\Block;
6use Torchlight\Commonmark\V2\TorchlightExtension;
7 
8class TorchlightWithCopyExtension extends TorchlightExtension
9{
10 public function defaultBlockRenderer()
11 {
12 return function (Block $block) {
13 $torchlight = parent::defaultBlockRenderer();
14 
15 return <<<HTML
16 <div x-data="codeBlock" class="relative">
17 <div class="hidden lg:flex lg:space-x-2 lg:items-center absolute top-0 right-0 mr-3 mt-3">
18 <div x-cloak x-show="showMessage" x-transition class="animate-bounce transition duration-300 mt-1 text-teal-400 text-xs">Copied!</div>
19 <button
20 type="button"
21 title="Copy to clipboard"
22 class="hidden md:block transition duration-300"
23 @click.prevent="copyToClipboard"
24 :class="{
25 'text-white/30 hover:text-white/80': !showMessage,
26 'text-teal-400 hover:text-teal-400': showMessage,
27 }">
28 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="block w-6 h-6">
29 <path stroke-linecap="round" stroke-linejoin="round" d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m8.9-4.414c.376.023.75.05 1.124.08 1.131.094 1.976 1.057 1.976 2.192V16.5A2.25 2.25 0 0118 18.75h-2.25m-7.5-10.5H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V18.75m-7.5-10.5h6.375c.621 0 1.125.504 1.125 1.125v9.375m-8.25-3l1.5 1.5 3-3.75" />
30 </svg>
31 </button>
32 </div>
33 {$torchlight($block)}
34 </div>
35 HTML;
36 };
37 }
38}

Your styling may vary of course.

Now that we've extended the Torchlight renderer, let's take a look at how the Alpine component functions.

The Alpine Data to make it magical

First, we have to add an option to the Torchlight config -- torchlight.options.copyable. This option makes Torchlight send an additional (unformatted, hidden) code block back that we can use as a copy target. Big thanks to Jason Beggs for pointing this option out! It makes copying the code to the user's clipboard much simpler.

Edit your config/torchlight.php file to include the following in the options array:

1'options' => [
2 'copyable' => true,
3],
1'options' => [
2 'copyable' => true,
3],

Next, we'll extract the Alpine code into a reusable chunk. First, create a file placed at resources/js/codeBlock.js. This file handles the actual copying of the code to the user's clipboard and then shows the "Copied!" message to the user.

1// resources/js/codeBlock.js
2export default () => ({
3 showMessage: false,
4 copyToClipboard() {
5 navigator.clipboard.writeText(
6 // This requires torchlight.options.copyable to be "true" on the PHP side.
7 $this.root.querySelector('.torchlight-copy-target').textContent
8 )
9 // show the "copied" message for 2 seconds
10 this.showMessage = true
11 setTimeout(() => (this.showMessage = false), 2000)
12 },
13})
1// resources/js/codeBlock.js
2export default () => ({
3 showMessage: false,
4 copyToClipboard() {
5 navigator.clipboard.writeText(
6 // This requires torchlight.options.copyable to be "true" on the PHP side.
7 $this.root.querySelector('.torchlight-copy-target').textContent
8 )
9 // show the "copied" message for 2 seconds
10 this.showMessage = true
11 setTimeout(() => (this.showMessage = false), 2000)
12 },
13})

We can simply grab the textContent from the .torchlight-copy-target selector and pass it to the clipboard using navigator.clipboard.writeText from the Clipboard API. Simple AND effective!

Next, make sure to register codeBlock as Alpine.data in our app.js file.

1// resources/js/app.js
2import Alpine from 'alpinejs'
3import codeBlock from './codeBlock'
4 
5window.Alpine = Alpine
6 
7Alpine.data('codeBlock', codeBlock)
8Alpine.start()
1// resources/js/app.js
2import Alpine from 'alpinejs'
3import codeBlock from './codeBlock'
4 
5window.Alpine = Alpine
6 
7Alpine.data('codeBlock', codeBlock)
8Alpine.start()

Register the Extension

There's one last step. We need to register our extension in the boot() method of our application's AppServiceProvider. Then, whenever we generate code blocks from markdown content, the copy button will automatically be included.

1use App\Extensions\TorchlightWithCopyButtonExtension;
2 
3public function boot()
4{
5 Markdown::addExtensions(fn () => [
6 new TorchlightWithCopyButtonExtension,
7 ]);
8}
1use App\Extensions\TorchlightWithCopyButtonExtension;
2 
3public function boot()
4{
5 Markdown::addExtensions(fn () => [
6 new TorchlightWithCopyButtonExtension,
7 ]);
8}

That's all there is to it! With just a few steps, we were able to enhance markdown-generated code blocks with a convenient Copy to Clipboard feature. Enjoy the new code blocks, and Happy Coding!

Enjoy this article? Follow me on Twitter for more tips, articles and links.
😢 Awww, nobody has liked or mentioned this on Twitter yet.

Want Updates?

Sign up here if you want to stay in the loop about new articles or products I'm making.
I'll never spam you. Unsubscribe at any time.
Copyright ©2024 Austen Cameron