feat(docs): add copy-to-clipboard button on code blocks

Add a Svelte action that wraps pre blocks in a container and injects a fixed copy button. Includes checkmark feedback, pre code style reset, wrapper border-radius handling, and compact padding. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Yechan Kim committed Apr 12, 2026 at 19:44 UTC ce2f6858d9cdca18df0c80fcddfccc06584e662f
3 files changed +105 -1
docs/src/app.css
+28
@@ -158,14 +158,42 @@ h1, h2, h3, h4, h5, h6 {
158 border-color: oklch(75% 0.14 195 / 0.2);
159 }
160
161 +/* Reset inline code styles inside code blocks */
162 +.prose :where(pre code):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
163 + background-color: transparent;
164 + padding: 0;
165 + border-radius: 0;
166 + border: none;
167 + font-size: inherit;
168 +}
169 +
170 /* Code blocks — relay terminal aesthetic */
171 .prose :where(pre):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
172 background-color: oklch(20% 0.005 250);
173 border-radius: 0.75rem;
174 overflow-x: auto;
175 + padding: 0.75rem 1rem;
176 border: 1px solid oklch(100% 0 0 / 0.05);
177 }
178
179 +/* When wrapped by copy-code action, move radius/border to wrapper */
180 +[data-copy-wrapper] {
181 + border-radius: 0.75rem;
182 + overflow: hidden;
183 + border: 1px solid oklch(100% 0 0 / 0.05);
184 + background-color: oklch(20% 0.005 250);
185 +}
186 +
187 +.dark [data-copy-wrapper] {
188 + border-color: oklch(100% 0 0 / 0.1);
189 + background-color: oklch(14% 0.008 250);
190 +}
191 +
192 +[data-copy-wrapper] pre {
193 + border-radius: 0 !important;
194 + border: none !important;
195 +}
196 +
197 .dark .prose :where(pre):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
198 background-color: oklch(14% 0.008 250);
199 border-color: oklch(100% 0 0 / 0.1);
docs/src/lib/actions/copy-code.ts new
+75
@@ -0,0 +1,75 @@
1 +/**
2 + * Svelte action that adds a copy button to all <pre> blocks inside an element.
3 + * Wraps each <pre> in a container div so the button stays fixed even when
4 + * the code scrolls horizontally.
5 + * Usage: <article use:copyCode={pathname}>
6 + */
7 +export function copyCode(node: HTMLElement) {
8 + const wrappers: HTMLDivElement[] = [];
9 +
10 + function addButtons() {
11 + removeButtons();
12 +
13 + const pres = node.querySelectorAll('pre');
14 + for (const pre of pres) {
15 + // Skip if already wrapped
16 + if (pre.parentElement?.hasAttribute('data-copy-wrapper')) continue;
17 +
18 + // Wrap pre in a relative container
19 + const wrapper = document.createElement('div');
20 + wrapper.setAttribute('data-copy-wrapper', '');
21 + wrapper.style.position = 'relative';
22 + pre.parentNode?.insertBefore(wrapper, pre);
23 + wrapper.appendChild(pre);
24 +
25 + const btn = document.createElement('button');
26 + btn.setAttribute('data-copy-btn', '');
27 + btn.setAttribute('aria-label', 'Copy code');
28 + btn.setAttribute('title', 'Copy');
29 + btn.className =
30 + 'absolute top-2.5 right-2.5 z-10 inline-flex h-7 w-7 items-center justify-center rounded-md text-gray-400 transition-colors hover:text-gray-200';
31 + btn.innerHTML = `<svg class="h-[22px] w-[22px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>`;
32 +
33 + btn.addEventListener('click', async () => {
34 + const code = pre.querySelector('code');
35 + const text = code?.textContent ?? pre.textContent ?? '';
36 + try {
37 + await navigator.clipboard.writeText(text);
38 + btn.innerHTML = `<svg class="h-[22px] w-[22px] text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>`;
39 + btn.setAttribute('title', 'Copied!');
40 + setTimeout(() => {
41 + btn.innerHTML = `<svg class="h-[22px] w-[22px]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>`;
42 + btn.setAttribute('title', 'Copy');
43 + }, 2000);
44 + } catch {
45 + // Fallback: silent fail
46 + }
47 + });
48 +
49 + wrapper.appendChild(btn);
50 + wrappers.push(wrapper);
51 + }
52 + }
53 +
54 + function removeButtons() {
55 + for (const wrapper of wrappers) {
56 + const pre = wrapper.querySelector('pre');
57 + if (pre && wrapper.parentNode) {
58 + wrapper.parentNode.insertBefore(pre, wrapper);
59 + wrapper.remove();
60 + }
61 + }
62 + wrappers.length = 0;
63 + }
64 +
65 + requestAnimationFrame(addButtons);
66 +
67 + return {
68 + update() {
69 + requestAnimationFrame(addButtons);
70 + },
71 + destroy() {
72 + removeButtons();
73 + }
74 + };
75 +}
docs/src/routes/+layout.svelte
+2 -1
@@ -8,6 +8,7 @@
8 import TableOfContents from '$lib/components/TableOfContents.svelte';
9 import PrevNextNav from '$lib/components/PrevNextNav.svelte';
10 import { getPrevNext } from '$lib/nav';
11 + import { copyCode } from '$lib/actions/copy-code';
12 import '../app.css';
13
14 let { children } = $props();
@@ -136,7 +137,7 @@
137 </aside>
138
139 <main class="min-w-0 flex-1 px-4 py-8 lg:px-8">
139 - <article class="prose prose-gray dark:prose-invert mx-auto max-w-3xl">
140 + <article use:copyCode={$page.url.pathname} class="prose prose-gray dark:prose-invert mx-auto max-w-3xl">
141 {@render children()}
142 </article>
143 <div class="mx-auto max-w-3xl">