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 format instead of stream.

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:

  1. Create a new .rb plugin file in _plugins.
  2. Inherit the Rouge::Formatters::HTML class
  3. Override the stream method to inject the button HTML.
  4. Register a Jekyll hook to replace the default formatter with your custom one.
  5. Add JavaScript to handle the copy functionality.
  6. Style the button with CSS.
  7. 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).

Happy coding! </>