Astro 程式碼區塊複製按鈕:用 Expressive Code 保留程式碼與行號
Astro 的 Markdown code fence 預設會由 Shiki 處理語法上色;若專案已使用 Expressive Code,又另外用 MutationObserver 逐個包裝 <pre>,很容易在換頁、內容替換或外掛調整 HTML 結構後重複加按鈕。這篇把舊做法改成目前 ZeroOne 使用的方式:在 Expressive Code 產生程式碼區塊時插入按鈕,再以一個 document click handler 處理複製。
這個方法的重點是:按鈕屬於程式碼區塊產生流程,不在瀏覽器端掃描後補 DOM;複製時只收集實際程式碼行,避免連行號或按鈕文字一起帶走。
先確認你要擴充的是哪一層
Astro 對 Markdown code fence 內建 Shiki 支援,也能改用 Prism;Expressive Code 是額外的 community integration,適合在程式碼區塊上增加標記、標題或客製化處理。
若你的網站只有一般 Shiki 輸出,先看 markdown.shikiConfig 是否已能滿足需求;不要為了一個按鈕同時引入兩套產生器。以下程式碼假設你已在 astro.config.mjs 設定 Expressive Code plugin。
在 Expressive Code 的渲染結果插入按鈕
建立一個 plugin,使用 postprocessRenderedBlock 走訪 block AST,找到 <pre> 後附加按鈕。這段只負責輸出結構,不在這裡寫瀏覽器事件:
import { definePlugin } from '@expressive-code/core'import type { Element } from 'hast'
export function pluginCustomCopyButton() { return definePlugin({ name: 'Custom Copy Button', hooks: { postprocessRenderedBlock(context) { const visit = (node: Element) => { if (node.tagName === 'pre') { node.children ??= [] node.children.push({ type: 'element', tagName: 'button', properties: { className: ['copy-btn'], type: 'button', 'aria-label': '複製程式碼', }, children: [], } as Element) return }
for (const child of node.children ?? []) { if (child.type === 'element') visit(child) } }
visit(context.renderData.blockAst) }, }, })}在 astro.config.mjs 把它放進 Expressive Code 的 plugins 設定。你也可以在按鈕 children 放 SVG icon;只要 copy-btn class 保留,後面的 click handler 不需要知道 icon 的結構。
用事件委派處理複製與成功狀態
按鈕是在每個 Markdown block 的 HTML 中產生,但事件只需註冊一次。把下面 script 放在包住文章內容的 Astro 元件中:
document.addEventListener('click', async (event) => { const target = event.target as Element | null const button = target?.closest<HTMLButtonElement>('.copy-btn') if (!button) return
const code = Array.from( button.closest('pre')?.querySelectorAll('.code:not(summary *)') ?? [], ) .map((line) => line.textContent) .map((line) => (line === '\n' ? '' : line)) .join('\n')
await navigator.clipboard.writeText(code) button.classList.add('success') window.setTimeout(() => button.classList.remove('success'), 1000)})closest() 很重要:讀者點到按鈕內的 SVG <path> 時,event.target 不是 button 本身;直接檢查 target.classList.contains('copy-btn') 會漏掉這個情況。從按鈕往上找到同一個 <pre>,也能確保多個 code block 不會互相複製內容。
注意:
navigator.clipboard需要安全環境(HTTPS 或 localhost),而且可能被瀏覽器權限或 iframe policy 擋下。若要顯示失敗提示,應在writeText()外包try/catch,不要先把按鈕改成成功狀態。
驗證時要檢查的三件事
- 有行號或註解區塊時,複製結果是否只含程式碼內容。
- 點 SVG icon、按鈕空白處與鍵盤操作時,是否都能觸發相同流程。
- SPA 換頁或重新載入文章後,每個區塊是否只有一個按鈕,且不會重複寫入 clipboard。
採用這種設計時,外掛在建置期插入按鈕,頁面則採事件委派,因此不需要 MutationObserver 監看整份 document。若你正在整理 Astro 的 Markdown 輸出,也可以搭配 Astro Sitemap 設定:如何排除 noindex、分頁與不該送出的路徑 檢查內容層與產出層是否各自只有一個責任。
把按鈕放回程式碼區塊的產生階段,能避免前端反覆掃描 DOM;複製行為則用一次事件委派處理。這兩個責任拆開後,程式碼區塊的外觀、語法上色與複製內容都比較容易各自測試。
常見問題
Q: 一定要用 Expressive Code 才能在 Astro 加複製按鈕嗎?
A: 不一定。Astro 的 Shiki 輸出也能用前端 script 加按鈕;但如果專案已用 Expressive Code 客製程式碼區塊,在外掛的渲染 hook 加結構通常比事後掃描 DOM 更容易維護。
Q: 為什麼複製後出現行號或多餘空白行?
A: 先檢查選取器是否抓到程式碼行的元素,而不是整個 <pre> 的 textContent。實際 class 與 HTML 結構會隨外掛設定不同,應先在瀏覽器 DevTools 確認,再調整選取器。
參考資料:
回報錯字、失效連結,或告訴我你想看的延伸主題。