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
|
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
@@ -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. `<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
|
### 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. `<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
|
## 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.
|
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 %}
|
||||||
|
|||||||
@@ -36,5 +36,6 @@
|
|||||||
<footer>
|
<footer>
|
||||||
<small>© {{ "now"|date("Y") }} {{ site_name }}</small>
|
<small>© {{ "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><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>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>
|
<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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user