Danny Guo | 郭亚东

How to Add Copy to Clipboard Buttons to Code Blocks in Hugo

 ·  965 words  ·  ~5 minutes to read

A small quality of life improvement for programming-related websites is to add copy to clipboard buttons to code blocks. When a visitor wants to copy a code example or a shell command, it’s nice to be able to just hit a button rather than manually select the text, right click, and press copy.

I use Hugo to build my personal website. While Hugo has built-in support for syntax highlighting, it doesn’t support copy buttons. Here is how I added the feature to my website. The end result looks like this:

code block with a copy button

Adding the buttons

I inspected the source of a page with code blocks and found that Hugo generates each block with markup like this:

<div class="highlight">
    <pre>
        <code>...</code>
    </pre>
</div>

Code blocks without syntax highlighting have the same structure but without the surrounding highlight div. To account for both cases, I selected for code elements that are children of pre elements.

document.querySelectorAll('pre > code').forEach(function (codeBlock) {
    var button = document.createElement('button');
    button.className = 'copy-code-button';
    button.type = 'button';
    button.innerText = 'Copy';

    var pre = codeBlock.parentNode;
    if (pre.parentNode.classList.contains('highlight')) {
        var highlight = pre.parentNode;
        highlight.parentNode.insertBefore(button, highlight);
    } else {
        pre.parentNode.insertBefore(button, pre);
    }
});

For many implementations of copy code buttons that I’ve seen, the button is located in the top right or bottom right corner of the code block. However, I’ve noticed that the button can cover up some of the code if the line is too long, especially on mobile. To avoid this possibility, I placed each button before the entire code block.

Some implementations only show the button when the user hovers over the code block, but for discoverability, I left the buttons always visible.

For styling the buttons, I used this CSS:

.copy-code-button {
    color: #272822;
    background-color: #FFF;
    border-color: #272822;
    border: 2px solid;
    border-radius: 3px 3px 0px 0px;

    /* right-align */
    display: block;
    margin-left: auto;
    margin-right: 0;

    margin-bottom: -2px;
    padding: 3px 8px;
    font-size: 0.8em;
}

.copy-code-button:hover {
    cursor: pointer;
    background-color: #F2F2F2;
}

.copy-code-button:focus {
    /* Avoid an ugly focus outline on click in Chrome,
       but darken the button for accessibility.
       See https://stackoverflow.com/a/25298082/1481479 */
    background-color: #E6E6E6;
    outline: 0;
}

.copy-code-button:active {
    background-color: #D9D9D9;
}

.highlight pre {
    /* Avoid pushing up the copy buttons. */
    margin: 0;
}

Interacting with the clipboard

Next, I investigated how to copy to the clipboard using JavaScript. The most popular library for doing so is clipboard.js, but I wanted to avoid bringing in a dependency if possible.

One way is to use execCommand with document.execCommand('copy'), which copies the current text selection. Under the hood, clipboard.js uses this method.

However, there is a newer approach, the Clipboard API. It has several advantages: it’s asynchronous, takes arbitrary text/data (so it doesn’t have to already exist on the page), and has a better story for dealing with permissions. Chrome, Firefox, and Opera support it already. For other browsers, there is a polyfill.

I put the code in a function and added a click handler. I used innerText to get the code to be copied. After the copy operation, the button displays either an error message or a success message that lasts for two seconds.

function addCopyButtons(clipboard) {
    document.querySelectorAll('pre > code').forEach(function (codeBlock) {
        var button = document.createElement('button');
        button.className = 'copy-code-button';
        button.type = 'button';
        button.innerText = 'Copy';

        button.addEventListener('click', function () {
            clipboard.writeText(codeBlock.innerText).then(function () {
                /* Chrome doesn't seem to blur automatically,
                   leaving the button in a focused state. */
                button.blur();

                button.innerText = 'Copied!';

                setTimeout(function () {
                    button.innerText = 'Copy';
                }, 2000);
            }, function (error) {
                button.innerText = 'Error';
            });
        });

        var pre = codeBlock.parentNode;
        if (pre.parentNode.classList.contains('highlight')) {
            var highlight = pre.parentNode;
            highlight.parentNode.insertBefore(button, highlight);
        } else {
            pre.parentNode.insertBefore(button, pre);
        }
    });
}

Next, I added a check for whether or not the browser supports the Clipboard API. If not, the script loads the polyfill from CDNJS.

if (navigator && navigator.clipboard) {
    addCopyButtons(navigator.clipboard);
} else {
    var script = document.createElement('script');
    script.src = 'https://cdnjs.cloudflare.com/ajax/libs/clipboard-polyfill/2.7.0/clipboard-polyfill.promise.js';
    script.integrity = 'sha256-waClS2re9NUbXRsryKoof+F9qc1gjjIhc2eT7ZbIv94=';
    script.crossOrigin = 'anonymous';
    script.onload = function() {
        addCopyButtons(clipboard);
    };

    document.body.appendChild(script);
}

After the Clipboard API becomes ubiquitous, I’ll remove the polyfill code.

Smart loading with Hugo

After I got the functionality to work, I thought about how to include the script. I had three options. The first was to indiscriminately include it on every page. The script is small, but for optimization, I wanted to only include it when it’s actually needed, saving a bit of bandwidth and a network request (or two, if the polyfill is needed).

The second option was to use a custom Hugo front matter variable. With this method, I’d set a flag on every post that has a code block. The template could then check for this flag. However, this approach involves manual work and runs the risk of me forgetting to do it.

The third option was to find a way to use Hugo to figure out which pages have at least one code block. A regex seemed like the way to go. I used Hugo’s findRE function to determine if the HTML seems to contain a pre element.

{{ if (findRE "<pre" .Content 1) }}
    <script src="/js/copy-code-button.js"></script>
{{ end }}

I passed it a limit parameter of 1 because I only care if the page has a code block or not, not the total number of code blocks.

Keep in mind that this script should be loaded after the page content, preferably at the end of the body so that it doesn’t block rendering. Otherwise, the selector might run before the code blocks actually exist.

Non-Hugo websites

This solution should easily work for non-Hugo websites as well. The only part of the script that is specific to Hugo is the pre > code selector. Modifying the selector and possibly where the button is inserted should be all that is needed.

CodeCopy

CodeCopy is a browser extension for Chrome and Firefox that adds copy buttons to code blocks on many websites that are likely to have them, such as GitHub and Stack Overflow. It’s made by the same person behind clipboard.js.


← Animated Multiline Link Underlines with CSS Automating TurboTax Data Entry With Puppeteer →

Follow me on Twitter or Mastodon or subscribe to my newsletter or RSS feed for future posts.

Found an error or typo? Feel free to open a pull request on GitHub.