davenicholson.xyz

Tue May 28, 2024

A Copy Code Button for Hugo

During the setup of this blog I knew it was probably going to be code heavy. That’s what I do! As it is Hugo code blocks do not have built in copy button. There are solutions out there but for things like this I’m a big fan of rolling my own. You can find the complete code on my github.

Adding the button

The first thing we will need is a button to press. This is done by finding all of the code blocks on the page. Hugo’s code blocks are nested within a pre tag so this is what is targeted with a query selector. This list of code blocks can be iterated over and a new button appended to each pre node.

const codeblocks = document.querySelectorAll("pre:has(code)");

codeblocks.forEach((codeblock) => {
  let btn = document.createElement("button");
  btn.innerText = "copy";
  codeblock.appendChild(btn);
});

Good start but looks really boring. It would look better with an icon.

Adding SVG Icons to the button with javascript

Unfortunately we can’t just pass an SVG string into the button’s innerHTML. We need to convert the string to an element. I used the DOMParser to parse the string into an XML document ready to be inserted into the DOM.

const SVG2HTMLElement = (svgString) => {
  const parser = new DOMParser();
  const doc = parser.parseFromString(svgString, "image/svg+xml");
  const svgElement = doc.documentElement;
  return svgElement;
};

The copy icon SVG string can now be passed to the SVG2HTMLElement function and have a DOM element returned.

const copy_svg = `<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M3 3a1 1 0 0 1 1-1h12a1 1 0 1 1 0 2H5v12a1 1 0 1 1-2 0zm4 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a3 3 0 0 1-3 3h-8a3 3 0 0 1-3-3z" clip-rule="evenodd"/></svg>`;
let copy_icon = SVG2HTMLElement(copy_svg);

const codeblocks = document.querySelectorAll("pre:has(code)");

codeblocks.forEach((codeblock) => {
  let btn = document.createElement("button");
  btn.classList.add("codecopy");
  btn.appendChild(copy_icon.cloneNode(true));
  codeblock.appendChild(btn);
});

The copy icon needs to be appended with the cloneNode method otherwise the icon will be removed from the previous element before appending to the next.

And some styling to make it pretty and place it in the top right corner.

/* pre needs to be relative to place button */
pre:has(code) {
  position: relative;
}

.codecopy {
  position: absolute;
  top: 8px;
  right: 8px;
  background: #3c3c3c;
  color: #e4e4e4;
  border: 1px solid #e4e4e4;
  border-radius: 5px;
  opacity: 0.7;
  padding: 4px;
}

.codecopy:hover {
  opacity: 1;
  cursor: pointer;
}

Copy on click

Now an event listener is added to handle clicking the button, firing a function to write the contents of the code block to the clipboard. This uses the browsers navigator API to write to the system clipboard.

const copyCode = async (el) => {
  let code = el.querySelector("code");
  let text = code.innerText;
  await navigator.clipboard.writeText(text);
};

codeblocks.forEach((codeblock) => {
  // ...
  btn.addEventListener("click", async () => {
    await copyCode(codeblock);
});

This works well but because of the way the code is formatted in the code block (every word is a span with the colour scheme applied) this creates additional line breaks after every line, so a function is needed to parse the copies code and remove the additional line breaks.

const copyCode = async (el) => {
  let code = el.querySelector("code");
  let text = cleanCopy(code.innerText); // Call the new cleanCopy function
  await navigator.clipboard.writeText(text);
};

const cleanCopy = (str) => {
  // Split on new line
  const lines = str.split("\n"); // Split on new line
  let result = []; // New array to hold the lines to be kept
  for (let i = 0; i < lines.length; i++) {
    // If the line is not empty then add to array
    if (lines[i].trim() !== "") {
      result.push(lines[i]);
    } else {
      // If the line is empty but is followed by another empty line
      // Keep it as it is a genuine new line then skip the next line
      if (i + 1 < lines.length && lines[i + 1].trim() === "") {
        result.push("");
        i++;
      }
    }
  }
  return result.join("\n");
};

Feedback after copying

It would be helpful to show that the code has been copied once the button is pressed, so let’s change the icon and colour of the button when it’s pressed. Another SVG is required for the copied icon.

const copied_svg = `<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 24 24"><path fill="currentColor" d="M18.333 6A3.667 3.667 0 0 1 22 9.667v8.666A3.667 3.667 0 0 1 18.333 22H9.667A3.667 3.667 0 0 1 6 18.333V9.667A3.667 3.667 0 0 1 9.667 6zM15 2c1.094 0 1.828.533 2.374 1.514a1 1 0 1 1-1.748.972C15.405 4.088 15.284 4 15 4H5c-.548 0-1 .452-1 1v9.998c0 .32.154.618.407.805l.1.065a1 1 0 1 1-.99 1.738A3 3 0 0 1 2 15V5c0-1.652 1.348-3 3-3zm1.293 9.293L13 14.585l-1.293-1.292a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l4-4a1 1 0 0 0-1.414-1.414"/></svg>`;
let copied_icon = SVG2HTMLElement(copied_svg);

After the code is copied to the clipboard lets switch the icon and add a class to the button. The original icon should then revert back after a set amount of time.

codeblocks.forEach((codeblock) => {
  let btn = document.createElement("button");
  btn.classList.add("codecopy");
  btn.appendChild(copy_icon.cloneNode(true));

  codeblock.appendChild(btn);

  btn.addEventListener("click", async () => {
    await copyCode(codeblock);
    btn.classList.add("copied");
    btn.replaceChildren(copied_icon.cloneNode(true));

    setTimeout(() => {
      btn.classList.remove("copied");
      btn.replaceChildren(copy_icon.cloneNode(true));
    }, 1500);
  });
});
// CSS for copied style
.codecopy.copied {
  color: #3cb371;
  border: 1px solid #3cb371;
}

And after all that we have a simple button to correctly copy Hugo code blocks to the clipboard. Grab it on github and link the JS and CSS files in you HMTL head. :)