Hugo: Add Copy-to-Clipboard Button to Code Blocks with Vanilla JS
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:
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 callingappendChild
.
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 callingnavigator.clipboard.writeText
. -
Line 17: If the browser supports the Clipboard API but the
clipboard-write
permission has not been granted, we callcopyCodeBlockExecCommand
. -
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 thecatch
block we callcopyCodeBlockExecCommand
. -
Line 23: Code within a
finally
block is called after eithertry
/catch
, regardless of result. Since the "copy" operation was invoked in eithertry
/catch
block, we callcodeWasCopied
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 callingdocument.execCommand("copy")
, which copies the text we assigned to thetextarea
element to the clipboard. After doing so, thetextarea
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!