1075 字
5 分鐘

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,不要先把按鈕改成成功狀態。

驗證時要檢查的三件事#

  1. 有行號或註解區塊時,複製結果是否只含程式碼內容。
  2. 點 SVG icon、按鈕空白處與鍵盤操作時,是否都能觸發相同流程。
  3. 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 確認,再調整選取器。

參考資料:

Astro Docs:Syntax Highlighting

Expressive Code:Plugin API

Astro 程式碼區塊複製按鈕:用 Expressive Code 保留程式碼與行號
https://laplusda.com/posts/astro-code-copy-button/
作者
Zero
發佈於
2024-04-27
許可協議
CC BY-NC-SA 4.0
這篇文章有幫助嗎?

回報錯字、失效連結,或告訴我你想看的延伸主題。