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:
@@ -238,3 +238,16 @@ site behavior for the next person.
|
||||
for the full story). Truncate strings in PHP instead, guarded with
|
||||
`function_exists('mb_substr')` falling back to `substr()`, and pass the
|
||||
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
@@ -72,18 +72,15 @@ of the others):
|
||||
best gated behind admin login once that exists.
|
||||
9. **Draft pages (admin-only preview)** — no hard dependency; the admin
|
||||
authentication it reuses already shipped (see `/admin/docs/admin-auth`).
|
||||
10. **Copy-to-clipboard button on code blocks** — no dependency; a
|
||||
self-contained styling/JS feature, can be picked up any time.
|
||||
11. **Syntax highlighting on code blocks** — no hard dependency on the
|
||||
copy button above, but touches the same `<pre><code>` markup, so
|
||||
worth sequencing together to avoid two separate passes over every
|
||||
code block.
|
||||
12. **Admin login & user management** — needs both SQLite groundwork (user
|
||||
10. **Syntax highlighting on code blocks** — no hard dependency on the
|
||||
copy button (see Done — shipped 2026-07-14), but touches the same
|
||||
`<pre><code>` markup it did.
|
||||
11. **Admin login & user management** — needs both SQLite groundwork (user
|
||||
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
|
||||
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;
|
||||
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
|
||||
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. `<h1>` 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 ~1–2 seconds) rather than a silent copy, so it's clear it worked.
|
||||
|
||||
### Syntax highlighting on code blocks
|
||||
|
||||
- **Type:** Feature
|
||||
@@ -420,7 +389,37 @@ _Nothing yet._
|
||||
|
||||
## 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. `<h1>` 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
|
||||
|
||||
|
||||
@@ -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. <h1> 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>
|
||||
@@ -6,9 +6,9 @@
|
||||
surrounding link/text color, including on :hover, with no extra CSS.
|
||||
|
||||
Available: home, git, book, link, sitemap, email, search, rss, tag,
|
||||
lock, trash, external_link, menu, back_to_top, sun, moon. Some
|
||||
(search, rss, tag) are ahead of the features that will use them (see
|
||||
novaconium/ISSUES.md) — added now so those features don't need an
|
||||
lock, trash, external_link, menu, back_to_top, sun, moon, copy, check.
|
||||
Some (search, rss, tag) are ahead of the features that will use them
|
||||
(see novaconium/ISSUES.md) — added now so those features don't need an
|
||||
icons.twig change later. #}
|
||||
|
||||
{% 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" />
|
||||
</svg>
|
||||
{% 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 %}
|
||||
|
||||
@@ -36,5 +36,6 @@
|
||||
<footer>
|
||||
<small>© {{ "now"|date("Y") }} {{ site_name }}</small>
|
||||
</footer>
|
||||
{% include '_layout/code-copy.twig' %}
|
||||
</body>
|
||||
</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><html></code> at runtime and persists the choice to <code>localStorage</code>. <code>novaconium/pages/_layout/theme-init.twig</code>, included early in <code><head></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>
|
||||
|
||||
<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><pre></code> containing a <code><code></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>&lt;h1&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 %}
|
||||
|
||||
@@ -131,6 +131,7 @@ code
|
||||
font-size: 0.9em
|
||||
|
||||
pre
|
||||
position: relative
|
||||
background: var(--surface)
|
||||
border: 1px solid var(--border-color)
|
||||
border-radius: 6px
|
||||
@@ -142,6 +143,35 @@ pre
|
||||
padding: 0
|
||||
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
|
||||
border: none
|
||||
border-top: 1px solid var(--border-color)
|
||||
|
||||
@@ -133,6 +133,7 @@ code {
|
||||
}
|
||||
|
||||
pre {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
@@ -144,6 +145,36 @@ pre code {
|
||||
padding: 0;
|
||||
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 {
|
||||
border: none;
|
||||
|
||||
Reference in New Issue
Block a user