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
formatin plaats vanstream.
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:
- Maak een nieuw .rb-bestand aan in _plugins.
- Laat het erven van
Rouge::Formatters::HTML. - Override de
streammethode en injecteer de knop. - Gebruik een Jekyll hook om de formatter te vervangen.
- Voeg JavaScript toe voor de kopieerfunctionaliteit.
- Style de knop met CSS.
- 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
<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.
Nog geen reacties.
Opmerking: ik verwelkom alle reacties, inclusief negatieve, maar ik behoud me het recht voor om te modereren op spam, misbruik, haatzaaien en andere ongepaste inhoud. Disclaimer: reacties worden gemodereerd en kunnen enige tijd duren voordat ze verschijnen. Ik onderschrijf de in reacties geuite opvattingen niet. Door een reactie in te dienen, gaat u akkoord met het privacybeleid en stemt u in met de verwerking van uw gegevens zoals daarin beschreven.