From fc83f096e90b969c9db430abf3bbbea597b00b4e Mon Sep 17 00:00:00 2001 From: Luca Del Puppo Date: Wed, 19 Aug 2026 15:26:14 +0000 Subject: [PATCH 1/4] feat(docs): add copy buttons to doc code snippets Issue #487: docs pages had no copy buttons on their code blocks while the landing page already had them on npm install commands and the quick-start snippet. This extracts the duplicated copy logic into a shared CopyButton primitive and covers all
blocks on docs pages via runtime DOM injection.

Changes:
- Add src/lib/copy.ts: copyText() with navigator.clipboard +
  execCommand fallback; shared wireCopyButton() helper.
- Add src/components/CopyButton.astro: shared primitive with
  icon-swap and label-swap variants, lets the host specify a
  literal text or a selector resolved against data-copy-root.
- Refactor InstallCommand and CodeTabs to use CopyButton; both
  preserve their prior visuals (icon-swap and label-swap
  respectively) and lose ~25 lines of inline script each.
- Wire DOM injection in DocsShell.astro: wrap each 
 in
  article.prose-fastify in a .docs-pre-wrap div and inject a
  copy button that reads textContent via the shared helper.
  Wrapper carries data-pagefind-ignore. Idempotent.
- Add .docs-pre-wrap and .docs-copy-btn rules to prose.css so
  the injected buttons are positioned top-right and stay
  visible across horizontal pre scroll; works in both light
  and dark themes.
---
 src/components/CodeTabs.astro       | 155 +++++++++++++---------------
 src/components/CopyButton.astro     |  69 +++++++++++++
 src/components/DocsShell.astro      |  41 ++++++++
 src/components/InstallCommand.astro |  43 ++------
 src/lib/copy.ts                     | 110 ++++++++++++++++++++
 src/styles/prose.css                |  44 ++++++++
 6 files changed, 347 insertions(+), 115 deletions(-)
 create mode 100644 src/components/CopyButton.astro
 create mode 100644 src/lib/copy.ts

diff --git a/src/components/CodeTabs.astro b/src/components/CodeTabs.astro
index 5edb2004..f3638e86 100644
--- a/src/components/CodeTabs.astro
+++ b/src/components/CodeTabs.astro
@@ -1,6 +1,6 @@
 ---
 import { Code } from "astro:components";
-import Icon from "~/components/Icon.astro";
+import CopyButton from "~/components/CopyButton.astro";
 
 interface Tab {
 	label: string;
@@ -14,90 +14,81 @@ interface Props {
 }
 const { tabs, title, id } = Astro.props;
 ---
-
-
-
- {tabs.map((tab, i) => ( - - ))} -
- -
+
+
+
+ {tabs.map((tab, i) => ( + + ))} +
+ +
- {tabs.map((tab, i) => ( -
- - -
- ))} + {tabs.map((tab, i) => ( +
+ + ))}
diff --git a/src/components/CopyButton.astro b/src/components/CopyButton.astro new file mode 100644 index 00000000..c2690d6b --- /dev/null +++ b/src/components/CopyButton.astro @@ -0,0 +1,69 @@ +--- +// Shared copy-to-clipboard primitive. Two visual variants: +// - "icon-swap": copy icon ↔ check icon (used by InstallCommand and docs) +// - "label-swap": copy icon + text "Copy" → "Copied" (used by CodeTabs) +// +// Host components are responsible for wrapping both the button and the copy +// source inside an element with `data-copy-root` (e.g. InstallCommand's +// existing [data-install] wrapper, or CodeTabs's [data-codetabs] wrapper). +// The script below wires every [data-copy-btn] on the page; it resolves the +// text to copy from the button's own attributes, falling back to walking the +// root for a
/ element.
+import Icon from "~/components/Icon.astro";
+
+interface Props {
+	text?: string;
+	textSelector?: string;
+	variant?: "icon-swap" | "label-swap";
+	copyLabel?: string;
+	ariaLabel?: string;
+	class?: string;
+}
+
+const {
+	text,
+	textSelector,
+	variant = "icon-swap",
+	copyLabel = "Copy",
+	ariaLabel = "Copy code",
+	class: className = "",
+} = Astro.props;
+---
+
+
+
+
diff --git a/src/components/DocsShell.astro b/src/components/DocsShell.astro
index 1fdc934f..d0b43aba 100644
--- a/src/components/DocsShell.astro
+++ b/src/components/DocsShell.astro
@@ -294,6 +294,7 @@ const markdownUrl = withBase(`/docs/${markdownSlug}.md`);
 
diff --git a/src/components/InstallCommand.astro b/src/components/InstallCommand.astro index 90ccf789..00599834 100644 --- a/src/components/InstallCommand.astro +++ b/src/components/InstallCommand.astro @@ -1,41 +1,18 @@ --- -import Icon from "~/components/Icon.astro"; +import CopyButton from "~/components/CopyButton.astro"; interface Props { command?: string; } const { command = "npm install fastify" } = Astro.props; --- -
- $ - {command} - +
+ $ + {command} +
- - diff --git a/src/lib/copy.ts b/src/lib/copy.ts new file mode 100644 index 00000000..6f928638 --- /dev/null +++ b/src/lib/copy.ts @@ -0,0 +1,110 @@ +// Shared clipboard helper. Returned to true when text was successfully +// written to the user's clipboard, false otherwise. Callers use the return +// value to decide whether to flash the "copied" feedback state. +export const COPY_REVERT_MS = 1600; +export const COPY_BTN_SELECTOR = "[data-copy-btn]"; + +export async function copyText(text: string): Promise { + // Modern path: async Clipboard API. Gated behind a secure context (HTTPS + // or localhost), so we may need to fall back below. + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch (_) { + // Permission denied or API blocked — fall through to the legacy path. + } + + // Legacy fallback: temporary off-screen textarea + execCommand("copy"). + // Used on plain-HTTP origins and older browsers where the async Clipboard + // API is unavailable or requires a permission prompt we can't satisfy. + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "absolute"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; + } catch (_) { + return false; + } +} + +// Resolve what text a copy button should write to the clipboard. +// +// Priority: +// 1. `data-copy-text` on the button (a literal string passed at render). +// 2. `data-copy-target` selector resolved against the nearest +// [data-copy-root] ancestor (lets the host pick a sub-element, e.g. +// CodeTabs selects the hidden raw text of the active tab). +// 3. The first
 / 
 /  inside the root, or —
+//      defensively — inside the closest 
 ancestor.
+export function resolveCopyText(btn: HTMLElement): string {
+	const literal = btn.getAttribute("data-copy-text");
+	if (literal != null && literal !== "") return literal;
+
+	const targetSel = btn.getAttribute("data-copy-target");
+	const root = btn.closest("[data-copy-root]");
+	if (targetSel && root) {
+		const el = root.querySelector(targetSel);
+		if (el) return el.textContent ?? "";
+	}
+
+	if (root) {
+		const code =
+			root.querySelector("pre code") ??
+			root.querySelector("pre") ??
+			root.querySelector("code");
+		if (code) return code.textContent ?? "";
+	}
+	const pre = btn.closest("pre");
+	if (pre) {
+		return pre.querySelector("code")?.textContent ?? pre.textContent ?? "";
+	}
+	return "";
+}
+
+// Flash the "copied" feedback on a button. The visual is driven by which
+// optional DOM hooks are present in the button:
+//   - icon-swap variant:  [data-copy-idle] + [data-copy-done] spans
+//   - label-swap variant:  a single [data-copy-label] whose textContent
+//     is replaced for `COPY_REVERT_MS`, then reverted.
+export function flashCopyState(btn: HTMLElement): void {
+	const idle = btn.querySelector("[data-copy-idle]");
+	const done = btn.querySelector("[data-copy-done]");
+	if (idle && done) {
+		idle.classList.add("hidden");
+		done.classList.remove("hidden");
+		setTimeout(() => {
+			idle.classList.remove("hidden");
+			done.classList.add("hidden");
+		}, COPY_REVERT_MS);
+		return;
+	}
+	const label = btn.querySelector("[data-copy-label]");
+	if (label) {
+		const original = label.textContent ?? "";
+		label.textContent = "Copied";
+		setTimeout(() => {
+			label.textContent = original;
+		}, COPY_REVERT_MS);
+	}
+}
+
+// Attach the click → copy behavior to a single button. Static markup
+// (CopyButton.astro) wires all existing buttons via querySelectorAll;
+// DocsShell.astro wires each button it dynamically injects.
+export function wireCopyButton(btn: HTMLElement): void {
+	btn.addEventListener("click", async () => {
+		const text = resolveCopyText(btn);
+		if (!text) return;
+		const ok = await copyText(text);
+		if (!ok) return;
+		flashCopyState(btn);
+	});
+}
diff --git a/src/styles/prose.css b/src/styles/prose.css
index 7567d4d2..0aee91d1 100644
--- a/src/styles/prose.css
+++ b/src/styles/prose.css
@@ -163,3 +163,47 @@
 	/* biome-ignore lint/complexity/noImportantStyles: override Shiki inline styles */
 	background: var(--shiki-light-bg) !important;
 }
+
+/* ============================================================
+   Docs copy button (injected at runtime by DocsShell.astro).
+   The wrap is the positioning context so the button stays visible
+   while the inner 
 scrolls horizontally.
+   ============================================================ */
+.prose-fastify .docs-pre-wrap {
+	position: relative;
+	margin: 1.5rem 0;
+}
+.prose-fastify .docs-pre-wrap pre {
+	margin: 0;
+}
+.docs-copy-btn {
+	position: absolute;
+	top: 0.5rem;
+	right: 0.5rem;
+	z-index: 10;
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	padding: 0.375rem;
+	border-radius: 0.375rem;
+	color: #8b95a1;
+	background: rgba(12, 15, 19, 0.6);
+	border: 1px solid rgba(255, 255, 255, 0.08);
+	transition:
+		color 0.15s,
+		background 0.15s,
+		border-color 0.15s;
+	cursor: pointer;
+}
+.docs-copy-btn:hover {
+	color: #fff;
+	background: rgba(12, 15, 19, 0.85);
+	border-color: rgba(255, 255, 255, 0.15);
+}
+.docs-copy-btn:focus-visible {
+	outline: 2px solid var(--amber);
+	outline-offset: 2px;
+}
+.docs-copy-btn [data-copy-done] {
+	color: var(--amber);
+}

From 8f797bf961a43a18cd5fd3a5e40540d8c393340b Mon Sep 17 00:00:00 2001
From: Luca Del Puppo 
Date: Wed, 19 Aug 2026 16:01:09 +0000
Subject: [PATCH 2/4] feat(docs): vertically center copy button on single-line
 snippets

Single-line code blocks (curl examples, single npm commands, etc.)
have the copy icon floated to the top-right corner where it sits
above blank padding. Multi-line snippets still benefit from that
position because there's lots of code below the button.

Detect by counting  children (Shiki emits one
per source line, falling back to textContent split by \n) and
toggle an is-single-line class on the button that re-positions it
to the pre's vertical center via top: 50% + translateY(-50%).
---
 src/components/DocsShell.astro | 10 ++++++++++
 src/styles/prose.css           |  6 ++++++
 2 files changed, 16 insertions(+)

diff --git a/src/components/DocsShell.astro b/src/components/DocsShell.astro
index d0b43aba..539f44c7 100644
--- a/src/components/DocsShell.astro
+++ b/src/components/DocsShell.astro
@@ -382,6 +382,16 @@ const markdownUrl = withBase(`/docs/${markdownSlug}.md`);
       btn.className = "docs-copy-btn";
       btn.dataset.copyBtn = "";
       btn.setAttribute("aria-label", "Copy code to clipboard");
+      // Center the icon vertically for single-line snippets so it sits
+      // alongside the code rather than floating in a corner above it.
+      // Shiki wraps every source line in ; we fall back
+      // to splitting textContent by newline if those hooks aren't present.
+      const lineCount =
+        pre.querySelectorAll("span.line").length ||
+        (pre.textContent?.split("\n").length ?? 0);
+      if (lineCount <= 1) {
+        btn.classList.add("is-single-line");
+      }
       btn.innerHTML =
         `${COPY_SVG}` +
         ``;
diff --git a/src/styles/prose.css b/src/styles/prose.css
index 0aee91d1..b42cb5c8 100644
--- a/src/styles/prose.css
+++ b/src/styles/prose.css
@@ -207,3 +207,9 @@
 .docs-copy-btn [data-copy-done] {
 	color: var(--amber);
 }
+/* Single-line snippets: center the icon alongside the code rather than
+   pinning it to the top-right corner where it floats above empty padding. */
+.docs-copy-btn.is-single-line {
+	top: 50%;
+	transform: translateY(-50%);
+}

From 348088dd132a9ffcbd414daa71b29c76c6f5af62 Mon Sep 17 00:00:00 2001
From: Luca Del Puppo 
Date: Wed, 19 Aug 2026 16:13:35 +0000
Subject: [PATCH 3/4] refactor: trim verbose block comments from copy helpers

Removes the multi-line JSDoc-style blocks and tutorial comments
added with the original implementation. Inline "why" notes for
non-obvious decisions are kept (secure-context fallback, Shiki
 fallback, pagefind-ignore rationale); block
banners and prose explanations are dropped.

Net: -37 lines of comments across src/lib/copy.ts,
src/components/CopyButton.astro, src/components/DocsShell.astro,
and src/styles/prose.css. No behavior change.
---
 src/components/CopyButton.astro | 14 ++++----------
 src/components/DocsShell.astro  | 17 +++++------------
 src/lib/copy.ts                 | 32 +++++---------------------------
 src/styles/prose.css            | 10 ++++------
 4 files changed, 18 insertions(+), 55 deletions(-)

diff --git a/src/components/CopyButton.astro b/src/components/CopyButton.astro
index c2690d6b..508b1305 100644
--- a/src/components/CopyButton.astro
+++ b/src/components/CopyButton.astro
@@ -1,14 +1,8 @@
 ---
-// Shared copy-to-clipboard primitive. Two visual variants:
-//   - "icon-swap": copy icon ↔ check icon (used by InstallCommand and docs)
-//   - "label-swap": copy icon + text "Copy" → "Copied" (used by CodeTabs)
-//
-// Host components are responsible for wrapping both the button and the copy
-// source inside an element with `data-copy-root` (e.g. InstallCommand's
-// existing [data-install] wrapper, or CodeTabs's [data-codetabs] wrapper).
-// The script below wires every [data-copy-btn] on the page; it resolves the
-// text to copy from the button's own attributes, falling back to walking the
-// root for a 
/ element.
+// Copy-to-clipboard button. Two variants: icon-swap (copy↔check) and
+// label-swap (icon + "Copy" → "Copied"). The host wraps the button and
+// the copy source inside an element with `data-copy-root` so the auto
+// script below can resolve the text.
 import Icon from "~/components/Icon.astro";
 
 interface Props {
diff --git a/src/components/DocsShell.astro b/src/components/DocsShell.astro
index 539f44c7..fb0a53f7 100644
--- a/src/components/DocsShell.astro
+++ b/src/components/DocsShell.astro
@@ -351,14 +351,11 @@ const markdownUrl = withBase(`/docs/${markdownSlug}.md`);
     for (const heading of headings) observer.observe(heading);
   }
 
-  // Copy buttons on every docs code block. The MDX source is fetched from
-  // fastify/fastify at build time, so we inject at runtime instead of
-  // editing upstream files. Each 
 is wrapped in a positioning div so
-  // the button stays visible across horizontal scroll.
+  // Inject copy buttons into every docs 
. The MDX source is fetched
+  // from fastify/fastify at build time, so we can't edit it.
   const proseRoot = document.querySelector("article.prose-fastify");
   if (proseRoot) {
-    // Inline Lucide paths match @lucide/astro's Copy / Check components.
-    // Plain inline SVG avoids , which is a build-time Astro component.
+    // Inline Lucide paths;  is a build-time Astro component.
     const COPY_SVG =
       '';
     const CHECK_SVG =
@@ -371,8 +368,6 @@ const markdownUrl = withBase(`/docs/${markdownSlug}.md`);
       const wrap = document.createElement("div");
       wrap.className = "docs-pre-wrap";
       wrap.setAttribute("data-copy-root", "");
-      // Defensive: keeps the wrapper out of the Pagefind index even though
-      // the button is textless.
       wrap.setAttribute("data-pagefind-ignore", "");
       pre.parentElement?.insertBefore(wrap, pre);
       wrap.appendChild(pre);
@@ -382,10 +377,8 @@ const markdownUrl = withBase(`/docs/${markdownSlug}.md`);
       btn.className = "docs-copy-btn";
       btn.dataset.copyBtn = "";
       btn.setAttribute("aria-label", "Copy code to clipboard");
-      // Center the icon vertically for single-line snippets so it sits
-      // alongside the code rather than floating in a corner above it.
-      // Shiki wraps every source line in ; we fall back
-      // to splitting textContent by newline if those hooks aren't present.
+      // Center the icon vertically for single-line snippets (Shiki emits
+      // one  per source line; fall back to textContent).
       const lineCount =
         pre.querySelectorAll("span.line").length ||
         (pre.textContent?.split("\n").length ?? 0);
diff --git a/src/lib/copy.ts b/src/lib/copy.ts
index 6f928638..c6dd40d3 100644
--- a/src/lib/copy.ts
+++ b/src/lib/copy.ts
@@ -1,24 +1,17 @@
-// Shared clipboard helper. Returned to true when text was successfully
-// written to the user's clipboard, false otherwise. Callers use the return
-// value to decide whether to flash the "copied" feedback state.
 export const COPY_REVERT_MS = 1600;
 export const COPY_BTN_SELECTOR = "[data-copy-btn]";
 
 export async function copyText(text: string): Promise {
-	// Modern path: async Clipboard API. Gated behind a secure context (HTTPS
-	// or localhost), so we may need to fall back below.
+	// Async Clipboard API is gated behind a secure context (HTTPS or
+	// localhost); fall back to execCommand when unavailable.
 	try {
 		if (navigator.clipboard?.writeText) {
 			await navigator.clipboard.writeText(text);
 			return true;
 		}
 	} catch (_) {
-		// Permission denied or API blocked — fall through to the legacy path.
+		// Permission denied or API blocked — fall through.
 	}
-
-	// Legacy fallback: temporary off-screen textarea + execCommand("copy").
-	// Used on plain-HTTP origins and older browsers where the async Clipboard
-	// API is unavailable or requires a permission prompt we can't satisfy.
 	try {
 		const ta = document.createElement("textarea");
 		ta.value = text;
@@ -35,15 +28,6 @@ export async function copyText(text: string): Promise {
 	}
 }
 
-// Resolve what text a copy button should write to the clipboard.
-//
-// Priority:
-//   1. `data-copy-text` on the button (a literal string passed at render).
-//   2. `data-copy-target` selector resolved against the nearest
-//      [data-copy-root] ancestor (lets the host pick a sub-element, e.g.
-//      CodeTabs selects the hidden raw text of the active tab).
-//   3. The first 
 / 
 /  inside the root, or —
-//      defensively — inside the closest 
 ancestor.
 export function resolveCopyText(btn: HTMLElement): string {
 	const literal = btn.getAttribute("data-copy-text");
 	if (literal != null && literal !== "") return literal;
@@ -69,12 +53,9 @@ export function resolveCopyText(btn: HTMLElement): string {
 	return "";
 }
 
-// Flash the "copied" feedback on a button. The visual is driven by which
-// optional DOM hooks are present in the button:
-//   - icon-swap variant:  [data-copy-idle] + [data-copy-done] spans
-//   - label-swap variant:  a single [data-copy-label] whose textContent
-//     is replaced for `COPY_REVERT_MS`, then reverted.
 export function flashCopyState(btn: HTMLElement): void {
+	// icon-swap: idle/done spans; label-swap: a single label whose text
+	// is swapped. Driven by whichever elements are present in the DOM.
 	const idle = btn.querySelector("[data-copy-idle]");
 	const done = btn.querySelector("[data-copy-done]");
 	if (idle && done) {
@@ -96,9 +77,6 @@ export function flashCopyState(btn: HTMLElement): void {
 	}
 }
 
-// Attach the click → copy behavior to a single button. Static markup
-// (CopyButton.astro) wires all existing buttons via querySelectorAll;
-// DocsShell.astro wires each button it dynamically injects.
 export function wireCopyButton(btn: HTMLElement): void {
 	btn.addEventListener("click", async () => {
 		const text = resolveCopyText(btn);
diff --git a/src/styles/prose.css b/src/styles/prose.css
index b42cb5c8..f389e970 100644
--- a/src/styles/prose.css
+++ b/src/styles/prose.css
@@ -164,11 +164,9 @@
 	background: var(--shiki-light-bg) !important;
 }
 
-/* ============================================================
-   Docs copy button (injected at runtime by DocsShell.astro).
+/* Docs copy button (injected at runtime by DocsShell.astro).
    The wrap is the positioning context so the button stays visible
-   while the inner 
 scrolls horizontally.
-   ============================================================ */
+   while the inner 
 scrolls horizontally. */
 .prose-fastify .docs-pre-wrap {
 	position: relative;
 	margin: 1.5rem 0;
@@ -207,8 +205,8 @@
 .docs-copy-btn [data-copy-done] {
 	color: var(--amber);
 }
-/* Single-line snippets: center the icon alongside the code rather than
-   pinning it to the top-right corner where it floats above empty padding. */
+/* Single-line snippets: pin to the vertical center rather than the
+   top-right corner, where it would float above empty padding. */
 .docs-copy-btn.is-single-line {
 	top: 50%;
 	transform: translateY(-50%);

From 5b18984f0bb4ad321bd08bae8ca0dd01b655761c Mon Sep 17 00:00:00 2001
From: Luca Del Puppo 
Date: Wed, 19 Aug 2026 18:25:46 +0000
Subject: [PATCH 4/4] refactor(docs): simplify code snippet copy buttons

Wrap documentation code blocks during Markdown processing, clone shared copy-button markup, and handle clipboard actions through one delegated listener. Use SVG assets for copy states and initialize the listener globally so documentation buttons remain functional.
---
 astro.config.mjs                |  15 ++--
 src/assets/icons/check.svg      |  13 +++
 src/assets/icons/copy.svg       |  14 +++
 src/components/CopyButton.astro |  27 +++---
 src/components/DocsShell.astro  |  63 +++++--------
 src/components/Icon.astro       |   4 -
 src/layouts/BaseLayout.astro    |   3 +
 src/lib/copy.ts                 | 153 ++++++++++++++++----------------
 src/lib/rehype-code-copy.mjs    |  51 +++++++++++
 src/styles/prose.css            |   8 +-
 10 files changed, 200 insertions(+), 151 deletions(-)
 create mode 100644 src/assets/icons/check.svg
 create mode 100644 src/assets/icons/copy.svg
 create mode 100644 src/lib/rehype-code-copy.mjs

diff --git a/astro.config.mjs b/astro.config.mjs
index 1ab6cd27..5118c6a9 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -7,23 +7,26 @@ import { defineConfig } from "astro/config";
 import astroInference from "astro-inference";
 import pagefind from "astro-pagefind";
 import baseConfig from "./astro.base.config.mjs";
+import { rehypeCodeCopy } from "./src/lib/rehype-code-copy.mjs";
 import { remarkReadingTime } from "./src/lib/remark-reading-time.mjs";
 
+const markdownProcessor = () =>
+	unified({
+		remarkPlugins: [remarkReadingTime],
+		rehypePlugins: [rehypeCodeCopy],
+	});
+
 // https://astro.build/config
 export default defineConfig({
 	site: "https://fastify.dev",
 	base: baseConfig.base,
 	outDir: "./build",
 	markdown: {
-		processor: unified({
-			remarkPlugins: [remarkReadingTime],
-		}),
+		processor: markdownProcessor(),
 	},
 	integrations: [
 		mdx({
-			processor: unified({
-				remarkPlugins: [remarkReadingTime],
-			}),
+			processor: markdownProcessor(),
 		}),
 		sitemap(),
 		pagefind(),
diff --git a/src/assets/icons/check.svg b/src/assets/icons/check.svg
new file mode 100644
index 00000000..90a705f4
--- /dev/null
+++ b/src/assets/icons/check.svg
@@ -0,0 +1,13 @@
+
+  
+
diff --git a/src/assets/icons/copy.svg b/src/assets/icons/copy.svg
new file mode 100644
index 00000000..6ca50be3
--- /dev/null
+++ b/src/assets/icons/copy.svg
@@ -0,0 +1,14 @@
+
+  
+  
+
diff --git a/src/components/CopyButton.astro b/src/components/CopyButton.astro
index 508b1305..ab393d65 100644
--- a/src/components/CopyButton.astro
+++ b/src/components/CopyButton.astro
@@ -1,15 +1,13 @@
 ---
-// Copy-to-clipboard button. Two variants: icon-swap (copy↔check) and
-// label-swap (icon + "Copy" → "Copied"). The host wraps the button and
-// the copy source inside an element with `data-copy-root` so the auto
-// script below can resolve the text.
-import Icon from "~/components/Icon.astro";
+import CheckIcon from "~/assets/icons/check.svg";
+import CopyIcon from "~/assets/icons/copy.svg";
 
 interface Props {
 	text?: string;
 	textSelector?: string;
 	variant?: "icon-swap" | "label-swap";
 	copyLabel?: string;
+	copiedLabel?: string;
 	ariaLabel?: string;
 	class?: string;
 }
@@ -19,6 +17,7 @@ const {
 	textSelector,
 	variant = "icon-swap",
 	copyLabel = "Copy",
+	copiedLabel = "Copied",
 	ariaLabel = "Copy code",
 	class: className = "",
 } = Astro.props;
@@ -29,6 +28,8 @@ const {
 	data-copy-btn
 	data-copy-text={text}
 	data-copy-target={textSelector}
+	data-copy-label={copyLabel}
+	data-copied-label={copiedLabel}
 	aria-label={ariaLabel}
 	class:list={[
 		"inline-flex items-center gap-1.5 rounded-md transition-colors",
@@ -39,25 +40,17 @@ const {
 		variant === "icon-swap" ? (
 			<>
 				
-					
+					
 				
 			
 		) : (
 			<>
-				
-				{copyLabel}
+				
+ + diff --git a/src/components/Icon.astro b/src/components/Icon.astro index 42422743..71c1cec6 100644 --- a/src/components/Icon.astro +++ b/src/components/Icon.astro @@ -7,9 +7,7 @@ import { AlertTriangle, ArrowRight, Braces, - Check, Clock, - Copy, ExternalLink, FileText, Heart, @@ -45,8 +43,6 @@ const lucide: Record = { search: Search, sun: Sun, moon: Moon, - copy: Copy, - check: Check, external: ExternalLink, menu: Menu, close: X, diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 8cbf5da1..c9b8d905 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -79,9 +79,12 @@ const jsonLd = JSON.stringify({