Skip to content

Commit cd596b8

Browse files
committed
feat(blog): copy a code block without selecting it
87 of the 104 posts carry code — connection strings, compose fragments, SQL — and the point of publishing them is that someone runs them. Selecting a multi-line block by hand was the one part of that the page controlled and was not helping with. The button is injected at runtime rather than rendered into the markup. The alternative is a `data-copy` attribute holding a second copy of every block, which would roughly double the weight of a code-heavy page to save a few lines of script; reading `textContent` at click time also guarantees the text is the code the reader is looking at. With JavaScript off the blocks render exactly as before. The clipboard write and its execCommand fallback moved to scripts/clipboard.ts so the two callers cannot drift on the part that is easy to get subtly wrong off https. Hidden until hover for the mouse, and always visible where there is no hover — an affordance that only appears on hover does not exist on a phone.
1 parent 4d2e675 commit cd596b8

5 files changed

Lines changed: 157 additions & 24 deletions

File tree

‎src/pages/blog/[...id].astro‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,10 @@ const schema = [
171171
</article>
172172
</BaseLayout>
173173

174+
<script>
175+
import '../../scripts/code-copy';
176+
</script>
177+
174178
<style>
175179
.post__inner {
176180
padding-block: clamp(var(--space-09), 6vh, 72px) clamp(var(--section-y-desktop), 10vh, 112px);
@@ -268,6 +272,50 @@ const schema = [
268272
border: var(--border-width-1) solid var(--border);
269273
}
270274

275+
/* The button sits inside the <pre> rather than in a wrapper: a wrapper would
276+
sit between the prose rules and the block, and the margins they set on
277+
adjacent elements would stop applying. */
278+
.post__body :global(pre.has-codecopy) {
279+
position: relative;
280+
}
281+
.post__body :global(.codecopy) {
282+
position: absolute;
283+
top: var(--space-03);
284+
right: var(--space-03);
285+
padding: var(--space-02) var(--space-03);
286+
border: var(--border-width-1) solid var(--console-border, var(--border));
287+
border-radius: var(--radius-sm);
288+
background: var(--console-bg, var(--surface));
289+
color: var(--console-text-tertiary, var(--text-tertiary));
290+
font: var(--weight-medium) 11.5px / 1 var(--font-mono);
291+
cursor: pointer;
292+
opacity: 0;
293+
transition:
294+
opacity 120ms ease,
295+
color 120ms ease,
296+
border-color 120ms ease;
297+
}
298+
/* Revealed on hover for the mouse, and always present for keyboard and touch —
299+
an affordance that only exists on hover does not exist on a phone. */
300+
.post__body :global(pre:hover .codecopy),
301+
.post__body :global(.codecopy:focus-visible),
302+
.post__body :global(.codecopy.is-copied),
303+
.post__body :global(.codecopy.is-copyfailed) {
304+
opacity: 1;
305+
}
306+
@media (hover: none) {
307+
.post__body :global(.codecopy) {
308+
opacity: 1;
309+
}
310+
}
311+
.post__body :global(.codecopy:hover) {
312+
color: var(--text-primary);
313+
border-color: var(--border-strong);
314+
}
315+
.post__body :global(.codecopy.is-copied) {
316+
color: var(--text-brand, var(--text-primary));
317+
}
318+
271319
.post__grid {
272320
margin-top: clamp(var(--space-09), 5vh, var(--space-11));
273321
display: grid;

‎src/scripts/clipboard.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/** Writing to the clipboard, and the fallback that makes it work off https.
2+
*
3+
* Shared so the two callers — [data-copy] buttons and the code blocks in a post
4+
* — cannot drift on the part that is easy to get subtly wrong. Falls back to a hidden textarea + execCommand so it
5+
* still works on http:// origins where the async clipboard API is unavailable. */
6+
export function write(text: string): Promise<void> {
7+
if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text);
8+
return new Promise((resolve, reject) => {
9+
const ta = document.createElement('textarea');
10+
ta.value = text;
11+
ta.setAttribute('readonly', '');
12+
ta.style.position = 'fixed';
13+
ta.style.opacity = '0';
14+
document.body.appendChild(ta);
15+
ta.select();
16+
try {
17+
if (document.execCommand('copy')) resolve();
18+
else reject(new Error('copy rejected'));
19+
} catch (err) {
20+
reject(err);
21+
} finally {
22+
ta.remove();
23+
}
24+
});
25+
}

‎src/scripts/code-copy.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* A copy button on every code block in a post.
3+
*
4+
* 87 of the 104 posts carry code — connection strings, compose fragments, SQL —
5+
* and the whole point of publishing them is that someone runs them. Selecting a
6+
* multi-line block by hand is the one part of that the page controlled and was
7+
* not helping with.
8+
*
9+
* The button is injected at runtime rather than rendered into the markup: the
10+
* alternative is a `data-copy` attribute holding a second copy of every block,
11+
* which would roughly double the weight of a code-heavy page to save a few
12+
* lines of script. The text is read from the DOM at click time, so it is by
13+
* definition the code the reader is looking at.
14+
*
15+
* With JavaScript off the blocks render exactly as before — this adds an
16+
* affordance, it does not carry the content.
17+
*/
18+
import { write } from './clipboard';
19+
20+
const IDLE = 'copy';
21+
const RESET_MS = 1600;
22+
23+
for (const pre of document.querySelectorAll<HTMLPreElement>('.post__body pre')) {
24+
// Shiki nests <code> inside <pre>; either way textContent is the source.
25+
const source = pre.textContent ?? '';
26+
if (!source.trim()) continue;
27+
28+
const btn = document.createElement('button');
29+
btn.type = 'button';
30+
btn.className = 'codecopy';
31+
btn.textContent = IDLE;
32+
btn.setAttribute('aria-label', 'Copy this code block');
33+
34+
// The button is positioned against the <pre>; a wrapper would change the
35+
// prose flow and the margins the stylesheet sets on sibling blocks.
36+
pre.classList.add('has-codecopy');
37+
pre.append(btn);
38+
39+
let timer: number | undefined;
40+
btn.addEventListener('click', async () => {
41+
let ok = true;
42+
try {
43+
await write(source);
44+
} catch {
45+
ok = false;
46+
}
47+
btn.textContent = ok ? 'copied' : 'press ⌘C';
48+
btn.setAttribute('aria-label', ok ? 'Copied to clipboard' : 'Copy failed — select the code and press Command C');
49+
btn.classList.toggle('is-copied', ok);
50+
btn.classList.toggle('is-copyfailed', !ok);
51+
52+
window.clearTimeout(timer);
53+
timer = window.setTimeout(() => {
54+
btn.textContent = IDLE;
55+
btn.setAttribute('aria-label', 'Copy this code block');
56+
btn.classList.remove('is-copied', 'is-copyfailed');
57+
}, RESET_MS);
58+
});
59+
}

‎src/scripts/copy.ts‎

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,6 @@
11
/** Copy-to-clipboard buttons: writes [data-copy], then shows "copied" for 1.6s
2-
* (the prototype's timing). Falls back to a hidden textarea + execCommand so it
3-
* still works on http:// origins where the async clipboard API is unavailable. */
4-
function write(text: string): Promise<void> {
5-
if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text);
6-
return new Promise((resolve, reject) => {
7-
const ta = document.createElement('textarea');
8-
ta.value = text;
9-
ta.setAttribute('readonly', '');
10-
ta.style.position = 'fixed';
11-
ta.style.opacity = '0';
12-
document.body.appendChild(ta);
13-
ta.select();
14-
try {
15-
if (document.execCommand('copy')) resolve();
16-
else reject(new Error('copy rejected'));
17-
} catch (err) {
18-
reject(err);
19-
} finally {
20-
ta.remove();
21-
}
22-
});
23-
}
2+
* (the prototype's timing). */
3+
import { write } from './clipboard';
244

255
for (const btn of document.querySelectorAll<HTMLButtonElement>('[data-copy]')) {
266
const idle = btn.textContent ?? 'copy';
@@ -58,5 +38,3 @@ for (const btn of document.querySelectorAll<HTMLButtonElement>('[data-copy]')) {
5838
}, 1600);
5939
});
6040
}
61-
62-
export {};

‎tests/post-relations.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,26 @@ describe('the sitemap dates what it knows', () => {
151151
for (const path of undated) expect(path, 'a post went undated').not.toMatch(/^\/blog\/[^/]+\/$/);
152152
});
153153
});
154+
155+
describe('code blocks can be copied', () => {
156+
it('loads the copier on every post', () => {
157+
// The button is injected at runtime, so the markup cannot be asserted — what
158+
// can be is that the module reaches the page and that it shares the
159+
// clipboard fallback rather than carrying a second copy of it.
160+
for (const slug of dirs) {
161+
const doc = readFileSync(`dist/blog/${slug}/index.html`, 'utf8');
162+
const modules = [...doc.matchAll(/<script type="module" src="([^"]+)"/g)].map((m) => m[1]!);
163+
const loaded = modules.some((src) => readFileSync(`dist${src}`, 'utf8').includes('codecopy'));
164+
expect(loaded, `${slug}: the code copier is not loaded`).toBe(true);
165+
}
166+
});
167+
168+
it('reads the code from the DOM instead of duplicating it into an attribute', () => {
169+
// A `data-copy` attribute per block would roughly double the weight of a
170+
// code-heavy page to save a few lines of script.
171+
const doc = readFileSync('dist/blog/postgresql-connect-docker-container/index.html', 'utf8');
172+
const body = doc.slice(doc.indexOf('post__body prose'), doc.indexOf('<nav class="toc"'));
173+
expect(body).toContain('<pre');
174+
expect(body, 'code was duplicated into an attribute').not.toContain('data-copy=');
175+
});
176+
});

0 commit comments

Comments
 (0)