Hugo: Add Copy-to-Clipboard Button to Code Blocks with Vanilla JS

Hugo: Add Copy-to-Clipboard Button to Code Blocks with Vanilla JS

Photo by Natalia Y on Unsplash

Hugo includes a built-in syntax-highlighter called Chroma. Chroma is extremely fast since it is written in pure Go (like Hugo) and supports every language I can think of. Chroma’s speed is especially important since syntax highlighters are notorious for causing slow page loads. However, it lacks one vital feature — an easy way to copy a code block to the clipboard. I decided to document my implementation using only vanilla JS (every blog post I found for this issue relied on jquery to parse the DOM, which is a shame. We can do better, people).

A quick search led me to this post on Danny Guo’s blog. I used his example as my starting point but made several changes:

  • The "copy" button is placed within the code block rather than outside it.
  • My implementation uses the Clipboard API if the user's browser supports it. However, since it is not yet widely supported, my script falls back to using document.execCommand("copy") if it is unsupported, or if the clipboard-write permission has not been granted.
  • "Copy" buttons are only added to code elements that are generated by Chroma.

The Hugo highlight shortcode accepts a line-nos parameter. If line-nos is not specified or line-nos=inline, the rendered HTML has this structure:

<div class="highlight">
  <pre class="chroma">
    <code class="language-xxxx">
      (the code we wish to copy)
    </code>
  </pre>
</div>

If line-nos=table, the HTML is slightly more complicated:

<div class="highlight">
  <div class="chroma">
    <table class="lntable">
      <tbody>
        <tr>
          <td class="lntd">
            (line numbers are rendered here)
          </td>
          <td class="lntd">
            <pre class="chroma">
              <code class="language-xxxx">
                (the code we wish to copy)
              </code>
            </pre>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</div>

I use the version with line numbers much more often than the version without, so it is important to me to support both. I decided to place the button inside the <div class="highlight"> element. Stacking elements on top of one another requires positioning and assigning z-index values, which you can see below along with the styling for the “copy” button:

.highlight-wrapper {
  display: block;
  margin: 0 0 1em 0;
}

.highlight {
  position: relative;
  z-index: 0;
  padding: 0;
  margin: 0;
}

.highlight > .chroma {
  position: static;
  z-index: 1;
}

.copy-code-button {
  position: absolute;
  z-index: 2;
  right: 0;
  top: 0;
  font-size: 14px;
  font-weight: 700;
  line-height: 14px;
  letter-spacing: 0.5px;
  width: 60px;
  color: #232326;
  background-color: #7f7f7f;
  border: 1.25px solid #232326;
  border-radius: 4px;
  white-space: nowrap;
  padding: 4px 4px 5px 4px;
  margin: 0 0 0 1px;
  cursor: pointer;
  opacity: 0.6;
}

.copy-code-button:hover,
.copy-code-button:focus,
.copy-code-button:active,
.copy-code-button:active:hover {
  background-color: #b3b3b3;
  opacity: 0.8;
}

.copyable-text-area {
  position: absolute;
  height: 0;
  z-index: -1;
  opacity: .01;
}

Did you notice that the CSS includes a selector for a highlight-wrapper class that is not present in the HTML structure generated by Chroma? We will create this element and append the positioned elements as a child node, then insert the wrapper into the DOM in place of the <div class="highlight"> element.

Similarly, the copyable-text-area class will be applied to a textarea element that will only exist if the Clipboard API is not available. This element will be added to the DOM and have it’s value set to the innerText value of the code we wish to copy. After copying the text, the textarea element will be removed fom the DOM. The height: 0 and opacity: .01 stylings make it virtually invisible, and z-index: -1 places it behind the code block.

With that in mind, let’s take a look at the JavaScript:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
function createCopyButton(highlight) {
  const button = document.createElement("button");
  button.className = "copy-code-button";
  button.type = "button";
  button.innerText = "Copy";
  button.addEventListener("click", () => copyCodeToClipboard(button, highlight));
  addCopyButtonToDom(button, highlight);
}

async function copyCodeToClipboard(button, highlight) {
  const codeElement = highlight.querySelector(":last-child > .chroma > code");
  const codeToCopy = codeElement.innerText;
  try {
    result = await navigator.permissions.query({ name: "clipboard-write" });
    if (result.state == "granted" || result.state == "prompt") {
      await navigator.clipboard.writeText(codeToCopy);
    } else {
      copyCodeBlockExecCommand(codeToCopy, highlight);
    }
  } catch (_) {
    copyCodeBlockExecCommand(codeToCopy, highlight);
  }
  finally {
    codeWasCopied(button);
  }
}

function copyCodeBlockExecCommand(codeToCopy, highlight) {
  const textArea = document.createElement("textArea");
  textArea.className = "copyable-text-area";
  textArea.value = codeToCopy;
  highlight.insertBefore(textArea, highlight.firstChild);
  textArea.select();
  document.execCommand("copy");
  highlight.removeChild(textArea);
}

function codeWasCopied(button) {
  button.blur();
  button.innerText = "Copied!";
  setTimeout(function() {
    button.innerText = "Copy";
  }, 2000);
}

function addCopyButtonToDom(button, highlight) {
  highlight.insertBefore(button, highlight.firstChild);
  const wrapper = document.createElement("div");
  wrapper.className = "highlight-wrapper";
  highlight.parentNode.insertBefore(wrapper, highlight);
  wrapper.appendChild(highlight);
}

document.querySelectorAll(".highlight")
  .forEach(highlight => createCopyButton(highlight));

So what does this script do? When the page has fully loaded, a “Copy” button is created for each <div class="highlight"> element and the copyCodeToClipboard function is assigned as the event handler for the button’s click event (Lines 2-6). Then, addCopyButtonToDom is called. Let’s examine how this function works:

  • Line 47: First, the "Copy" button is inserted into the DOM as the first child of the <div class="highlight"> element.

  • Line 48-49: We create a div element to act as a wrapper for the <div class="highlight"> element and assign the appropriate styling.

  • Line 50-51: Finally, the wrapper element is inserted into the DOM in the same location as the <div class="highlight"> element, and the <div class="highlight"> element is "wrapped" by calling appendChild.

When the user clicks a “Copy” button, the copyCodeToClipboard function is called. Since the logic that determines which copy function to use may not seem intuitive, let’s go through it together:

  • Lines 14-15: Within the try/catch block, we first check if the clipboard-write permission has been granted.

  • Line 16: If the browser supports the Clipboard API and the clipboard-write permission has been granted, the text within the code block is copied to the clipboard by calling navigator.clipboard.writeText.

  • Line 18: If the browser supports the Clipboard API but the clipboard-write permission has not been granted, we call copyCodeBlockExecCommand.

  • Lines 21: If the browser does not support the Clipboard API, an error will be raised and caught. Since this is an expected failure, the error is not re-thrown, and the same action that is performed when the browser supports the Clipboard API but the clipboard-write permission has not been granted is executed — within the catch block we call copyCodeBlockExecCommand.

  • Line 24: Code within a finally block is called after either try/catch, regardless of result. Since the "copy" operation was invoked in either try/catch block, we call codeWasCopied which changes the button text to "Copied!". After two seconds the button text is changed back to "Copy".

  • Line 29-30: When the Clipboard API is unsupported/permission is not granted, we create a textarea element and assign the appropriate styling to make it hidden from the user but still available programmatically.

  • Line 31: We set the value of the textarea element to be equal the text the user wishes to copy.

  • Line 32: The textarea element is temporarily aded to the DOM next to the copy button.

  • Line 33-35: The textarea element is selected before calling document.execCommand("copy"), which copies the text we assigned to the textarea element to the clipboard. After doing so, the textarea element is removed from the DOM.

On this site, the JavaScript in the code block above is bundled with other js files and minified. If you’d like, you can verify the code and debug it using your browser’s dev tools on any page that contains a code block (the easiest way would be to inspect the copy button, find the event listeners attached to it and add breakpoints to the method attached to the click handler). I hope this is helpful to you if you use Hugo and have run into the same problem, please leave any feedback/questions in the comments below!