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 元件中:
const resetTimers = new WeakMap<HTMLButtonElement, number>()
document.addEventListener('click', async (event) => { if (!(event.target instanceof Element)) return const button = event.target.closest<HTMLButtonElement>('button.copy-btn') if (!button || button.disabled) return const lines = button.closest('pre')?.querySelectorAll('.code:not(summary *)') if (!lines?.length) return const code = Array.from(lines) .map((line) => line.textContent ?? '') .map((line) => (line === '\n' ? '' : line)) .join('\n')
window.clearTimeout(resetTimers.get(button)) button.classList.remove('success', 'failure') button.disabled = true button.setAttribute('aria-label', '正在複製程式碼') try { if (!navigator.clipboard?.writeText) { throw new Error('Clipboard API unavailable') } await navigator.clipboard.writeText(code) button.classList.add('success') button.setAttribute('aria-label', '程式碼已複製') } catch { button.classList.add('failure') button.setAttribute('aria-label', '複製失敗,請手動選取程式碼') } finally { button.disabled = false resetTimers.set(button, window.setTimeout(() => { button.classList.remove('success', 'failure') button.setAttribute('aria-label', '複製程式碼') resetTimers.delete(button) }, 1500)) }})closest() 很重要:讀者點到按鈕內的 SVG <path> 時,event.target 不是 button 本身;直接檢查 target.classList.contains('copy-btn') 會漏掉這個情況。從按鈕往上找到同一個 <pre>,也能確保多個 code block 不會互相複製內容。
依 MDN 的 Clipboard.writeText(),API 需要安全環境,寫入不被允許時可能拒絕 Promise。上面在寫入完成後才設 success,失敗則設 failure;正在寫入時暫時停用按鈕,避免同一按鈕的兩次請求互相覆寫狀態。
failure class 需要另外提供可見樣式,例如在按鈕旁放持續存在的提示;aria-label 是名稱,不應當成螢幕閱讀器一定會即時播報的通知。若產品需要即時播報,另外設計 aria-live status 區域。這段 DOM/clipboard 程式碼僅檢閱,尚未在瀏覽器驗證權限與無障礙行為。
舊成功提示不能提早清掉新狀態
假設第一次複製成功後啟動 1 秒 timer,接著使用者又點一次;如果沒有清掉舊 timer,第一次的 timer 會在第二次操作後把成功狀態移除。範例用 WeakMap 記住每個按鈕的 timer,下一次操作前先取消,再在完成時建立新 timer。
把事件 handler 放在 Astro 元件的 processed <script>,不要每次換頁都再次呼叫 document.addEventListener。若改用 is:inline、data-astro-rerun 或自訂換頁初始化,必須自己避免重複綁定。請依網站實際使用的 router 檢查;ZeroOne 目前使用的 Swup 與 Astro ClientRouter 不是相同套件,不要直接套用另一套的 lifecycle event。
驗證成功、失敗與換頁行為
- 有行號或註解區塊時,複製結果是否只含程式碼內容。
- 點 SVG icon、按鈕空白處與鍵盤操作時,是否都能觸發相同流程。
- 連續點擊時等待中的按鈕是否停用;第二次成功狀態是否不會被第一次 timer 提早清掉。
- 在測試環境拒絕剪貼簿權限,確認顯示失敗提示且沒有
success;不支援 API 時仍能手動選取程式碼。 - SPA 換頁、返回與重新載入文章後,每個區塊是否只有一個按鈕,單次點擊是否只呼叫一次 clipboard;另外檢查空白行與摺疊區塊。
上述是待執行的瀏覽器驗收,不是已通過的測試報告。文章程式碼的 TypeScript 語法檢查也不能替代真實權限、焦點和換頁測試。
採用這種設計時,外掛在建置期插入按鈕,頁面則採事件委派,因此不需要 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 確認,再調整選取器。
參考資料:
Astro Docs:Syntax Highlighting