If you Google “Add copy button to code blocks in Jekyll” you will find a lot of solutions that involve adding JavaScript to your site to dynamically add a copy button to each code block. But what if you don’t want to use JavaScript? I meant you’d still need JavaScript for the Clipboard API to actually copy the code, but at least you won’t need to manipulate the DOM with JavaScript.
When I launched this website, I knew at some point I would be sharing code snippets and when that moment came of course like any other friendly developer I wanted to make it easy for users to copy the code.
So like any other friendly developer I started Googling.. I mean just go ahead and Google it yourself, thousands and thousands of results, all with jQuery’s $('div.highlighter-rouge').each(...) or vanilla JS document.querySelectorAll("div.highlighter-rouge").forEach(...) to dynamically add a button to each code block.
But wait, I don’t want to use JavaScript!
Well, I mean you still need JavaScript to actually copy the code to the clipboard, but at least you won’t need to manipulate the DOM with JavaScript.
I mean I already knew that Jekyll uses Rouge (from when I was styling the blocks) and one of the first things I picked up in Jekyll was that, in Ruby, everything is a class and you can easily override classes and manipulate specific methods, so Rouge has to have a class that actually parses and generates the HTML for code blocks so they look that good when rendered.
So I went digging and surprise surprise, Rouge 4.6.0 has a class ..
Well, kinda, so the way Rouge works is that it has a the Formatters module with contains a bunch of formatters, each one of them inheriting from the Formatter class, and one of those formatters is the HTML formatter which is the one that generates the HTML for code blocks.
So each formatter has a method called stream (probably it used to be called format in older versions) that takes the tokens generated by the lexer and generates the HTML.
If you are using Rouge < 4.0.0, the method is called
formatinstead ofstream.
With a bit of debugging, trial & error, I got the hang of it and decided to override the HTML formatter’s output to add a button right after the opening <pre> tag.
Please note that I’m not a Ruby developer, I just know enough to get by and read the documentation, so if you find any mistakes or better ways to do this, please let me know!
So without further ado, here’s how to add a copy button to code blocks in Jekyll (or Rouge) JavaScript to manipulate the DOM.
Step 1: Override the Rouge HTML formatter
To do this I created a new file in the _plugins folder called rouge_copy_button.rb
That would be the plugin file that Jekyll will load and execute when building the site.
Of course first things first .. we need to require Rouge!
require "rouge"
At first I thought I can just take over the HTML formatter class, but that would be a bad idea because that means I would be losing all the functionality of the original HTML formatter, so instead I created a new class that inherits from the original HTML formatter.
# store original formatter class reference before patching
OriginalHTMLFormatter = Rouge::Formatters::HTML
class Rouge::Formatters::HTMLWithCopyButton < OriginalHTMLFormatter
puts "Initializing Rouge::Formatters::HTMLWithCopyButton"
end
Step 2: Hooking into Jekyll
Now we need to hook into the Jekyll build process and tell it (and Rouge) that we have something special going on here.
Jekyll::Hooks.register :site, :after_init do |_site|
puts "Registering Rouge::Formatters::HTMLWithCopyButton as the default formatter"
Rouge::Formatters::HTML = Rouge::Formatters::HTMLWithCopyButton
end
Remember when I said I’m not a Ruby developer? see that mistake up there? I mean even an HTML developer would have caught that, right? I mean Rouge::Formatters::HTML is a constant, you can’t just reassign it like that, that’s when the terminal started screaming at me.. so the correct way to do it is:
Jekyll::Hooks.register :site, :after_init do |_site|
puts "Registering Rouge::Formatters::HTMLWithCopyButton as the default formatter"
Rouge::Formatters.send(:remove_const, :HTML)
Rouge::Formatters.const_set(:HTML, Rouge::Formatters::HTMLWithCopyButton)
end
not that I just became a Ruby developer.. I just ChatGPT-ed it! but honestly I’m starting to like Ruby.
So what that did is that it registered a hook that will be called after Jekyll initializes the site, and in that hook we unset the HTML constant from the Rouge::Formatters module and set it to our new HTMLWithCopyButton class. Pretty neat, right?
Step 3: Override the stream method
Now that we have our setup ready, we need to implement our own stream method inside our class:
def stream(tokens)
end
The tokens parameter is an enumerable of tokens generated by the lexer _ I don’t know what that is yet, but I know it’s important _ and I’m not in the middle of a deep dive into Rouge’s internals and/or Ruby’s workings, I just want to manipulate the HTML output a bit. So..
def stream(tokens)
# First off we call the original stream method to get the HTML output
html = super(tokens)
# only add the button if it contains a <pre>
if html.include?("<pre")
# inject button right after the opening <pre> tag and before the <code> tag
html.sub!("<code>", "<button class='copy-code' aria-label='Copy code'>Copy</button><code>")
# and get out of here!
end
# Of course, return the modified HTML
html
end
That’s it! now whenever Jekyll builds the site, it will use our custom formatter, which was pretty cool to do!
Here’s how the whole class looks like:
require "rouge"
# store original formatter class reference before patching
OriginalHTMLFormatter = Rouge::Formatters::HTML
class Rouge::Formatters::HTMLWithCopyButton < OriginalHTMLFormatter
puts "Initializing Rouge::Formatters::HTMLWithCopyButton"
def stream(tokens)
puts "Formatting code block, length=#{html.length}"
# First off we call the original stream method to get the HTML output
html = super(tokens)
# only add the button if it contains a <pre>
if html.include?("<pre")
puts "Adding copy button to code block"
# inject button right after the opening <pre> tag and before the <code> tag
html.sub!("<code>", "<button class='copy-code' aria-label='Copy code'>Copy</button><code>")
end
# Of course, return the modified HTML
html
end
end
Jekyll::Hooks.register :site, :after_init do |_site|
puts "Registering Rouge::Formatters::HTMLWithCopyButton as the default formatter"
Rouge::Formatters.send(:remove_const, :HTML)
Rouge::Formatters.const_set(:HTML, Rouge::Formatters::HTMLWithCopyButton)
end
Step 4: Add the button copy functionality .. back to JavaScript
Now that we have the button in place, we need to add the functionality to copy the code snippets to the clipboard.
That’s straightforward:
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("button.copy-code").forEach((button) => {
button.addEventListener("click", async () => {
const codeBlock = button.nextElementSibling;
if (codeBlock && codeBlock.tagName === "CODE") {
try {
await navigator.clipboard.writeText(codeBlock.innerText);
button.setAttribute("aria-label", "Code copied to clipboard");
button.classList.add("copied");
setTimeout(() => {
button.setAttribute("aria-label", "Copy code");
button.classList.remove("copied");
}, 2000);
} catch (err) {
console.error("Failed to copy code: ", err);
button.setAttribute("aria-label", "Failed to copy code");
button.classList.add("error");
setTimeout(() => {
button.setAttribute("aria-label", "Copy code");
button.classList.remove("error");
}, 2000);
}
}
});
});
});
Now it’s supposed to work! (sorry if it doesn’t.. please let me know why)
So to summarize, here’s how to add a copy button to code blocks in Jekyll without using JavaScript to manipulate the DOM:
- Create a new
.rbplugin file in_plugins. - Inherit the
Rouge::Formatters::HTMLclass - Override the
streammethod to inject the button HTML. - Register a Jekyll hook to replace the default formatter with your custom one.
- Add JavaScript to handle the copy functionality.
- Style the button with CSS.
- Enjoy your new copy button!
Bonus: Copy Button WebComponent
Since I started developing a taste for WebComponents, I thought this would make the perfect use case for a Copy Button WebComponent and that’s what I’m using right now! Here’s the barebones code for the Copy Button WebComponent:
<script type="module">
if (typeof window !== "undefined" && !customElements.get("copy-button")) {
class CopyButton extends HTMLElement {
constructor() {
super();
this._timer = null;
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
:host {
position: absolute;
top: 0;
right: 0;
cursor: copy;
font: 11px system-ui, sans-serif;
}
:host(:hover) { opacity: 1; }
:host([state=ok]) { background: green; }
:host([state=err]) { background: red; }
</style>
<span part="label">Copy</span>
`;
this._label = shadow.querySelector("span");
}
connectedCallback() {
this.addEventListener("click", () => this.copy());
this.addEventListener("keydown", e => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); this.copy(); }
});
this.tabIndex = 0;
}
async copy() {
const pre = this.closest("pre");
const code = pre?.querySelector("code") || this.closest("code");
const text = code?.innerText ?? "";
if (!text) return this._feedback(false, "Error");
try {
await navigator.clipboard.writeText(text);
this._feedback(true, "✓");
} catch {
this._feedback(false, "×");
}
}
_feedback(ok, char) {
this.setAttribute("state", ok ? "ok" : "err");
this._label.textContent = char;
clearTimeout(this._timer);
this._timer = setTimeout(() => {
this.removeAttribute("state");
this._label.textContent = "Copy";
}, 1200);
}
}
customElements.define("copy-button", CopyButton);
}
</script>
If you would like a more comprehensive version with multi use cases and accessibility features.. hit F12 and checkout #copy-button-module-script, here’s a console select command you can use to copy it:
document.getElementById("copy-button-module-script");
If you got this far, thank you for reading! I hope you found this useful and if you have any questions or suggestions, please let me know in the comments below or via the (contact form).
No comments yet.
Note: I welcome all comments, including negative ones, but I reserve the right to moderate for spam, abuse, hate speech, and other inappropriate content. Disclaimer: Comments are moderated and may take some time to appear. I do not endorse the views expressed in comments. By submitting a comment, you agree to the privacy policy and consent to the handling of your data as described therein.