Als je Googelt op “Copy-knop toevoegen aan codeblokken in Jekyll” vind je honderden oplossingen die allemaal JavaScript gebruiken om dynamisch een knop aan elk codeblok toe te voegen.
Maar wat als je geen JavaScript wilt gebruiken? Oké, je hebt nog steeds JavaScript nodig voor de Clipboard API om daadwerkelijk te kunnen kopiëren, maar je hoeft in elk geval niet het hele DOM te manipuleren.

Toen ik deze website lanceerde, wist ik dat ik vroeg of laat codefragmenten zou delen. En toen dat moment kwam, wilde ik — zoals elke vriendelijke developer — het natuurlijk makkelijk maken voor bezoekers om de code te kopiëren.

Dus, zoals elke vriendelijke developer, begon ik te Googelen… En echt, probeer het maar zelf: duizenden resultaten, allemaal met jQuery’s $('div.highlighter-rouge').each(...) of vanilla JS document.querySelectorAll("div.highlighter-rouge").forEach(...) om dynamisch een knop toe te voegen aan elk codeblok.

Maar wacht, ik wil geen JavaScript gebruiken!

Nou ja, je hebt JavaScript nog steeds nodig om de code naar het klembord te kopiëren, maar je hoeft niet het DOM aan te raken met JavaScript.

Ik wist al dat Jekyll Rouge gebruikt (kwam ik achter toen ik de blokken aan het stijlen was). En één van de eerste dingen die ik over Jekyll leerde, is dat in Ruby alles een class is — en dat je die classes vrij makkelijk kunt overriden of uitbreiden.
Dus Rouge moest wel een class hebben die de HTML genereert voor codeblokken, toch?

Dus ik ben gaan graven… en jawel, Rouge 4.6.0 heeft inderdaad zo’n class.
Nou ja, een soort van. Rouge werkt met een Formatters-module met allerlei formatters die allemaal erven van de Formatter-class, en één daarvan is de HTML-formatter — degene die de HTML voor codeblokken genereert.

Elke formatter heeft een methode stream (vroeger heette die waarschijnlijk format) die de tokens van de lexer omzet in HTML.

Gebruik je een Rouge-versie ouder dan 4.0.0? Dan heet de methode format in plaats van stream.

Na wat debuggen, trial & error begon ik het door te krijgen, en besloot ik de HTML-formatter te overriden om een knop toe te voegen direct na de <pre>-tag.

Let op: ik ben geen Ruby-developer — ik kan het net genoeg lezen om de documentatie te volgen. Dus als je iets beters weet of fouten ziet, laat het me weten!

Dus zonder verder gedoe: zo voeg je een copy-knop toe aan je codeblokken in Jekyll (of Rouge) zonder JavaScript te gebruiken om het DOM te manipuleren.

Stap 1: Override de Rouge HTML-formatter

Maak een nieuw bestand in de map _plugins met de naam rouge_copy_button.rb.

Dat is het plugin-bestand dat Jekyll laadt en uitvoert tijdens het bouwen van je site.

Eerst natuurlijk even Rouge binnenhalen:

require "rouge"

In eerste instantie dacht ik dat ik de HTML-formatter gewoon kon “overnemen”, maar dat zou betekenen dat ik alle bestaande functionaliteit van die formatter kwijt ben. Dus ik maakte een nieuwe class die er gewoon van erft:

  # originele formatter opslaan voordat we hem patchen
  OriginalHTMLFormatter = Rouge::Formatters::HTML

  class Rouge::Formatters::HTMLWithCopyButton < OriginalHTMLFormatter
    puts "Initializing Rouge::Formatters::HTMLWithCopyButton"
  end

Stap 2: Hooken in Jekyll

Nu moeten we Jekyll vertellen dat we een aangepaste formatter hebben.

Jekyll::Hooks.register :site, :after_init do |_site|
  puts "Registering Rouge::Formatters::HTMLWithCopyButton as the default formatter"
  Rouge::Formatters::HTML = Rouge::Formatters::HTMLWithCopyButton
end

Weet je nog dat ik zei dat ik geen Ruby-dev ben? Ja… die fout zag zelfs een HTML-developer aankomen!

Rouge::Formatters::HTML is namelijk een constant, dus die kun je niet zomaar herdefiniëren. De terminal begon dus keihard te schreeuwen. De juiste manier 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

Niet dat ik ineens een Ruby-developer ben geworden hoor… ik heb het gewoon even ge-ChatGPT’d Maar eerlijk: Ruby begint me te bevallen.

Wat dit doet, is een hook registreren die wordt uitgevoerd nadat Jekyll is geïnitialiseerd. In die hook verwijderen we de HTML-constant uit de Rouge::Formatters-module en vervangen we hem door onze eigen HTMLWithCopyButton-class. Pretty neat, toch?

Stap 3: De stream-methode overschrijven

Nu alles staat, kunnen we onze eigen stream-methode schrijven:

  def stream(tokens)
  end

De tokens-parameter is een enumerable van tokens die door de lexer zijn gegenereerd — ik weet nog steeds niet precies wat dat is, maar ik weet wel dat het belangrijk is. En ik was niet van plan diep de Rouge-broncode of Ruby zelf in te duiken, ik wilde gewoon wat HTML aanpassen.

Dus:

  def stream(tokens)
    # originele stream-methode aanroepen
    html = super(tokens)

    # alleen toevoegen als er een <pre> aanwezig is
    if html.include?("<pre")
      # knop injecteren direct na <pre> en vóór <code>
      html.sub!("<code>", "<button class='copy-code' aria-label='Copy code'>Copy</button><code>")
    end

    # gewijzigde HTML retourneren
    html
  end

En klaar! Elke keer dat Jekyll nu de site bouwt, gebruikt het onze aangepaste formatter.

Hier het complete script:

  require "rouge"
  OriginalHTMLFormatter = Rouge::Formatters::HTML

  class Rouge::Formatters::HTMLWithCopyButton < OriginalHTMLFormatter
    puts "Initializing Rouge::Formatters::HTMLWithCopyButton"

    def stream(tokens)
      html = super(tokens)
      if html.include?("<pre")
        html.sub!("<code>", "<button class='copy-code' aria-label='Copy code'>Copy</button><code>")
      end
      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

Stap 4: De knop laten werken (Ja, met JavaScript)

De knop staat er nu, maar we moeten hem nog laten kopiëren.

Dat is vrij simpel:

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 gekopieerd!");
          button.classList.add("copied");
          setTimeout(() => {
            button.setAttribute("aria-label", "Copy code");
            button.classList.remove("copied");
          }, 2000);
        } catch (err) {
          console.error("Kopiëren mislukt:", err);
          button.setAttribute("aria-label", "Kopiëren mislukt");
          button.classList.add("error");
          setTimeout(() => {
            button.setAttribute("aria-label", "Copy code");
            button.classList.remove("error");
          }, 2000);
        }
      }
    });
  });
});

En dat is het. Werkt het niet? Laat me weten waarom niet!

Samenvatting

Zo voeg je een copy-knop toe aan Jekyll-codeblokken zonder DOM-manipulatie:

  1. Maak een nieuw .rb-bestand aan in _plugins.
  2. Laat het erven van Rouge::Formatters::HTML.
  3. Override de stream methode en injecteer de knop.
  4. Gebruik een Jekyll hook om de formatter te vervangen.
  5. Voeg JavaScript toe voor de kopieerfunctionaliteit.
  6. Style de knop met CSS.
  7. Klaar, geniet van je nieuwe copy knop!

Bonus: Copy Button WebComponent

Sinds ik een zwak heb gekregen voor WebComponents, leek dit de perfecte use-case. Hier is de minimalistische versie van mijn component:

<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>

Wil je de uitgebreidere versie met meer features en toegankelijkheid? Open de devtools (F12) en zoek op #copy-button-module-script. Of gebruik dit commando om hem direct te kopiëren:

document.getElementById("copy-button-module-script");

Als je tot hier bent gekomen: bedankt voor het lezen! Hopelijk vond je dit nuttig.. en als je vragen of suggesties hebt, laat gerust een reactie achter of stuur me een berichtje via het contactformulier.

Happy coding! </>