Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/pages/blog/[...id].astro
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// nothing defined in the rest of this frontmatter is visible inside it. Every
// helper it uses is either imported or declared within the function body.
export const getStaticPaths = (async () => {
const { getCollection } = await import('astro:content');

Check warning on line 14 in src/pages/blog/[...id].astro

View workflow job for this annotation

GitHub Actions / build

eslint(no-shadow)

src/pages/blog/[...id].astro:14:11: 'getCollection' is already declared in the upper scope.
const posts = await getCollection('posts', ({ data }) => data.status === 'published');
return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
}) satisfies GetStaticPaths;
Expand Down Expand Up @@ -171,6 +171,10 @@
</article>
</BaseLayout>

<script>
import '../../scripts/code-copy';
</script>

<style>
.post__inner {
padding-block: clamp(var(--space-09), 6vh, 72px) clamp(var(--section-y-desktop), 10vh, 112px);
Expand Down Expand Up @@ -268,6 +272,54 @@
border: var(--border-width-1) solid var(--border);
}

/* The button sits inside the <pre> rather than in a wrapper: a wrapper would
sit between the prose rules and the block, and the margins they set on
adjacent elements would stop applying. */
.post__body :global(pre.has-codecopy) {
position: relative;
}
.post__body :global(.codecopy) {
position: absolute;
top: var(--space-03);
right: var(--space-03);
padding: var(--space-02) var(--space-03);
border: var(--border-width-1) solid var(--console-border, var(--border));
border-radius: var(--radius-s);
background: var(--console-bg, var(--surface));
color: var(--console-text-tertiary, var(--text-tertiary));
font: var(--weight-medium) 11.5px / 1 var(--font-mono);
cursor: pointer;
/* The button lives inside the <pre>, so its label is part of the block's
selectable text. Without this, selecting the block by hand — the very
thing this button exists to replace — copies "docker run …copy". */
user-select: none;
opacity: 0;
transition:
opacity 120ms ease,
color 120ms ease,
border-color 120ms ease;
}
/* Revealed on hover for the mouse, and always present for keyboard and touch —
an affordance that only exists on hover does not exist on a phone. */
.post__body :global(pre:hover .codecopy),
.post__body :global(.codecopy:focus-visible),
.post__body :global(.codecopy.is-copied),
.post__body :global(.codecopy.is-copyfailed) {
opacity: 1;
}
@media (hover: none) {
.post__body :global(.codecopy) {
opacity: 1;
}
}
.post__body :global(.codecopy:hover) {
color: var(--text-primary);
border-color: var(--border-strong);
}
.post__body :global(.codecopy.is-copied) {
color: var(--text-brand, var(--text-primary));
}

.post__grid {
margin-top: clamp(var(--space-09), 5vh, var(--space-11));
display: grid;
Expand Down
25 changes: 25 additions & 0 deletions src/scripts/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** Writing to the clipboard, and the fallback that makes it work off https.
*
* Shared so the two callers — [data-copy] buttons and the code blocks in a post
* — cannot drift on the part that is easy to get subtly wrong. Falls back to a hidden textarea + execCommand so it
* still works on http:// origins where the async clipboard API is unavailable. */
export function write(text: string): Promise<void> {
if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text);
return new Promise((resolve, reject) => {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
if (document.execCommand('copy')) resolve();
else reject(new Error('copy rejected'));
} catch (err) {
reject(err);
} finally {
ta.remove();
}
});
}
59 changes: 59 additions & 0 deletions src/scripts/code-copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* A copy button on every code block in a post.
*
* 87 of the 104 posts carry code — connection strings, compose fragments, SQL —
* and the whole point of publishing them is that someone runs them. Selecting a
* multi-line block by hand is 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. The text is read from the DOM at click time, so it is by
* definition the code the reader is looking at.
*
* With JavaScript off the blocks render exactly as before — this adds an
* affordance, it does not carry the content.
*/
import { write } from './clipboard';

const IDLE = 'copy';
const RESET_MS = 1600;

for (const pre of document.querySelectorAll<HTMLPreElement>('.post__body pre')) {
// Shiki nests <code> inside <pre>; either way textContent is the source.
const source = pre.textContent ?? '';
if (!source.trim()) continue;

const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'codecopy';
btn.textContent = IDLE;
btn.setAttribute('aria-label', 'Copy this code block');

// The button is positioned against the <pre>; a wrapper would change the
// prose flow and the margins the stylesheet sets on sibling blocks.
pre.classList.add('has-codecopy');
pre.append(btn);

let timer: number | undefined;
btn.addEventListener('click', async () => {
let ok = true;
try {
await write(source);
} catch {
ok = false;
}
btn.textContent = ok ? 'copied' : 'press ⌘C';
btn.setAttribute('aria-label', ok ? 'Copied to clipboard' : 'Copy failed — select the code and press Command C');
btn.classList.toggle('is-copied', ok);
btn.classList.toggle('is-copyfailed', !ok);

window.clearTimeout(timer);
timer = window.setTimeout(() => {
btn.textContent = IDLE;
btn.setAttribute('aria-label', 'Copy this code block');
btn.classList.remove('is-copied', 'is-copyfailed');
}, RESET_MS);
});
}
26 changes: 2 additions & 24 deletions src/scripts/copy.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,6 @@
/** Copy-to-clipboard buttons: writes [data-copy], then shows "copied" for 1.6s
* (the prototype's timing). Falls back to a hidden textarea + execCommand so it
* still works on http:// origins where the async clipboard API is unavailable. */
function write(text: string): Promise<void> {
if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(text);
return new Promise((resolve, reject) => {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
if (document.execCommand('copy')) resolve();
else reject(new Error('copy rejected'));
} catch (err) {
reject(err);
} finally {
ta.remove();
}
});
}
* (the prototype's timing). */
import { write } from './clipboard';

for (const btn of document.querySelectorAll<HTMLButtonElement>('[data-copy]')) {
const idle = btn.textContent ?? 'copy';
Expand Down Expand Up @@ -58,5 +38,3 @@ for (const btn of document.querySelectorAll<HTMLButtonElement>('[data-copy]')) {
}, 1600);
});
}

export {};
38 changes: 38 additions & 0 deletions tests/post-relations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,41 @@ describe('the sitemap dates what it knows', () => {
for (const path of undated) expect(path, 'a post went undated').not.toMatch(/^\/blog\/[^/]+\/$/);
});
});

describe('code blocks can be copied', () => {
it('loads the copier on every post', () => {
// The button is injected at runtime, so the markup cannot be asserted — what
// can be is that the module reaches the page and that it shares the
// clipboard fallback rather than carrying a second copy of it.
for (const slug of dirs) {
const doc = readFileSync(`dist/blog/${slug}/index.html`, 'utf8');
const modules = [...doc.matchAll(/<script type="module" src="([^"]+)"/g)].map((m) => m[1]!);
const loaded = modules.some((src) => readFileSync(`dist${src}`, 'utf8').includes('codecopy'));
expect(loaded, `${slug}: the code copier is not loaded`).toBe(true);
}
});

it('keeps its own label out of the block a reader selects by hand', () => {
// The button sits inside the <pre>, so without user-select: none its label
// joins the block's selectable text and a hand-selected `docker run …`
// comes out as `docker run …copy` — breaking the one workflow this was
// added to improve.
const doc = readFileSync('dist/blog/postgresql-connect-docker-container/index.html', 'utf8');
const sheets = [...doc.matchAll(/<link rel="stylesheet" href="([^"]+)"/g)].map((m) => m[1]!);
const rule = sheets
.map((href) => readFileSync(`dist${href}`, 'utf8'))
.flatMap((css) => [...css.matchAll(/\.codecopy\{([^}]*)\}/g)].map((m) => m[1]!))
.find((body) => body.includes('cursor:pointer'));
expect(rule, 'the .codecopy rule is not in any stylesheet').toBeDefined();
expect(rule, 'the button label is selectable with the code').toContain('user-select:none');
});

it('reads the code from the DOM instead of duplicating it into an attribute', () => {
// A `data-copy` attribute per block would roughly double the weight of a
// code-heavy page to save a few lines of script.
const doc = readFileSync('dist/blog/postgresql-connect-docker-container/index.html', 'utf8');
const body = doc.slice(doc.indexOf('post__body prose'), doc.indexOf('<nav class="toc"'));
expect(body).toContain('<pre');
expect(body, 'code was duplicated into an attribute').not.toContain('data-copy=');
});
});
Loading