mirror of https://github.com/theNewDynamic/gohugo-theme-ananke.git

Patrick Kollitsch
2 days ago 8c3143a220e69752c9434b15309c75145b99b896
feat: copy-to-clipboard buttons for code blocks (#986)

Add a code block render hook (layouts/_markup/render-codeblock.html) that
wraps highlighted output in a .code-block container with a .code-copy
button, plus the copy behaviour in site-scripts.html and button styles in
_code.css.

Progressive enhancement: buttons are rendered with the hidden attribute
and only revealed once JavaScript runs, so sites without JS show no inert
button and code stays selectable. A single delegated listener handles
every block; copy uses the async Clipboard API with an execCommand
fallback for non-secure contexts. Styling is reduced-motion aware, has
focus-visible reveal for keyboard users, and a touch-device fallback.

Reuses the same class names (.code-block/.code-copy/.is-copied) as the
documentation site's existing override so the two remain drop-in
compatible. Toggle with [ananke] copy_code (default true).

Part of #985
Closes #986

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3 files modified
1 files added
165 ■■■■■ changed files
assets/ananke/css/_code.css 60 ●●●●● patch | view | raw | blame | history
config/_default/params.toml 1 ●●●● patch | view | raw | blame | history
layouts/_markup/render-codeblock.html 27 ●●●●● patch | view | raw | blame | history
layouts/_partials/site-scripts.html 77 ●●●●● patch | view | raw | blame | history
assets/ananke/css/_code.css
@@ -19,6 +19,62 @@
}
p code {
  font-size: 0.9em;
  }
}
/* ------------------------------------------------------------------ *
 * Code block copy button (see layouts/_markup/render-codeblock.html)  *
 * Progressive enhancement: the button is hidden until site-scripts.html
 * reveals it, so no inert button appears when JavaScript is disabled.  *
 * ------------------------------------------------------------------ */
.code-block {
  position: relative;
}
.code-block .code-copy {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  z-index: 2;
  padding: 0.25rem 0.6rem;
  font-family: inherit;
  font-size: 0.75rem;
  line-height: 1.4;
  color: #ddd;
  background-color: rgba(255, 255, 255, 0.12);
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: 4px;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s ease-in-out, background-color 0.15s ease-in-out;
}
/* Reveal on hover and when focused for keyboard users. */
.code-block:hover .code-copy,
.code-block .code-copy:focus-visible {
  opacity: 1;
}
.code-block .code-copy:hover {
  background-color: rgba(255, 255, 255, 0.22);
}
.code-block .code-copy.is-copied {
  color: #fff;
  background-color: #19a974;
  border-color: #19a974;
}
/* Touch devices have no hover; keep the button visible but subtle. */
@media (hover: none) {
  .code-block .code-copy {
    opacity: 0.7;
  }
}
@media (prefers-reduced-motion: reduce) {
  .code-block .code-copy {
    transition: none;
  }
}
config/_default/params.toml
@@ -1,6 +1,7 @@
[ananke]
show_recent_posts = true # show recent posts on the homepage
show_categories = true # show categories terms on single pages
copy_code = true # show a copy-to-clipboard button on code blocks (progressive enhancement)
[ananke.home]
content_alignment = "center" # options: left, center, right
layouts/_markup/render-codeblock.html
New file
@@ -0,0 +1,27 @@
{{- /*
  Code block render hook.
  Wraps Hugo's standard syntax-highlighted output in a container with a
  copy-to-clipboard button. Highlighting still honours the site's
  [markup.highlight] configuration because we delegate to
  transform.HighlightCodeBlock, which reads both the per-fence options and the
  global config.
  Progressive enhancement: the button is rendered with the `hidden` attribute
  and is only revealed by site-scripts.html when JavaScript runs, so sites
  without JS show no inert button. The copy behaviour itself is wired by the
  same script (a single delegated listener).
  Disable site-wide with `[ananke] copy_code = false` (see params.toml).
*/ -}}
{{- $result := transform.HighlightCodeBlock . -}}
{{- if eq false site.Params.ananke.copy_code -}}
  {{- $result.Wrapped -}}
{{- else -}}
  <div class="code-block">
    <button class="code-copy" type="button" hidden aria-label="Copy code to clipboard">
      <span class="code-copy-label" aria-hidden="true">Copy</span>
    </button>
    {{ $result.Wrapped }}
  </div>
{{- end -}}
layouts/_partials/site-scripts.html
@@ -1 +1,76 @@
{{/* For Users's overwrite */}}
{{- /*
  Copy-to-clipboard behaviour for code blocks rendered by
  layouts/_markup/render-codeblock.html.
  Progressive enhancement: the buttons are hidden in the markup and only
  revealed here, so a site with JavaScript disabled never shows an inert
  button. A single delegated listener handles every code block on the page.
  Override this partial in your own project to add or replace site scripts;
  disable the copy buttons entirely with `[ananke] copy_code = false`.
*/ -}}
{{- if ne false site.Params.ananke.copy_code -}}
<script>
  (function () {
    "use strict";
    // Reveal the copy buttons now that JavaScript is available.
    var buttons = document.querySelectorAll(".code-block .code-copy[hidden]");
    for (var i = 0; i < buttons.length; i++) {
      buttons[i].hidden = false;
    }
    function flash(button) {
      var label = button.querySelector(".code-copy-label");
      var previous = label ? label.textContent : null;
      button.classList.add("is-copied");
      if (label) label.textContent = "Copied";
      window.setTimeout(function () {
        button.classList.remove("is-copied");
        if (label && previous !== null) label.textContent = previous;
      }, 2000);
    }
    function legacyCopy(text) {
      var area = document.createElement("textarea");
      area.value = text;
      area.setAttribute("readonly", "");
      area.style.position = "absolute";
      area.style.left = "-9999px";
      document.body.appendChild(area);
      area.select();
      var ok = false;
      try {
        ok = document.execCommand("copy");
      } catch (e) {
        ok = false;
      }
      document.body.removeChild(area);
      return ok;
    }
    function copyFrom(button) {
      var container = button.closest(".code-block");
      if (!container) return;
      var pre = container.querySelector("pre");
      if (!pre) return;
      var source = pre.querySelector("code") || pre;
      var text = source.innerText;
      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(text).then(
          function () { flash(button); },
          function () { if (legacyCopy(text)) flash(button); }
        );
      } else if (legacyCopy(text)) {
        flash(button);
      }
    }
    document.addEventListener("click", function (event) {
      var button = event.target.closest(".code-copy");
      if (button) copyFrom(button);
    });
  })();
</script>
{{- end -}}