Spotlight on Torchlight

I've been futzing around a bit with how code blocks render on this blog. Hugo has a built-in, really fast, syntax highlighter courtesy of Chroma. Chroma is basically automatic and it renders very quickly1 during the hugo build process, and it's a pretty solid "works everywhere out of the box" option.

That said, the one-size-fits-all approach may not actually fit everyone well, and Chroma does leave me wanting a bit more. Chroma sometimes struggles with tokenizing and highlighting certain languages, leaving me with boring monochromatic text blocks. Hugo's implementation supports highlighting individual lines by inserting directives next to the code fence backticks (like {hl_lines="11-13"} to highlight lines 11-13), but that can be clumsy if you're not sure which lines need to be highlighted2, are needing to highlight multiple disjointed lines, or later insert additional lines which throw off the count. And sometimes I'd like to share a full file for context while also collapsing it down to just the bits I'm going to write about. That's not something that can be done with the built-in highlighter (at least not without tacking on a bunch of extra JavaScript and CSS nonsense3).

But then I found a post from Sebastian de Deyne about Better code highlighting in Hugo with Torchlight. and I thought that Torchlight sounded pretty promising.

From Torchlight's docs,

Torchlight is a VS Code-compatible syntax highlighter that requires no JavaScript, supports every language, every VS Code theme, line highlighting, git diffing, and more.

Unlike traditional syntax highlighting tools, Torchlight is an HTTP API that tokenizes and highlights your code on our backend server instead of in the visitor's browser.

We find this to be the easiest and most powerful way to achieve accurate and feature rich syntax highlighting.

Client-side language parsers are limited in their complexity since they have to run in the browser environment. There are a lot of edge cases that those libraries can't catch.

Torchlight relies on the VS Code parsing engine and TextMate language grammars to achieve the most accurate results possible. We bring the power of the entire VS Code ecosystem to your docs or blog.

In short: Code blocks in, formatted HTML out, and no JavaScript or extra code to render this slick display in the browser:

1# netlify.toml
2[build]
3 publish = "public"
4 
5[build.environment]
- HUGO_VERSION = "0.111.3"
+ HUGO_VERSION = "0.116.1"
7 
8[context.production]
9 command = """
10 hugo --minify
11 npm i @torchlight-api/torchlight-cli
12 npx torchlight
13 """
14 
15[context.preview] { ... }
16 command = """
17 hugo --minify --environment preview
18 npm i @torchlight-api/torchlight-cli
19 npx torchlight
20 """
21 [[headers]]
22 for = "/*"
23 [headers.values]
24 X-Robots-Tag = "noindex"
25 
26[[redirects]]
27 from = "/*"
28 to = "/404/"
29 status = 404

Pretty nice, right? That block's got:

  • Colorful, accurate syntax highlighting
  • Traditional line highlighting
  • A shnazzy blur/focus to really make the important lines pop
  • In-line diffs to show what's changed
  • An expandable section to reveal additional context on-demand

And marking-up that code block was pretty easy and intuitive. Torchlight is controlled by annotations inserted as comments appropriate for whatever language you're using (like # [tl! highlight] to highlight a single line). In most cases you can just put the annotation right at the end of the line you're trying to flag. You can also specify ranges relative to the current line ([tl! focus:5] to apply the focus effect to the current line and the next five) or use :start and :end so you don't have to count at all.

# netlify.toml
[build]
publish = "public"
 
[build.environment]
# diff: remove this line
HUGO_VERSION = "0.111.3" # [tl! --]
# diff: add this line, adjust line numbering to compensate
HUGO_VERSION = "0.116.1" # [tl! ++ reindex(-1)]
 
# focus this line and the following 5, highlight the third line down
[context.production] # [tl! focus:5 highlight:3,1]
command = """
hugo --minify
npm i @torchlight-api/torchlight-cli
npx torchlight
"""
 
# collapse everything from `:start` to `:end`
[context.preview] # [tl! collapse:start]
command = """
hugo --minify --environment preview
npm i @torchlight-api/torchlight-cli
npx torchlight
"""
[[headers]]
for = "/*"
[headers.values]
X-Robots-Tag = "noindex"
 
[[redirects]]
from = "/*"
to = "/404/"
status = 404 # [tl! collapse:end]

See what I mean? Being able to put the annotations directly on the line(s) they modify is a lot easier to manage than trying to keep track of multiple line numbers in the header. And I think the effect is pretty cool.

Basic setup

So what did it take to get this working on my blog?

I started with registering for a free4 account at torchlight.dev and generating an API token. I'll need to include that later with calls to the Torchlight API. The token will be stashed as an environment variable in my Netlify configuration, but I'll also stick it in a local .env file for use with local builds:

echo "TORCHLIGHT_TOKEN=torch_[...]" > ./.env

Installation

I then used npm to install Torchlight in the root of my Hugo repo:

npm i @torchlight-api/torchlight-cli
 
added 94 packages in 5s

That created a few new files and directories that I don't want to sync with the repo, so I added those to my .gitignore configuration. I'll also be sure to add that .env file so that I don't commit any secrets!

1# .gitignore
2.hugo_build.lock
+/node_modules/
+/package-lock.json
+/package.json
6/public/
7/resources/
+/.env

The installation instructions say to then initialize Torchlight like so:

npx torchlight init
 
node:internal/fs/utils:350
throw err;
^
 
Error: ENOENT: no such file or directory, open '/home/john/projects/runtimeterror/node_modules/@torchlight-api/torchlight-cli/dist/stubs/config.js'
at Object.openSync (node:fs:603:3)
at Object.readFileSync (node:fs:471:35)
at write (/home/john/projects/runtimeterror/node_modules/@torchlight-api/torchlight-cli/dist/bin/torchlight.cjs.js:524:39)
at init (/home/john/projects/runtimeterror/node_modules/@torchlight-api/torchlight-cli/dist/bin/torchlight.cjs.js:538:12)
at Command.<anonymous> (/home/john/projects/runtimeterror/node_modules/@torchlight-api/torchlight-cli/dist/bin/torchlight.cjs.js:722:12)
at Command.listener [as _actionHandler] (/home/john/projects/runtimeterror/node_modules/commander/lib/command.js:488:17)
at /home/john/projects/runtimeterror/node_modules/commander/lib/command.js:1227:65
at Command._chainOrCall (/home/john/projects/runtimeterror/node_modules/commander/lib/command.js:1144:12)
at Command._parseCommand (/home/john/projects/runtimeterror/node_modules/commander/lib/command.js:1227:27)
at Command._dispatchSubcommand (/home/john/projects/runtimeterror/node_modules/commander/lib/command.js:1050:25) {
errno: -2,
syscall: 'open',
code: 'ENOENT',
path: '/home/john/projects/runtimeterror/node_modules/@torchlight-api/torchlight-cli/dist/stubs/config.js'
}
 
Node.js v18.17.1
 

Oh. Hmm.

There's an open issue which reveals that the stub config file is actually located under the src/ directory instead of dist/. And it turns out the init step isn't strictly necessary, it's just a helper to get you a working config to start.

Configuration

Now that I know where the stub config lives, I can simply copy it to my repo root. I'll then get to work modifying it to suit my needs:

cp node_modules/@torchlight-api/torchlight-cli/src/stubs/config.js ./torchlight.config.js
1// torchlight.config.js
2module.exports = {
3 // Your token from https://torchlight.dev
4 token: process.env.TORCHLIGHT_TOKEN, // this will come from a netlify build var
5 
6 // The Torchlight client caches highlighted code blocks. Here you
7 // can define which directory you'd like to use. You'll likely
8 // want to add this directory to your .gitignore. Set to
9 // `false` to use an in-memory cache. You may also
10 // provide a full cache implementation.
- cache: 'cache',
+ cache: false, // disable cache for netlify builds
12 
13 // Which theme you want to use. You can find all of the themes at
14 // https://torchlight.dev/docs/themes.
- theme: 'material-theme-palenight',
+ theme: 'one-dark-pro', // switch up the theme
16 
17 // The Host of the API.
18 host: 'https://api.torchlight.dev',
19 
20 // Global options to control block-level settings.
21 // https://torchlight.dev/docs/options
22 options: {
23 // Turn line numbers on or off globally.
24 lineNumbers: false,
25 
26 // Control the `style` attribute applied to line numbers.
27 // lineNumbersStyle: '',
28 
29 // Turn on +/- diff indicators.
30 diffIndicators: true,
31 
32 // If there are any diff indicators for a line, put them
33 // in place of the line number to save horizontal space.
- diffIndicatorsInPlaceOfLineNumbers: true
+ diffIndicatorsInPlaceOfLineNumbers: true,
35 
36 // When lines are collapsed, this is the text that will
37 // be shown to indicate that they can be expanded.
- // summaryCollapsedIndicator: '...',
+ summaryCollapsedIndicator: 'Click to expand...', // make the collapse a little more explicit
39 },
40 
41 // Options for the highlight command.
42 highlight: {
43 // Directory where your un-highlighted source files live. If
44 // left blank, Torchlight will use the current directory.
- input: '',
+ input: 'public', // tells Torchlight where to find Hugo's processed HTML output
46 
47 // Directory where your highlighted files should be placed. If
48 // left blank, files will be modified in place.
49 output: '',
50 
51 // Globs to include when looking for files to highlight.
52 includeGlobs: [
53 '**/*.htm',
54 '**/*.html'
55 ],
56 
57 // String patterns to ignore (not globs). The entire file
58 // path will be searched and if any of these strings
59 // appear, the file will be ignored.
60 excludePatterns: [
61 '/node_modules/',
62 '/vendor/'
63 ]
64 }
65}

You can find more details about the configuration options here.

Stylization

It's not strictly necessary for the basic functionality, but applying a little bit of extra CSS to match up with the classes leveraged by Torchlight can help to make things look a bit more polished. Fortunately for this fake-it-til-you-make-it dev, Torchlight provides sample CSS that work great for this:

Put those blocks together (along with a few minor tweaks), and here's what I started with in assets/css/torchlight.css:

1 
2/*********************************************
3* Basic styling for Torchlight code blocks. *
4**********************************************/
5 
6/*
7 Margin and rounding are personal preferences,
8 overflow-x-auto is recommended.
9*/
10pre {
11 border-radius: 0.25rem;
12 margin-top: 1rem;
13 margin-bottom: 1rem;
14 overflow-x: auto;
15}
16 
17/*
18 Add some vertical padding and expand the width
19 to fill its container. The horizontal padding
20 comes at the line level so that background
21 colors extend edge to edge.
22*/
23pre.torchlight {
24 display: block;
25 min-width: -webkit-max-content;
26 min-width: -moz-max-content;
27 min-width: max-content;
28 padding-top: 1rem;
29 padding-bottom: 1rem;
30}
31 
32/*
33 Horizontal line padding to match the vertical
34 padding from the code block above.
35*/
36pre.torchlight .line {
37 padding-left: 1rem;
38 padding-right: 1rem;
39}
40 
41/*
42 Push the code away from the line numbers and
43 summary caret indicators.
44*/
45pre.torchlight .line-number,
46pre.torchlight .summary-caret {
47 margin-right: 1rem;
48}
49 
50/*********************************************
51* Focus styling *
52**********************************************/
53 
54/*
55 Blur and dim the lines that don't have the `.line-focus` class,
56 but are within a code block that contains any focus lines.
57*/
58.torchlight.has-focus-lines .line:not(.line-focus) {
59 transition: filter 0.35s, opacity 0.35s;
60 filter: blur(.095rem);
61 opacity: .65;
62}
63 
64/*
65 When the code block is hovered, bring all the lines into focus.
66*/
67.torchlight.has-focus-lines:hover .line:not(.line-focus) {
68 filter: blur(0px);
69 opacity: 1;
70}
71 
72/*********************************************
73* Collapse styling *
74**********************************************/
75 
76.torchlight summary:focus {
77 outline: none;
78}
79 
80/* Hide the default markers, as we provide our own */
81.torchlight details > summary::marker,
82.torchlight details > summary::-webkit-details-marker {
83 display: none;
84}
85 
86.torchlight details .summary-caret::after {
87 pointer-events: none;
88}
89 
90/* Add spaces to keep everything aligned */
91.torchlight .summary-caret-empty::after,
92.torchlight details .summary-caret-middle::after,
93.torchlight details .summary-caret-end::after {
94 content: " ";
95}
96 
97/* Show a minus sign when the block is open. */
98.torchlight details[open] .summary-caret-start::after {
99 content: "-";
100}
101 
102/* And a plus sign when the block is closed. */
103.torchlight details:not([open]) .summary-caret-start::after {
104 content: "+";
105}
106 
107/* Hide the [...] indicator when open. */
108.torchlight details[open] .summary-hide-when-open {
109 display: none;
110}
111 
112/* Show the [...] indicator when closed. */
113.torchlight details:not([open]) .summary-hide-when-open {
114 display: initial;
115}
116 
117/*********************************************
118* Additional styling *
119**********************************************/
120 
121/* Fix for disjointed horizontal scrollbars */
122.highlight div {
123 overflow-x: visible;
124}

I'll make sure that this CSS gets dynamically attached to any pages with a code block by adding this to the bottom of my layouts/partials/head.html:

<!-- syntax highlighting -->
{{ if (findRE "<pre" .Content 1) }}
{{ $syntax := resources.Get "css/torchlight.css" | minify }}
<link href="{{ $syntax.RelPermalink }}" rel="stylesheet">
{{ end }}

As a bit of housekeeping, I'm also going to remove the built-in highlighter configuration from my config/_default/markup.toml file to make sure it doesn't conflict with Torchlight:

1# config/_default/markup.toml
2[goldmark]
3 [goldmark.renderer]
4 hardWraps = false
5 unsafe = true
6 xhtml = false
7 [goldmark.extensions]
8 typographer = false
9 
-[highlight]
- anchorLineNos = true
- codeFences = true
- guessSyntax = true
- hl_Lines = ''
- lineNos = false
- lineNoStart = 1
- lineNumbersInTable = false
- noClasses = false
- tabwidth = 2
- style = 'monokai'
- 
10# Table of contents #
11# Add toc = true to content front matter to enable
12[tableOfContents]
13 endLevel = 5
14 ordered = false
15 startLevel = 3

Building

Now that the pieces are in place, it's time to start building!

Local

I like to preview my blog as I work on it so that I know what it will look like before I hit git push and let Netlify do its magic. And Hugo has been fantastic for that! But since I'm offloading the syntax highlighting to the Torchlight API, I'll need to manually build the site instead of relying on Hugo's instant preview builds.

There are a couple of steps I'll use for this:

  1. First, I'll source .env to load the TORCHLIGHT_TOKEN for the API.
  2. Then, I'll use hugo --minify --environment local -D to render my site into the public/ directory.
  3. Next, I'll call npx torchlight to parse the HTML files in public/, extract the content of any <pre>/<code> blocks, send it to the Torchlight API to work the magic, and write the formatted code blocks back to the existing HTML files.
  4. Finally, I use python3 -m http.server --directory public 1313 to serve the public/ directory so I can view the content at http://localhost:1313.

I'm lazy, though, so I'll even put that into a quick build.sh script to help me run local builds:

1#!/usr/bin/env bash
2# Quick script to run local builds
3source .env
4hugo --minify --environment local -D
5npx torchlight
6python3 -m http.server --directory public 1313

Now I can just make the script executable and fire it off:

chmod +x build.sh
./build.sh
Start building sites
hugo v0.111.3+extended linux/amd64 BuildDate=unknown VendorInfo=nixpkgs
 
| EN
-------------------+------
Pages | 202
Paginator pages | 0
Non-page files | 553
Static files | 49
Processed images | 0
Aliases | 5
Sitemaps | 1
Cleaned | 0
 
Total in 248 ms
Highlighting index.html
Highlighting 3d-modeling-and-printing-on-chrome-os/index.html
Highlighting 404/index.html
Highlighting about/index.html { ... }
 
+ + + O
o '
________________ _
\__(=======/_=_/____.--'-`--.___
\ \ `,--,-.___.----'
.--`\\--'../ |
'---._____.|] -0- |o
* | -0- -O-
' o 0 | '
. -0- . '
 
Did you really want to see the full file list?
 
Highlighting tags/vsphere/index.html
Highlighting tags/windows/index.html
Highlighting tags/wireguard/index.html
Highlighting tags/wsl/index.html
Writing to /home/john/projects/runtimeterror/public/abusing-chromes-custom-search-engines-for-fun-and-profit/index.html
Writing to /home/john/projects/runtimeterror/public/auto-connect-to-protonvpn-on-untrusted-wifi-with-tasker/index.html
Writing to /home/john/projects/runtimeterror/public/cat-file-without-comments/index.html { ... }
 
' * + -O- |
o o .
___________ 0 o .
+/-/_"/-/_/-/| -0- o -O- * *
/"-/-_"/-_//|| . -O-
/__________/|/| + | *
|"|_'='-]:+|/|| . o -0- . *
|-+-|.|_'-"||// + | | ' ' 0
|[".[:!+-'=|// | -0- 0 -O-
|='!+|-:]|-|/ -0- o |-0- 0 -O-
---------- * | -O| + o
o -O- -0- -0- -O-
| + | -O- |
-0- -0- . O
-O- | -O- *
your code will be assimilated
 
Writing to /home/john/projects/runtimeterror/public/k8s-on-vsphere-node-template-with-packer/index.html
Writing to /home/john/projects/runtimeterror/public/tanzu-community-edition-k8s-homelab/index.html
Serving HTTP on 0.0.0.0 port 1313 (http://0.0.0.0:1313/) ...
127.0.0.1 - - [07/Nov/2023 20:34:29] "GET /spotlight-on-torchlight/ HTTP/1.1" 200 -

Netlify

Setting up Netlify to leverage the Torchlight API is kind of similar. I'll start with logging in to the Netlify dashboard and navigating to Site Configuration > Environment Variables. There, I'll click on Add a variable > Add a ingle variable. I'll give the new variable a key of TORCHLIGHT_TOKEN and set its value to the token I obtained earlier.

Once that's done, I edit the netlify.toml file at the root of my site repo to alter the build commands:

1[build]
2 publish = "public"
3 
4[build.environment]
5 HUGO_VERSION = "0.111.3"
6 
7[context.production]
- command = "hugo"
+ command = """
+ hugo --minify
+ npm i @torchlight-api/torchlight-cli
+ npx torchlight
+ """

Now when I git push new content, Netlify will use Hugo to build the site, then install and call Torchlight to ++fancy; the code blocks before the site gets served. Very nice!

#Goals

Of course, I. Just. Can't. leave well enough alone, so my work here isn't finished - not by a long shot.

You see, I'm a sucker for handy "copy" buttons attached to code blocks, and that's not something that Torchlight does (it just returns rendered HTML, remember? No fancy JavaScript here). I also wanted to add informative prompt indicators (like $ and #) to code blocks representing command-line inputs (rather than script files). And I'd like to flag text returned by a command so that only the commands get copied, effectively ignoring the returned text, diff-removed lines, diff markers, line numbers, and prompt indicators.

I had previously implemented a solution based heavily on Justin James' blog post, Hugo - Dynamically Add Copy Code Snippet Button. Getting that Chroma-focused solution to work well with Torchlight-formatted code blocks took some work, particularly since I'm inept at web development and can barely spell "CSS" and "JavaScrapped".

But I5 eventually fumbled through the changes required to meet my #goals, and I'm pretty happy with how it all works.

Custom classes

Remember Torchlight's in-line annotations that I mentioned earlier? They're pretty capable out of the box, but can also be expanded through the use of custom classes. This makes it easy to selectively apply special handling to selected lines of code, something that's otherwise pretty dang tricky to do with Chroma.

So, for instance, I could add a class .cmd for standard user-level command-line inputs:

sudo make me a sandwich # [tl! .cmd]
sudo make me a sandwich

Or .cmd_root for a root prompt:

wall "Make your own damn sandwich." # [tl! .cmd_root]
wall "Make your own damn sandwich."

And for deviants:

Write-Host -ForegroundColor Green "A taco is a sandwich" # [tl! .cmd_pwsh]
Write-Host -ForegroundColor Green "A taco is a sandwich"

I also came up with a cleverly-named .nocopy class for the returned lines that shouldn't be copyable:

copy this # [tl! .cmd]
but not this # [tl! .nocopy]
copy this
but not this

So that's how I'll tie my custom classes to individual lines of code6, but I still need to actually define those classes.

I'll drop those at the bottom of the assets/css/torchlight.css file I created earlier:

1 { ... }
2/*********************************************
3* Basic styling for Torchlight code blocks. *
4**********************************************/
5 
6/*
7 Margin and rounding are personal preferences,
8 overflow-x-auto is recommended.
9*/
10pre {
11 border-radius: 0.25rem;
12 margin-top: 1rem;
13 margin-bottom: 1rem;
14 overflow-x: auto;
15}
16 
17/*
18 Add some vertical padding and expand the width
19 to fill its container. The horizontal padding
20 comes at the line level so that background
21 colors extend edge to edge.
22*/
23pre.torchlight {
24 display: block;
25 min-width: -webkit-max-content;
26 min-width: -moz-max-content;
27 min-width: max-content;
28 padding-top: 1rem;
29 padding-bottom: 1rem;
30}
31 
32/*
33 Horizontal line padding to match the vertical
34 padding from the code block above.
35*/
36pre.torchlight .line {
37 padding-left: 1rem;
38 padding-right: 1rem;
39}
40 
41/*
42 Push the code away from the line numbers and
43 summary caret indicators.
44*/
45pre.torchlight .line-number,
46pre.torchlight .summary-caret {
47 margin-right: 1rem;
48}
49 
50/*********************************************
51* Focus styling *
52**********************************************/
53 
54/*
55 Blur and dim the lines that don't have the `.line-focus` class,
56 but are within a code block that contains any focus lines.
57*/
58.torchlight.has-focus-lines .line:not(.line-focus) {
59 transition: filter 0.35s, opacity 0.35s;
60 filter: blur(.095rem);
61 opacity: .65;
62}
63 
64/*
65 When the code block is hovered, bring all the lines into focus.
66*/
67.torchlight.has-focus-lines:hover .line:not(.line-focus) {
68 filter: blur(0px);
69 opacity: 1;
70}
71 
72/*********************************************
73* Collapse styling *
74**********************************************/
75 
76.torchlight summary:focus {
77 outline: none;
78}
79 
80/* Hide the default markers, as we provide our own */
81.torchlight details > summary::marker,
82.torchlight details > summary::-webkit-details-marker {
83 display: none;
84}
85 
86.torchlight details .summary-caret::after {
87 pointer-events: none;
88}
89 
90/* Add spaces to keep everything aligned */
91.torchlight .summary-caret-empty::after,
92.torchlight details .summary-caret-middle::after,
93.torchlight details .summary-caret-end::after {
94 content: " ";
95}
96 
97/* Show a minus sign when the block is open. */
98.torchlight details[open] .summary-caret-start::after {
99 content: "-";
100}
101 
102/* And a plus sign when the block is closed. */
103.torchlight details:not([open]) .summary-caret-start::after {
104 content: "+";
105}
106 
107/* Hide the [...] indicator when open. */
108.torchlight details[open] .summary-hide-when-open {
109 display: none;
110}
111 
112/* Show the [...] indicator when closed. */
113.torchlight details:not([open]) .summary-hide-when-open {
114 display: initial;
115}
116 
117/*********************************************
118* Additional styling *
119**********************************************/
120 
121/* Fix for disjointed horizontal scrollbars */
122.highlight div {
123 overflow-x: visible;
124}
125 
126 
127Insert prompt indicators on interactive shells.
128*/
129.cmd::before {
130 color: var(--base07);
131 content: "$ ";
132}
133 
134.cmd_root::before {
135 color: var(--base08);
136 content: "# ";
137}
138 
139.cmd_pwsh::before {
140 color: var(--base07);
141 content: "PS> ";
142}
143 
144/*
145Don't copy shell outputs
146*/
147.nocopy {
148 webkit-user-select: none;
149 user-select: none;
150}

The .cmd classes will simply insert the respective prompt before each flagged line, and the .nocopy class will prevent those lines from being selected (and copied). Now for the tricky part...

Copy that blocky

There are two major pieces for the code-copy wizardry: the CSS to style/arrange the copy button and language label, and the JavaScript to make it work.

I put the CSS in assets/css/code-copy-button.css:

1/* adapted from https://digitaldrummerj.me/hugo-add-copy-code-snippet-button/ */
2 
3.highlight {
4 position: relative;
5 z-index: 0;
6 padding: 0;
7 margin:40px 0 10px 0;
8 border-radius: 4px;
9}
10 
11.copy-code-button {
12 position: absolute;
13 z-index: -1;
14 right: 0px;
15 top: -26px;
16 font-size: 13px;
17 font-weight: 700;
18 line-height: 14px;
19 letter-spacing: 0.5px;
20 width: 65px;
21 color: var(--fg);
22 background-color: var(--bg);
23 border: 1.25px solid var(--off-bg);
24 border-top-left-radius: 4px;
25 border-top-right-radius: 4px;
26 border-bottom-right-radius: 0px;
27 border-bottom-left-radius: 0px;
28 white-space: nowrap;
29 padding: 6px 6px 7px 6px;
30 margin: 0 0 0 1px;
31 cursor: pointer;
32 opacity: 0.6;
33}
34 
35.copy-code-button:hover,
36.copy-code-button:focus,
37.copy-code-button:active,
38.copy-code-button:active:hover {
39 color: var(--off-bg);
40 background-color: var(--off-fg);
41 opacity: 0.8;
42}
43 
44.copyable-text-area {
45 position: absolute;
46 height: 0;
47 z-index: -1;
48 opacity: .01;
49}
50 
51.torchlight [data-lang]:before {
52 position: absolute;
53 z-index: -1;
54 top: -26px;
55 left: 0px;
56 content: attr(data-lang);
57 font-size: 13px;
58 font-weight: 700;
59 color: var(--fg);
60 background-color: var(--bg);
61 border-top-left-radius: 4px;
62 border-top-right-radius: 4px;
63 border-bottom-left-radius: 0;
64 border-bottom-right-radius: 0;
65 padding: 6px 6px 7px 6px;
66 line-height: 14px;
67 opacity: 0.6;
68 position: absolute;
69 letter-spacing: 0.5px;
70 border: 1.25px solid var(--off-bg);
71 margin: 0 0 0 1px;
72}

And, as before, I'll link this from the bottom of my layouts/partial/head.html so it will get loaded on the appropriate pages:

<!-- syntax highlighting -->
{{ if (findRE "<pre" .Content 1) }}
{{ $syntax := resources.Get "css/torchlight.css" | minify }}
<link href="{{ $syntax.RelPermalink }}" rel="stylesheet">
+ {{ $copyCss := resources.Get "css/code-copy-button.css" | minify }}
+ <link href="{{ $copyCss.RelPermalink }}" rel="stylesheet">
{{ end }}

Code behind the copy

That sure makes the code blocks and accompanying button / labels look pretty great, but I still need to actually make the button work. For that, I'll need some JavaScript that (again) largely comes from Justin's post.

With all the different classes and things used with Torchlight, it took a lot of (generally misguided) tinkering for me to get the script to copy just the text I wanted (and nothing else). I learned a ton in the process, so I've highlighted the major deviations from Justin's script.

Anyway, here's my assets/js/code-copy-button.js:

1// adapted from https://digitaldrummerj.me/hugo-add-copy-code-snippet-button/
2 
3function createCopyButton(highlightDiv) {
4 const button = document.createElement("button");
5 button.className = "copy-code-button";
6 button.type = "button";
7 button.innerText = "Copy";
8 button.addEventListener("click", () => copyCodeToClipboard(button, highlightDiv));
9 highlightDiv.insertBefore(button, highlightDiv.firstChild);
10 const wrapper = document.createElement("div");
11 wrapper.className = "highlight-wrapper";
12 highlightDiv.parentNode.insertBefore(wrapper, highlightDiv);
13 wrapper.appendChild(highlightDiv);
14}
15 
16document.querySelectorAll(".highlight").forEach((highlightDiv) => createCopyButton(highlightDiv));
17 
18async function copyCodeToClipboard(button, highlightDiv) {
19 // capture all code lines in the selected block which aren't classed `nocopy` or `line-remove`
20 let codeToCopy = highlightDiv.querySelectorAll(":last-child > .torchlight > code > .line:not(.nocopy, .line-remove)");
21 // now remove the first-child of each line if it is of class `line-number`
22 codeToCopy = Array.from(codeToCopy).reduce((accumulator, line) => {
23 if (line.firstChild.className != "line-number") {
24 return accumulator + line.innerText + "\n"; }
25 else {
26 return accumulator + Array.from(line.children).filter(
27 (child) => child.className != "line-number").reduce(
28 (accumulator, child) => accumulator + child.innerText, "") + "\n";
29 }
30 }, "");
31 try {
32 var result = await navigator.permissions.query({ name: "clipboard-write" });
33 if (result.state == "granted" || result.state == "prompt") {
34 await navigator.clipboard.writeText(codeToCopy);
35 } else {
36 button.blur();
37 button.innerText = "Error!";
38 setTimeout(function () {
39 button.innerText = "Copy";
40 }, 2000);
41 }
42 } catch (_) {
43 button.blur();
44 button.innerText = "Error!";
45 setTimeout(function () {
46 button.innerText = "Copy";
47 }, 2000);
48 } finally {
49 button.blur();
50 button.innerText = "Copied!";
51 setTimeout(function () {
52 button.innerText = "Copy";
53 }, 2000);
54 }
55}

And this script gets called from the bottom of my layouts/partials/footer.html:

{{ if (findRE "<pre" .Content 1) }}
{{ $jsCopy := resources.Get "js/code-copy-button.js" | minify }}
<script src="{{ $jsCopy.RelPermalink }}"></script>
{{ end }}

Going live!

And at this point, I can just run my build.sh script again to rebuild the site locally and verify that it works as well as I think it does.

It looks pretty good to me, so I'll go ahead and push this up to Netlify. If all goes well, this post and the new code block styling will go live at the same time.

See you on the other side!


  1. Did I mention that it's fast? ↩︎

  2. (or how to count to eleven) ↩︎

  3. Spoiler: I'm going to tack on some JS and CSS nonsense later - we'll get to that. ↩︎

  4. Torchlight is free for sites which don't generate revenue, though it does require a link back to torchlight.dev. I stuck the attribution link in the footer. More pricing info here↩︎

  5. With a little help from my Copilot buddy... ↩︎

  6. Or ranges of lines, using the same syntax as before: [tl! .nocopy:5] will make this line and the following five uncopyable. ↩︎


runtimeterror


 jbowdre