Advanced Torchlight Integration with Statamic's Bard Field

Build a custom Bard fieldtype that integrates Torchlight's syntax highlighting and allows us to edit code blocks right in our IDE.

Joe Tannenbaum

Original photo by Linus Sandvide on Unsplash

What We're Building

In this article, you'll learn how to build a custom Bard fieldtype that allows us to integrate Torchlight syntax highlighting into Statamic and edit the code from our posts directly in our editor of choice.

I had a couple of rules when I started my blog about a month ago.

  1. Don't build a custom blog.
    Am I even a developer if I haven't built my own blog and am constantly refining it? I didn't want to fall into the trap of reinventing the wheel or over-engineering something as simple as a blog.

  2. Write posts in a rich text editor.
    I love markdown as much as the next person, but I don't always write as efficiently with it. It creates mental overhead, I just want to type out my articles in a decent rich text editor.

  3. Keep it simple.
    The blog didn't need to have a bunch of bells and whistles.

  4. Don't get caught up in the theme or design.
    As long as it looked pretty good and was readable, I was happy. I'm not a designer and so I didn't want to fall down a rabbit hole of visual tweaks when I'm not qualified to do that in the first place.

I started with Ghost because I was working on a project with a client that heavily involved Ghost and the simplicity and lack of customization was alluring. It allowed me to just start writing and not break any of my rules.

Code Red

Overall I was happy with Ghost. Except for one major thing: writing code blocks. I found it frustrating. The syntax highlighting was never quite right, I was constantly copying and pasting blocks of code in and out of Visual Studio Code and Ghost to get the formatting to look right... it just felt painful.

I See the Light

I was listening to the Twenty Percent Time podcast with their guest Aaron Francis. He was discussing his project Torchlight, syntax-highlighting-as-a-service that was more accurate than popular client-side libraries.

Syntax highlighting that was as accurate as my IDE? Uh, yes. Bada bing. There it is. That alone made me want to switch my blog over to Statamic (which I was already familiar with) and integrate Torchlight. Torchlight also offers:

...among so many other well-thought-out options.

Assuring myself I would keep the dev side as light as possible (ha), I grabbed the Starter's Creek kit and got crackin'.

Without further ado, let's break all of my rules!

Integrating Torchlight into Statamic

With a quick Google search, this article by Duncan McClean gave me a great head start. I'm not going to repeat the steps that he already laid out, we'll just pick up where the article leaves off.

Planning It Out

On the front end, we'll have a torchlight Statamic tag that will do all of the heavy lifting. We've already got some code thanks to Duncan, here's what it looks like after following the steps in his article:

1<?php
2 
3namespace App\Tags;
4 
5use Statamic\Tags\Tags;
6use Torchlight\Blade\BladeManager;
7use Torchlight\Block;
8 
9class Torchlight extends Tags
10{
11 /**
12 * {{ torchlight language="php" }}{{ my_code }}{{ /torchlight }}
13 */
14 public function index()
15 {
16 $language = $this->params->get('language');
17 $code = $this->context->raw('code');
18 
19 $block = Block::make()
20 ->language($language)
21 ->code($code)
22 ->theme(config('torchlight.theme'));
23 
24 BladeManager::registerBlock($block);
25 
26 $render = function (Block $block) {
27 return "<pre><code class='{$block->placeholder('classes')}' style='{$block->placeholder('styles')}'>{$block->placeholder()}</code></pre>";
28 };
29 
30 return $render($block);
31 }
32}

When writing in Bard, we should have two options:

  • writing code right in the post for one or two liners

  • writing code in our editor of choice for longer chunks and Statamic pulls them in

We'll create a Bard set to cover each scenario. We could combo them into one, but it feels messy and we want to keep these blocks as clean as possible.

The inline code set is pretty straightforward, a code text box and language override if we need it:

Let's set a couple of parameters for the editor feature:

  • Code snippets should be organized per article so that we could navigate through all of them quickly if we need to.

  • We don't want to have to create the actual files manually, it should happen automatically if possible (aaaand we're already breaking rules #1 and #3).

  • We don't want to have to specify the language for the syntax highlighter within Bard. For the vast majority of cases, it should be based on an educated guess (the file extension) and we can course correct on a case-by-case basis.

With those parameters in mind, we're going to build a custom fieldtype for the second set.

Building the Torchlight Fieldtype

Statamic helps us kick things off with a scaffolding command:

1php please make:fieldtype Torchlight

That gives us the Torchlight fieldtype PHP class and Torchlight.vue for rendering it in Bard.

We're going to be adding some JavaScript to the control panel and registering some routes, so let's do that in our AppServiceProvider:

1<?php
2 
3namespace App\Providers;
4 ...
5use Illuminate\Support\Facades\Route;
6use Illuminate\Support\ServiceProvider;
7use Statamic\Facades\Markdown;
8use Statamic\Statamic;
9use Torchlight\Commonmark\V1\TorchlightExtension;
10 
11class AppServiceProvider extends ServiceProvider
12{
13 public function boot()
14 {
15 Markdown::addExtension(function () {
16 return new TorchlightExtension;
17 });
18 
19 Statamic::script('app', 'cp.js');
20 
21 Statamic::pushCpRoutes(function () {
22 Route::namespace('\App\Http\Controllers')->group(function () {
23 require base_path('routes/cp.php');
24 });
25 });
26 }
27}

Finally, we'll need to add our JavaScript file to our webpack.mix.js:

1mix.js('resources/js/cp.js', 'public/vendor/app/js').vue({ version: 2 });

Ok, let's figure out how we want our block to work. At its core, it's basically just a text input. Here's the logic:

  • If we enter an explicit filename, it will create that file (e.g. "save-entry-listener.php")

  • If we enter a general description + a language it will create that file with a unique identifier (e.g. "save entry listener php")

  • If we just enter a language, it will create a file with a unique identifier (e.g. "php")

  • We should be able to click a link to open the file in our editor directly

  • We should be able to see a simple preview of the code in the block

The Client Side

Let's take a look at Torchlight.vue:

1<template>
2 <div>
3 <text-input
4 :value="value"
5 @input="update"
6 placeholder="Filename or language"
7 :is-read-only="disable"
8 />
9 <div class="flex items-center justify-center w-full mt-2 space-x-2">
10 <button
11 v-if="!disable"
12 @click.stop="createFile"
13 type="button"
14 tabindex="0"
15 title="Create"
16 class="w-6 h-6"
17 :class="{
18 'opacity-50 cursor-not-allowed': !value,
19 }"
20 >
21 ...
22 <svg
23 xmlns="http://www.w3.org/2000/svg"
24 fill="none"
25 viewBox="0 0 24 24"
26 stroke-width="1.5"
27 stroke="currentColor"
28 class="w-full h-full"
29 >
30 <path
31 stroke-linecap="round"
32 stroke-linejoin="round"
33 d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
34 />
35 </svg>
36
37 </button>
38 <button
39 @click.stop="openEditor('file')"
40 type="button"
41 tabindex="0"
42 title="Open"
43 class="w-6 h-6"
44 :class="{
45 'opacity-50 cursor-not-allowed': !value,
46 }"
47 >
48 ...
49 <svg
50 xmlns="http://www.w3.org/2000/svg"
51 fill="none"
52 viewBox="0 0 24 24"
53 stroke-width="1.5"
54 stroke="currentColor"
55 class="w-full h-full"
56 >
57 <path
58 stroke-linecap="round"
59 stroke-linejoin="round"
60 d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
61 />
62 </svg>
63
64 </button>
65 <button
66 @click.stop="openEditor('directory')"
67 type="button"
68 tabindex="0"
69 title="Open Directory"
70 class="w-6 h-6"
71 :class="{
72 'opacity-50 cursor-not-allowed': !value,
73 }"
74 >
75 ...
76 <svg
77 xmlns="http://www.w3.org/2000/svg"
78 fill="none"
79 viewBox="0 0 24 24"
80 stroke-width="1.5"
81 stroke="currentColor"
82 class="w-full h-full"
83 >
84 <path
85 stroke-linecap="round"
86 stroke-linejoin="round"
87 d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
88 />
89 </svg>
90
91 </button>
92 <button
93 @click.stop="refreshCode"
94 type="button"
95 tabindex="0"
96 title="Refresh Code Preview"
97 class="w-6 h-6"
98 :class="{
99 'opacity-50 cursor-not-allowed': !value,
100 }"
101 >
102 ...
103 <svg
104 xmlns="http://www.w3.org/2000/svg"
105 fill="none"
106 viewBox="0 0 24 24"
107 stroke-width="1.5"
108 stroke="currentColor"
109 class="w-full h-full"
110 >
111 <path
112 stroke-linecap="round"
113 stroke-linejoin="round"
114 d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
115 />
116 </svg>
117
118 </button>
119 </div>
120 <div
121 class="p-2 mt-2 overflow-auto text-white bg-black"
122 style="max-height: 12rem"
123 >
124 <pre style="min-width: max-content">{{ code }}</pre>
125 </div>
126 </div>
127</template>
128 
129<script>
130export default {
131 mixins: [Fieldtype],
132 
133 inject: ['storeName'],
134 
135 mounted() {
136 this.refreshCode();
137 },
138 
139 data() {
140 return {
141 code: '',
142 disable: !!this.value,
143 };
144 },
145 
146 methods: {
147 refreshCode() {
148 if (!this.value) {
149 return;
150 }
151 
152 this.$axios
153 .get('/cp/torchlight/code', {
154 params: {
155 filename: this.value,
156 entry_id: this.entryId,
157 },
158 })
159 .then((res) => {
160 this.code = res.data;
161 });
162 },
163 openEditor(toOpen) {
164 if (!this.value) {
165 return;
166 }
167 
168 this.createFile().then((res) => {
169 if (toOpen === 'file') {
170 window.open(
171 `${this.meta.baseUrl}/${res.data.path}?windowId=_blank`,
172 );
173 } else {
174 window.open(
175 `${this.meta.baseUrl}/${this.meta.baseDirectory}/${this.entryId}?windowId=_blank`,
176 );
177 }
178 });
179 },
180 createFile() {
181 return this.$axios
182 .post('/cp/torchlight/file', {
183 filename: this.value,
184 entry_id: this.entryId,
185 })
186 .then((res) => {
187 this.update(res.data.filename);
188 this.disable = true;
189 
190 return res;
191 });
192 },
193 },
194 
195 computed: {
196 entryId() {
197 return this.$store.state.publish[this.storeName].values.id;
198 },
199 },
200};
201</script>

Here's what it looks like in Bard:

Icons are from the wonderful Heroicons
Icons are from the wonderful Heroicons

Pretty clean! A couple things to note:

  • Once we have a value, we are disabling the input so we don't accidentally change the filename

  • The buttons (left-to-right) are:

    • Create file

    • Create file and open in IDE

    • Create file and open post code snippets directory in IDE

    • Refresh the code preview in the block from the source file

This is what a filled-out block looks like if we enter "torchlight vue" and click on the plus button:

You'll notice:

  • The input is disabled

  • The "add" button is gone

  • There is a simple preview of the code

The Server Side

Let's take a look at the class powering the fieldtype, Fieldtypes\Torchlight. We changed very little from the initial scaffolding, we added some metadata using the preload method and attached an icon (a torch from the Streamline Light icon set):

1<?php
2 
3namespace App\Fieldtypes;
4 
5use App\Torchlight\Editor;
6use Statamic\Fields\Fieldtype;
7 
8class Torchlight extends Fieldtype
9{
10 
11 /**
12 * The blank/default value.
13 *
14 * @return array
15 */
16 public function defaultValue()
17 {
18 return null;
19 }
20 
21 /**
22 * Pre-process the data before it gets sent to the publish page.
23 *
24 * @param mixed $data
25 * @return array|mixed
26 */
27 public function preProcess($data)
28 {
29 return $data;
30 }
31 
32 /**
33 * Process the data before it gets saved.
34 *
35 * @param mixed $data
36 * @return array|mixed
37 */
38 public function process($data)
39 {
40 return $data;
41 }
42 
43 public function preload()
44 {
45 return [
46 'baseDirectory' => resource_path('code-snippets'),
47 'baseUrl' => Editor::url(),
48 ];
49 }
50 
51 public function icon()
52 {
53 return file_get_contents(resource_path('svg/torch.svg'));
54 }
55}

You might notice that we're using an Editor helper, let's see what that looks like:

1<?php
2 
3namespace App\Torchlight;
4 
5class Editor
6{
7 public static function url($path = '')
8 {
9 return rtrim("vscode://file/{$path}", '/');
10 }
11 
12 public static function urlInNewWindow($path = '')
13 {
14 return self::url($path) . '?windowId=_blank';
15 }
16}

I'm using VS Code, but you can easily set up URLs for your preferred IDE. Since we're already in Laravel anyway, you can check out the Illuminate\Foundation\Concerns\ResolvesDumpSource trait to see if yours is included there for use in the Editor class.

Let's get to the meat and potatoes. First, let's look at TorchlightFile, which has a ton of helpers for us to use as we build out the PHP side of things:

1<?php
2 
3namespace App\Torchlight;
4 
5use Illuminate\Support\Str;
6 
7class TorchlightFile
8{
9 public function __construct(protected string $entryId, protected string $filename)
10 {
11 }
12 
13 public $languageMapping = [
14 'antlers.html' => 'antlers',
15 ];
16 
17 public function path()
18 {
19 return resource_path("code-snippets/{$this->entryId}/{$this->filename}");
20 }
21 
22 public function dir()
23 {
24 return pathinfo($this->path(), PATHINFO_DIRNAME);
25 }
26 
27 public function filename()
28 {
29 return $this->filename;
30 }
31 
32 public function create()
33 {
34 $path = $this->path();
35 $dir = $this->dir();
36 
37 if (!is_dir($dir)) {
38 mkdir($dir);
39 }
40 
41 if (!$this->exists()) {
42 touch($path);
43 }
44 
45 return $path;
46 }
47 
48 public function createRandom($language, $prefix = '')
49 {
50 $extension = collect($this->languageMapping)->search(fn ($l) => $l === $language) ?: $language;
51 
52 $this->filename = ltrim($prefix . '-' . Str::uuid() . '.' . $extension, '-');
53 
54 return $this->create();
55 }
56 
57 public function code()
58 {
59 if (!$this->exists()) {
60 return null;
61 }
62 
63 return file_get_contents($this->path());
64 }
65 
66 public function exists()
67 {
68 return file_exists($this->path());
69 }
70 
71 public function details(): array
72 {
73 if (!$this->exists()) {
74 return [];
75 }
76 
77 $path = $this->path();
78 
79 return [
80 'code' => $this->code(),
81 'language' => $this->languageFromPath($path),
82 'path' => $path,
83 ];
84 }
85 
86 public function codeWithoutAnnotations()
87 {
88 $code = $this->code();
89 
90 if (!$code) {
91 return null;
92 }
93 
94 return preg_replace(
95 [
96 '/\/\/ \[tl! .+\]/',
97 '/<!-- \[tl! .+\] -->/',
98 ],
99 '',
100 $code,
101 );
102 }
103 
104 public function languageFromPath(string $path)
105 {
106 $extension = pathinfo($path, PATHINFO_EXTENSION);
107 
108 return collect($this->languageMapping)->first(fn ($l) => Str::contains($path, '.' . $l)) ?? $extension;
109 }
110 
111 public function editorFileUrl()
112 {
113 return Editor::urlInNewWindow($this->path());
114 }
115 
116 public function editorDirectoryUrl()
117 {
118 return Editor::urlInNewWindow($this->dir());
119 }
120}

This is a utility class, pretty straightforward. You'll see it in use as we continue.

Let's turn now to the routes file we loaded up earlier in the AppServiceProvider, cp.php. You'll notice from the Vue file that we're using two routes. Let's take a look at the first one:

1<?php
2 
3Route::post('torchlight/file', function (Request $request) {
4 $createdFileResponse = function (TorchlightFile $tlFile) {
5 return [
6 'path' => $tlFile->path(),
7 'filename' => $tlFile->filename(),
8 'directory_url' => $tlFile->editorDirectoryUrl(),
9 'file_url' => $tlFile->editorFileUrl(),
10 ];
11 };
12 
13 $filename = $request->input('filename');
14 $entryId = $request->input('entry_id');
15 
16 $tlFile = new TorchlightFile(
17 entryId: $entryId,
18 filename: $filename,
19 );
20 
21 if ($tlFile->exists()) {
22 return $createdFileResponse($tlFile);
23 }
24 
25 if (Str::contains($filename, ' ')) {
26 // A description and language was typed in, let's resolve a filename
27 // e.g. "entry saved php"
28 $parts = collect(explode(' ', $filename));
29 $language = $parts->pop();
30 
31 $tlFile->createRandom($language, $parts->join('-'));
32 
33 return $createdFileResponse($tlFile);
34 }
35 
36 if (!Str::contains($filename, '.')) {
37 // They probably typed in just a language,
38 // just create an anonymous file of the correct type for them
39 $tlFile->createRandom($filename);
40 
41 return $createdFileResponse($tlFile);
42 }
43 
44 // At this point we can be reasonbly sure they typed in a filename, let's just create it for them
45 $tlFile = new TorchlightFile(
46 entryId: $entryId,
47 filename: $filename,
48 );
49 
50 $tlFile->create();
51 
52 return $createdFileResponse($tlFile);
53});

This route does one thing: it creates the file if it doesn't already exist. It handles the three points referenced above (explicit filename, general description + a language, just a language) and returns the full path, filename, and editor URLs. It leans heavily on the TorchlightFile class.

Next route:

1<?php
2 
3Route::get('torchlight/code', function (Request $request) {
4 $tlFile = new TorchlightFile(
5 entryId: $request->input('entry_id'),
6 filename: $request->input('filename'),
7 );
8 
9 return $tlFile->code();
10});

This one's straight down the middle. Get the code from the requested file. We use this to fetch the code preview for the block.

Just a couple of files left to look at. You're doing great. Drink some water and let's keep movin'.

Next up is Tags\Torchlight. We've changed it significantly since we followed the steps in the initial article. We needed to determine whether we were dealing with inline code or code from a file and handle each properly, plus we've added some functionality on the Antlers side. Let's take a peek:

1<?php
2 
3namespace App\Tags;
4 
5use App\Torchlight\TorchlightFile;
6use Statamic\Tags\Tags;
7use Torchlight\Blade\BladeManager;
8use Torchlight\Block;
9 
10class Torchlight extends Tags
11{
12 public function index()
13 {
14 if (!$this->context->raw('torchlight')) {
15 // This is inline code from the code editor
16 return $this->codeBlock(
17 $this->context->raw('language') ?: $this->context->raw('code')['mode'],
18 $this->context->raw('code')['code'],
19 );
20 }
21 
22 $tlFile = new TorchlightFile(
23 entryId: $this->context->raw('id'),
24 filename: $this->context->raw('torchlight'),
25 );
26 
27 $details = $tlFile->details();
28 
29 return array_merge(
30 [
31 'file_url' => $tlFile->editorFileUrl(),
32 'directory_url' => $tlFile->editorDirectoryUrl(),
33 'show_file_links' => app()->environment('local'),
34 ],
35 $this->codeBlock(
36 $details['language'],
37 $details['code'],
38 [
39 'raw' => $tlFile->codeWithoutAnnotations(),
40 ],
41 )
42 );
43 }
44 
45 protected function codeBlock($language, $code, $extra = [])
46 {
47 $block = Block::make()
48 ->language($language)
49 ->code($code)
50 ->theme(config('torchlight.theme'));
51 
52 BladeManager::registerBlock($block);
53 
54 return [
55 'code' => array_merge([
56 'classes' => $block->placeholder('classes'),
57 'styles' => $block->placeholder('styles'),
58 'placeholder' => $block->placeholder(),
59 ], $extra),
60 ];
61 }
62}

For the file-based code we're merging in some extra data, specifically:

  • file_url - allows us to open the code in the editor directly from the post preview

  • directory_url - allows us to open the directory of post code snippets in the editor directly from the post preview

  • show_file_links - a flag that tells us whether or not we should show the above links (only in the local env)

  • code.raw - the code without Torchlight annotations so the reader can copy it right from the block

Let's have a look at the Antlers side of the equation. First, we check for our sets as we're looping through Bard content:

1{{ main_content }}
2 {{ if $type == "text" }}
3 {{ $text }}
4 {{ elseif $type == "code_block" || $type == 'torchlight' }}
5 {{ partial:torchlight }}
6 {{ /if }}
7{{ /main_content }}

Let's lay our peepers on that torchlight partial:

1{{ torchlight }}
2 <div x-data="codeBlock" class="relative">
3 {{ if show_file_links }}
4 <div class="absolute top-0 left-0 flex flex-col pr-2 -translate-x-full">
5 <a href="{{ file_url }}">
6 ...
7 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
8 stroke="currentColor" class="w-6 h-6">
9 <path stroke-linecap="round" stroke-linejoin="round"
10 d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z" />
11 </svg>
12
13 </a>
14 <a href="{{ directory_url }}">
15 ...
16 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
17 stroke="currentColor" class="w-6 h-6">
18 <path stroke-linecap="round" stroke-linejoin="round"
19 d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
20 </svg>
21
22 </a>
23 </div>
24 {{ /if }}
25 {{ code }}
26 <pre class="overflow-x-auto"><code class="{{ classes }} min-w-max w-full min-h-full"
27 style="{{ styles }}">{{ placeholder }}</code></pre>
28 
29 <button type="button" @click="onCopy" x-clipboard.raw="{{ raw | entities }}" title="Copy to Clipboard"
30 class="absolute w-6 h-6 overflow-hidden cursor-pointer text-hot-pink right-4 top-4">
31 <svg x-show="!copied" x-cloak xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
32 stroke-width="1.5" stroke="currentColor" class="absolute top-0 left-0 w-6 h-6"
33 x-transition:enter="transition ease-in duration-200" x-transition:enter-start="-translate-x-full"
34 x-transition:enter-end="translate-x-0" x-transition:leave="transition ease-out duration-200"
35 x-transition:leave-start="translate-x-0" x-transition:leave-end="-translate-x-full">
36 <path stroke-linecap="round" stroke-linejoin="round"
37 d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
38 </svg>
39 <svg x-show="copied" x-cloak xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
40 stroke-width="1.5" stroke="currentColor" class="absolute top-0 left-0 w-6 h-6"
41 x-transition:enter="transition ease-out duration-200" x-transition:enter-start="translate-x-full"
42 x-transition:enter-end="translate-x-0" x-transition:leave="transition ease-in duration-200"
43 x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full">
44 <path stroke-linecap="round" stroke-linejoin="round"
45 d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
46 </svg>
47 </button>
48 {{ /code }}
49 </div>
50{{ /torchlight }}

Not too shabby. Here's what we're looking at:

  • We have links (that only show if the show_file_links flag is set) to open the file/directory in our editor

  • We have a "copy code" button that has two SVGs (Heroicons ftw again), one for "copy" and one for "copied"

  • We are outputting the Torchlight placeholders as returned by our tag, these will be replaced by the middleware we registered when setting up Torchlight (steps in Duncan's article)

When I'm writing an article locally, here's what I see:

The icons on the left won't show up in the published article
The icons on the left won't show up in the published article

Finally, the "copy code" functionality. There are lots of ways to handle this, but since the theme was already using Alpine.js, I just pulled in Ryan Chandler's Alpine Clipboard Plugin. Here's what the JS looks like:

1document.addEventListener('alpine:init', () => {
2 Alpine.data('codeBlock', () => ({
3 copied: false,
4 onCopy() {
5 this.copied = true;
6 
7 setTimeout(() => {
8 this.copied = false;
9 }, 2000);
10 },
11 }));
12});

Well, We've Broken All of My Rules

...but now we have a sweet new way of writing code in our blog posts that I, personally, am loving. So let's put a W on the board.

If you stuck with me to the end, you're amazing, thank you so much. Treat yourself to a pat on the back and a fresh cup of coffee. If you haven't, the browser tab is already closed and I'll never know.

Some Caveats

There are a couple of shortcomings with this approach that I haven't quite figured out yet:

  • The post needs to be saved first.
    Because we're organizing code snippets according to the entry ID, the post has to be saved before we can add any Torchlight fields. Generally speaking, I don't consider this a problem, but it certainly is a consideration.

  • The code in our Bard block in the control panel is not syntax highlighted.
    I'm personally ok with this. It can be implemented but wasn't high on my list of requirements.

  • The code in our Bard block in the control panel doesn't live update with the code from our file.
    Again, not high on my personal list of priorities. But if you come up with a solution to this I'd love to hear about it.

Bonus: VS Code Torchlight Snippets

Since you've made it this far, you deserve a parting gift: a JSON file full of VS Code snippets so you don't have to remember the Torchlight comment syntax.

1{
2 "Torchlight: Focus (Slash Comment)": {
3 "scope": "php,javascript,typescript,go",
4 "prefix": "tlfocus",
5 "body": ["// [tl! focus$1]"],
6 "description": "Instruct Torchlight to focus on this line"
7 },
8 "Torchlight: Highlight (Slash Comment)": {
9 "scope": "php,javascript,typescript,go",
10 "prefix": "tlhighlight",
11 "body": ["// [tl! highlight$1]"],
12 "description": "Instruct Torchlight to highlight this line"
13 },
14 "Torchlight: Start Collapse (Slash Comment)": {
15 "scope": "php,javascript,typescript,go",
16 "prefix": "tlstartcollapse",
17 "body": ["// [tl! collapse:start$1]"],
18 "description": "Instruct Torchlight to start collapsing from this line"
19 },
20 "Torchlight: End Collapse (Slash Comment)": {
21 "scope": "php,javascript,typescript,go",
22 "prefix": "tlendcollapse",
23 "body": ["// [tl! collapse:end$1]"],
24 "description": "Instruct Torchlight to end collapsing on this line"
25 },
26 "Torchlight: Diff Add (Slash Comment)": {
27 "scope": "php,javascript,typescript,go",
28 "prefix": "tladd",
29 "body": ["// [tl! ++]"],
30 "description": "Instruct Torchlight to indicate this line was added"
31 },
32 "Torchlight: Diff Remove (Slash Comment)": {
33 "scope": "php,javascript,typescript,go",
34 "prefix": "tlremove",
35 "body": ["// [tl! --]"],
36 "description": "Instruct Torchlight to indicate this line was removed"
37 },
38 "Torchlight: Auto Link (Slash Comment)": {
39 "scope": "php,javascript,typescript,go",
40 "prefix": "tllink",
41 "body": ["// [tl! autolink]"],
42 "description": "Instruct Torchlight to autolink this line"
43 },
44 "Torchlight: Re-Index (Slash Comment)": {
45 "scope": "php,javascript,typescript,go",
46 "prefix": "tlreindex",
47 "body": ["// [tl! reindex($1)]"],
48 "description": "Instruct Torchlight to re-index this line"
49 },
50 "Torchlight: Focus (HTML Comment)": {
51 "scope": "html",
52 "prefix": "tlfocus",
53 "body": ["<!-- [tl! focus$1] -->"],
54 "description": "Instruct Torchlight to focus on this line"
55 },
56 "Torchlight: Highlight (HTML Comment)": {
57 "scope": "html",
58 "prefix": "tlhighlight",
59 "body": ["<!-- [tl! highlight$1] -->"],
60 "description": "Instruct Torchlight to highlight this line"
61 },
62 "Torchlight: Start Collapse (HTML Comment)": {
63 "scope": "html",
64 "prefix": "tlstartcollapse",
65 "body": ["<!-- [tl! collapse:start$1] -->"],
66 "description": "Instruct Torchlight to start collapsing from this line"
67 },
68 "Torchlight: End Collapse (HTML Comment)": {
69 "scope": "html",
70 "prefix": "tlendcollapse",
71 "body": ["<!-- [tl! collapse:end$1] -->"],
72 "description": "Instruct Torchlight to end collapsing on this line"
73 },
74 "Torchlight: Diff Add (HTML Comment)": {
75 "scope": "html",
76 "prefix": "tladd",
77 "body": ["<!-- [tl! ++] -->"],
78 "description": "Instruct Torchlight to indicate this line was added"
79 },
80 "Torchlight: Diff Remove (HTML Comment)": {
81 "scope": "html",
82 "prefix": "tlremove",
83 "body": ["<!-- [tl! --] -->"],
84 "description": "Instruct Torchlight to indicate this line was removed"
85 },
86 "Torchlight: Auto Link (HTML Comment)": {
87 "scope": "html",
88 "prefix": "tllink",
89 "body": ["<!-- [tl! autolink] -->"],
90 "description": "Instruct Torchlight to autolink this line"
91 },
92 "Torchlight: Re-Index (HTML Comment)": {
93 "scope": "html",
94 "prefix": "tlreindex",
95 "body": ["<!-- [tl! reindex($1)] -->"],
96 "description": "Instruct Torchlight to re-index this line"
97 }
98}
(Excellent) syntax highlighting provided by Torchlight