Add copy-to-clipboard button to code blocks

Injects a hover-revealed copy button into every <pre><code> block
site-wide via a single layout partial, reading textContent so escaped
samples copy as literal text. Ships copy/check icons and matching Sass.

Passes icon markup to JS via <template> elements instead of Twig's
|escape('js'), which calls mb_ord() and fatals without mbstring — same
class of bug as the existing |slice/mb_substr gotcha, now documented in
AGENTS.md.

Closes the "Copy-to-clipboard button on code blocks" backlog item in
novaconium/ISSUES.md.
This commit is contained in:
code
2026-07-14 03:03:29 +00:00
parent fe5f5ee131
commit 672f997d8b
8 changed files with 190 additions and 41 deletions
+13
View File
@@ -238,3 +238,16 @@ site behavior for the next person.
for the full story). Truncate strings in PHP instead, guarded with for the full story). Truncate strings in PHP instead, guarded with
`function_exists('mb_substr')` falling back to `substr()`, and pass the `function_exists('mb_substr')` falling back to `substr()`, and pass the
already-truncated value into the template. already-truncated value into the template.
- Same class of bug, different filter: don't use Twig's `|escape('js')` (or
the `'js'` arg to `|e`) either — it calls `Twig\Runtime\mb_ord()`
(`novaconium/vendor/twig/src/Extension/EscaperExtension.php`), which
hard-requires `mbstring` the same way `|slice` does, and fatals
identically without it. Hit for real when
`novaconium/pages/_layout/code-copy.twig` used it to pass SVG icon
markup into an inline `<script>` as a JS string literal. Fixed by not
needing string-escaped markup in JS at all: render the markup as plain
HTML into a `<template>` element (default autoescaping, no mbstring
dependency) and read it in JS via that template element's `.innerHTML`
getter instead. Prefer that pattern — or a `data-*` attribute if the
value is plain text, not markup — over `|escape('js')` any time a Twig
value needs to reach JS.
+37 -38
View File
@@ -72,18 +72,15 @@ of the others):
best gated behind admin login once that exists. best gated behind admin login once that exists.
9. **Draft pages (admin-only preview)** — no hard dependency; the admin 9. **Draft pages (admin-only preview)** — no hard dependency; the admin
authentication it reuses already shipped (see `/admin/docs/admin-auth`). authentication it reuses already shipped (see `/admin/docs/admin-auth`).
10. **Copy-to-clipboard button on code blocks** — no dependency; a 10. **Syntax highlighting on code blocks** — no hard dependency on the
self-contained styling/JS feature, can be picked up any time. copy button (see Done — shipped 2026-07-14), but touches the same
11. **Syntax highlighting on code blocks** — no hard dependency on the `<pre><code>` markup it did.
copy button above, but touches the same `<pre><code>` markup, so 11. **Admin login & user management** — needs both SQLite groundwork (user
worth sequencing together to avoid two separate passes over every
code block.
12. **Admin login & user management** — needs both SQLite groundwork (user
store) and session handling (logged-in state). store) and session handling (logged-in state).
13. **Ecommerce functionality** — needs SQLite groundwork, session handling 12. **Ecommerce functionality** — needs SQLite groundwork, session handling
(cart), and admin login & user management (order/product admin, and (cart), and admin login & user management (order/product admin, and
customer accounts). customer accounts).
14. **Paywall functionality** — needs everything Ecommerce needs, plus 13. **Paywall functionality** — needs everything Ecommerce needs, plus
Ecommerce itself for the recurring-billing/payment-gateway plumbing; Ecommerce itself for the recurring-billing/payment-gateway plumbing;
build after it rather than in parallel. build after it rather than in parallel.
@@ -285,34 +282,6 @@ standing-rule note — it's exactly the kind of interaction between two
independently-reasonable features that's easy to get wrong once and independently-reasonable features that's easy to get wrong once and
should only need explaining once. should only need explaining once.
### Copy-to-clipboard button on code blocks
- **Type:** Feature
- **Status:** Backlog
- **Priority:** Low
- **Added:** 2026-07-13
Every `<pre><code>` block across `/admin/docs/*` and the blog's reference
posts (Twig Syntax Guide, Style Guide) is meant to be copy-pasted — add a
small button on hover that copies the block's text via the
[Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText)
(`navigator.clipboard.writeText(...)`), consistent with this project's
no-build-step philosophy: vanilla JS, no dependency, same pattern as the
dark/light theme toggle (`novaconium/pages/_layout/theme-toggle.twig`) —
a small inline script using event delegation rather than a script per
button. Needs a new icon (a "copy"/clipboard glyph — see
`novaconium/pages/_layout/icons.twig` for the existing set and its
inline-SVG-not-icon-font convention) and matching Sass in
`novaconium/sass/main.sass`, plus a way to actually grab a code block's
raw text without the HTML entities used to keep literal tags/Twig syntax
from rendering inside `<pre><code>` (e.g. `&lt;h1&gt;` in the SEO starter
template) — reading `textContent` rather than `innerHTML` handles the
entity-decoding automatically, so this should be low-risk, but worth
calling out since it's the same class of escaping issue documented in
`AGENTS.md`/fixed across `/admin/docs/seo` and the Twig Syntax Guide.
Should show a brief "Copied!" confirmation (swap the button label/icon
for ~12 seconds) rather than a silent copy, so it's clear it worked.
### Syntax highlighting on code blocks ### Syntax highlighting on code blocks
- **Type:** Feature - **Type:** Feature
@@ -420,7 +389,37 @@ _Nothing yet._
## Done ## Done
_Nothing yet._ ### Copy-to-clipboard button on code blocks
- **Type:** Feature
- **Status:** Done
- **Priority:** Low
- **Added:** 2026-07-13
- **Shipped:** 2026-07-14
Every `<pre><code>` block across `/admin/docs/*` and the blog's reference
posts (Twig Syntax Guide, Style Guide) is meant to be copy-pasted — added a
small button on hover that copies the block's text via the
[Clipboard API](https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText)
(`navigator.clipboard.writeText(...)`), consistent with this project's
no-build-step philosophy: vanilla JS, no dependency, same event-delegation
pattern as the dark/light theme toggle (`novaconium/pages/_layout/nav.twig`).
Implemented as a single site-wide partial
(`novaconium/pages/_layout/code-copy.twig`, included from
`_layout/layout.twig`'s footer) that injects a button into every `<pre>`
containing a `<code>` on `DOMContentLoaded`, rather than touching each doc
page's markup individually. Added `copy`/`check` icons to
`novaconium/pages/_layout/icons.twig` and matching styles in
`novaconium/sass/main.sass` (hover/focus-revealed button, `.copied` state).
Icon markup reaches JS via two `<template>` elements read through
`.innerHTML`, not Twig's `|escape('js')` — that filter calls
`Twig\Runtime\mb_ord()` and fatals without the `mbstring` extension, hit
for real once on a bare-PHP install; see the standing rule added to
`AGENTS.md` next to the existing `|slice`/`mb_substr` gotcha.
Copies via `code.textContent`, not `innerHTML`, so HTML-entity-escaped
samples (e.g. `&lt;h1&gt;` in the SEO starter template) come out as literal
characters rather than escaped markup. Shows a "Copied!" label/checkmark
for 1.5s after a successful copy.
## Won't Do ## Won't Do
+58
View File
@@ -0,0 +1,58 @@
{% import '_layout/icons.twig' as icons %}
{# Adds a hover-revealed copy button to every <pre><code> block on the
page, without touching any individual doc/blog page's markup. Reads
textContent (not innerHTML) when copying, so HTML-entity-escaped
samples (e.g. &lt;h1&gt; in the SEO starter template) come out as their
literal, unescaped characters rather than the escaped markup.
The icon markup is passed to JS via <template> elements (plain HTML
output, default autoescaping) rather than Twig's `|escape('js')`
filter — that filter calls Twig\Runtime\mb_ord() under the hood, which
hard-requires the mbstring extension and fatals
(`Call to undefined function Twig\Runtime\mb_ord()`) without it, the
same class of mbstring gotcha documented in AGENTS.md for `|slice` on
strings. #}
<template id="copy-code-icon-copy">{{ icons.copy() }}<span class="copy-code-label">Copy</span></template>
<template id="copy-code-icon-copied">{{ icons.check() }}<span class="copy-code-label">Copied!</span></template>
<script>
(function () {
var copyIconHtml = document.getElementById('copy-code-icon-copy').innerHTML;
var checkIconHtml = document.getElementById('copy-code-icon-copied').innerHTML;
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('pre').forEach(function (pre) {
if (!pre.querySelector('code')) {
return;
}
var button = document.createElement('button');
button.type = 'button';
button.className = 'copy-code-button icon-link';
button.setAttribute('aria-label', 'Copy code to clipboard');
button.innerHTML = copyIconHtml + '<span class="copy-code-label">Copy</span>';
pre.appendChild(button);
});
});
document.addEventListener('click', function (event) {
var button = event.target.closest('.copy-code-button');
if (!button) {
return;
}
var code = button.closest('pre').querySelector('code');
navigator.clipboard.writeText(code.textContent).then(function () {
var originalHtml = button.innerHTML;
button.innerHTML = checkIconHtml + '<span class="copy-code-label">Copied!</span>';
button.classList.add('copied');
setTimeout(function () {
button.innerHTML = originalHtml;
button.classList.remove('copied');
}, 1500);
});
});
})();
</script>
+16 -3
View File
@@ -6,9 +6,9 @@
surrounding link/text color, including on :hover, with no extra CSS. surrounding link/text color, including on :hover, with no extra CSS.
Available: home, git, book, link, sitemap, email, search, rss, tag, Available: home, git, book, link, sitemap, email, search, rss, tag,
lock, trash, external_link, menu, back_to_top, sun, moon. Some lock, trash, external_link, menu, back_to_top, sun, moon, copy, check.
(search, rss, tag) are ahead of the features that will use them (see Some (search, rss, tag) are ahead of the features that will use them
novaconium/ISSUES.md) — added now so those features don't need an (see novaconium/ISSUES.md) — added now so those features don't need an
icons.twig change later. #} icons.twig change later. #}
{% macro home(class) %} {% macro home(class) %}
@@ -144,3 +144,16 @@
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" /> <path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />
</svg> </svg>
{% endmacro %} {% endmacro %}
{% macro copy(class) %}
<svg class="icon icon-copy {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<rect x="9" y="9" width="11" height="11" rx="1.5" />
<path d="M5.5 15H4.5A1.5 1.5 0 0 1 3 13.5v-9A1.5 1.5 0 0 1 4.5 3h9A1.5 1.5 0 0 1 15 4.5v1" />
</svg>
{% endmacro %}
{% macro check(class) %}
<svg class="icon icon-check {{ class }}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M4.5 12.5 9.5 17.5 19.5 6.5" />
</svg>
{% endmacro %}
+1
View File
@@ -36,5 +36,6 @@
<footer> <footer>
<small>&copy; {{ "now"|date("Y") }} {{ site_name }}</small> <small>&copy; {{ "now"|date("Y") }} {{ site_name }}</small>
</footer> </footer>
{% include '_layout/code-copy.twig' %}
</body> </body>
</html> </html>
@@ -57,4 +57,8 @@ docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app novaconium-sass \
<p>The toggle button in <code>novaconium/pages/_layout/nav.twig</code> flips a <code>data-theme</code> attribute on <code>&lt;html&gt;</code> at runtime and persists the choice to <code>localStorage</code>. <code>novaconium/pages/_layout/theme-init.twig</code>, included early in <code>&lt;head&gt;</code> before the stylesheet, re-applies a saved choice before first paint on every later page load, so switching to light doesn't flash dark first. The sun/moon icon swap inside the button is pure CSS reacting to the attribute — no JS involved there — so it works correctly even on sidecar-less pages that get statically cached.</p> <p>The toggle button in <code>novaconium/pages/_layout/nav.twig</code> flips a <code>data-theme</code> attribute on <code>&lt;html&gt;</code> at runtime and persists the choice to <code>localStorage</code>. <code>novaconium/pages/_layout/theme-init.twig</code>, included early in <code>&lt;head&gt;</code> before the stylesheet, re-applies a saved choice before first paint on every later page load, so switching to light doesn't flash dark first. The sun/moon icon swap inside the button is pure CSS reacting to the attribute — no JS involved there — so it works correctly even on sidecar-less pages that get statically cached.</p>
<p>To customize the light theme the same way you'd customize the dark one, edit the <code>-light</code> variables in <code>App/sass/_colors.sass</code> and recompile.</p> <p>To customize the light theme the same way you'd customize the dark one, edit the <code>-light</code> variables in <code>App/sass/_colors.sass</code> and recompile.</p>
<h2>Copy-to-clipboard on code blocks</h2>
<p><code>novaconium/pages/_layout/code-copy.twig</code>, included once from <code>_layout/layout.twig</code>'s footer, injects a hover-revealed copy button into every <code>&lt;pre&gt;</code> containing a <code>&lt;code&gt;</code> on the page — no per-page markup needed. It copies via <code>code.textContent</code> (not <code>innerHTML</code>), so HTML-entity-escaped samples like <code>&amp;lt;h1&amp;gt;</code> in the <a href="/admin/docs/seo">SEO</a> starter template come out as literal characters, not escaped markup. Uses the same event-delegation pattern as the theme toggle in <code>_layout/nav.twig</code>: one document-level click listener rather than a listener per button.</p>
{% endblock %} {% endblock %}
+30
View File
@@ -131,6 +131,7 @@ code
font-size: 0.9em font-size: 0.9em
pre pre
position: relative
background: var(--surface) background: var(--surface)
border: 1px solid var(--border-color) border: 1px solid var(--border-color)
border-radius: 6px border-radius: 6px
@@ -142,6 +143,35 @@ pre
padding: 0 padding: 0
color: var(--text-color) color: var(--text-color)
&:hover .copy-code-button, .copy-code-button:focus-visible
opacity: 1
.copy-code-button
position: absolute
top: 0.5rem
right: 0.5rem
display: inline-flex
align-items: center
gap: 0.3rem
background: var(--bg)
border: 1px solid var(--border-color)
border-radius: 4px
color: var(--muted-color)
font-size: 0.75rem
padding: 0.25rem 0.5rem
cursor: pointer
opacity: 0
transition: opacity 0.15s ease
&:hover
background: var(--bg)
color: var(--text-color)
border-color: var(--accent)
&.copied
color: var(--accent)
border-color: var(--accent)
hr hr
border: none border: none
border-top: 1px solid var(--border-color) border-top: 1px solid var(--border-color)
+31
View File
@@ -133,6 +133,7 @@ code {
} }
pre { pre {
position: relative;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
@@ -144,6 +145,36 @@ pre code {
padding: 0; padding: 0;
color: var(--text-color); color: var(--text-color);
} }
pre:hover .copy-code-button, pre .copy-code-button:focus-visible {
opacity: 1;
}
.copy-code-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: var(--bg);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--muted-color);
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
}
.copy-code-button:hover {
background: var(--bg);
color: var(--text-color);
border-color: var(--accent);
}
.copy-code-button.copied {
color: var(--accent);
border-color: var(--accent);
}
hr { hr {
border: none; border: none;