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 completely unnecessary at this point).

The finished product can be seen/modified with the codepen below:

See the Pen Add Copy Button to Chroma (Hugo) Code Blocks by Aaron Luna (@a-luna) on CodePen.

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.
  • Instead of polyfilling the Clipboard API, my implementation falls back to using document.execCommand("copy") if it is unsupported.
  • "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;
}

.highlight {
  position: relative;
  z-index: 0;
  padding: 0;
  margin: 0;
  border-radius: 4px;
}

.highlight > .chroma {
  color: #d0d0d0;
  background-color: #212121;
  position: static;
  z-index: 1;
  border-radius: 4px;
  padding: 10px;
}

.chroma .lntd:first-child {
  padding: 7px 7px 7px 10px;
  margin: 0;
}

.chroma .lntd:last-child {
  padding: 7px 10px 7px 7px;
  margin: 0;
}

.copy-code-button {
  position: absolute;
  z-index: 2;
  right: 0;
  top: 0;
  font-size: 13px;
  font-weight: 700;
  line-height: 14px;
  letter-spacing: 0.5px;
  width: 65px;
  color: #232326;
  background-color: #7f7f7f;
  border: 1.25px solid #232326;
  border-top-left-radius: 0;
  border-top-right-radius: 4px;
  border-bottom-right-radius: 0;
  border-bottom-left-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 {
  color: #222225;
  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
56
57
58
59
60
61
function createCopyButton(highlightDiv) {
  const button = document.createElement("button");
  button.className = "copy-code-button";
  button.type = "button";
  button.innerText = "Copy";
  button.addEventListener("click", () => copyCodeToClipboard(button, highlightDiv));
  addCopyButtonToDom(button, highlightDiv);
}

async function copyCodeToClipboard(button, highlightDiv) {
  const codeToCopy = highlightDiv.querySelector(":last-child > .chroma > code").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, highlightDiv);
    }
  } catch (_) {
    copyCodeBlockExecCommand(codeToCopy, highlightDiv);
  }
  finally {
    codeWasCopied(button);
  }
}

function copyCodeBlockExecCommand(codeToCopy, highlightDiv) {
  const textArea = document.createElement("textArea");
  textArea.contentEditable = 'true'
  textArea.readOnly = 'false'
  textArea.className = "copyable-text-area";
  textArea.value = codeToCopy;
  highlightDiv.insertBefore(textArea, highlightDiv.firstChild);
  const range = document.createRange()
  range.selectNodeContents(textArea)
  const sel = window.getSelection()
  sel.removeAllRanges()
  sel.addRange(range)
  textArea.setSelectionRange(0, 999999)
  document.execCommand("copy");
  highlightDiv.removeChild(textArea);
}

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

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

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

So what is happening here? Whenever a page is loaded, all <div class="highlight"> elements are located and a “Copy” button is created for each. Then, the copyCodeToClipboard function is assigned as the event handler for the button’s click event (Lines 2-6). Finally, some DOM manipulation is performed by calling addCopyButtonToDom. Let’s examine how this function works:

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

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

  • Line 56-57: 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 13-14: Within the try/catch block, we first check if the clipboard-write permission has been granted.

  • Line 15: 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 17: If the browser supports the Clipboard API but the clipboard-write permission has not been granted, we call copyCodeBlockExecCommand.

  • Line 20: 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 23: 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 28-31: 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 32: We set the value of the textarea element to be equal the text the user wishes to copy.

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

  • Line 34-39: While testing my code, I found that it worked correctly on all browsers on desktop. However, on my iPhone the text wasn't being copied. I researched this issue and found the steps performed in these lines are needed.

  • Line 40-41: 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!