用 Expressive Code 外掛來標示指令輸出 — Gutter API 以及控制複製按鈕的複製內容
在 Astro 部落格用 Expressive Code 外掛來標示指令輸出:溝槽元素的 API、外掛的執行順序,以及改寫複製按鈕複製的內容。

本頁目錄
引言
Claude Code 在自己的對話紀錄裡,會將執行的指令標記為 IN、結果標為 OUT,我希望把相同的模式帶進這個部落格。這裡的 Bash 輸出一直是用 # 註解的方式來呈現。讀起來沒問題,但:
- 讀者分不出到底是註解還是指令的輸出。 因為兩者都寫成
#。 - 無論是註解還是指令的輸出, 複製按鈕都會一併複製。

解決方法是利用 Expressive Code 的外掛,並在 ``` 圍籬加上 out={…}:
輸入gh api repos/oharu121/<repo-name>/automated-security-fixes輸出{"enabled":true,"paused":false}它用到三個擴充點:
- 溝槽元素負責顯示 IN/OUT 標籤。
- 算繪後的改寫將指令輸出從複製按鈕的對象中移除。
- 特異性的覆寫去掉輸出行上 Shiki 的著色。
這篇文章會介紹這三個擴充點、標記時錯誤的寫法,以及我遇到的兩個障礙。
為什麼我關閉了 Expressive Code 的註解移除
Expressive Code 其實已經處理問題 2 了。它的 frames 外掛在複製終程式碼區塊時,會移除帶有 # 的註解行,而且預設是開啟的:
if (options.removeCommentsWhenCopyingTerminalFrames && isTerminal) { codeToCopy = codeToCopy.replace(/(?<=^|\n)\s*#.*($|\n+)/g, "").trim();}我刻意把它關掉:這裡的 # 大多是帶有實際意義的說明,並非雜訊。關掉之後這些說明就會一起被複製。不過代價是指令的輸出那幾行也一起被複製。
到頭來,兩種設定都不對:
- 關閉時會複製說明,也把指令輸出一起複製。
- 開啟時會移除指令輸出,也把說明一起移除。
輸入和輸出必須分開標示,就像 Claude Code 那樣。輸入和輸出之間需要一條分隔線,複製按鈕就只會複製指令本身以及相關說明,而不會複製指令的輸出。Expressive Code 正好有擴充點可以實現這一點。
我做出的三個擴充點
實現這個功能的是 Expressive Code API 的三個模組,它們在算繪中各自有不同功能。
呈現標籤的溝槽元素
Expressive Code 把行編號外掛所用的溝槽公開出來了,任何外掛都能客製化。addGutterElement 接受三個參數:
renderLine產生每一行的儲存格:IN 標籤、OUT 標籤,或是一個空的標籤。renderPlaceholder遇到引擎自行插入的行時產生儲存格,在這邊我們讓它永遠是空的。renderPhase決定存在多個外掛時往溝槽添加東西的先後順序,在這裡我們用不到。
context.addGutterElement({ renderPhase: "earlier", renderLine: ({ lineIndex }) => { const run = lineIndex === 0 ? firstOut : lineIndex === firstOut ? lines.length - firstOut : 0; if (!run) return h("div.io-label", ""); const label = lineIndex === 0 ? texts.inputLabel : texts.outputLabel; return h("div.io-label", { style: `--io-run:${run}` }, label); }, renderPlaceholder: () => h("div.io-label", ""),});有一個硬性條件會左右後面的 CSS:每一行的溝槽寬度都必須相同,否則程式碼就對不齊。沒有標籤的行仍然會畫出儲存格,並且寬度是指定的,與其承載的內容無關。
標籤走的是 PluginTexts,和 frames 外掛處理自己字串時用的是同一個類別,因此三個語系只要在 astro.config.mjs 註冊一次。getBlockLocale 本來就會從檔名解析語系,所以日文頁面不必額外做什麼就會顯示「入力」和「出力」。
外掛的執行順序,以及複製按鈕的改寫
註冊的外掛會在內建外掛之後執行:
const pluginsWithDefaults = [...pluginsToPrepend, ...baseConfig.plugins || []];pluginsToPrepend 的內容依序是 Shiki、text markers、frames。掛鉤依外掛順序執行,所以等到 plugins: [pluginIo()] 的掛鉤啟動時,這三個都已經處理過同一個區塊了。
複製按鈕的改寫就是建立在這個順序上。這個外掛從來不負責產生複製按鈕;因為 frames 早就做好了。postprocessRenderedBlock 只是伸手進算繪好的樹裡,替換掉複製的內容。
annotateCode 負責驗證。在它之前的階段,外掛還可以增減區塊的行數;它是這些階段跑完後的第一個掛鉤,所以它檢查過的行號不會再被後面的外掛改掉。
覆寫 Shiki 著色的特異性
輸出行會去掉語法著色,而且用一般的類別選擇器就辦得到,不需要 !important。Shiki 不會直接在 span 上寫顏色:它用的是一個行內的自訂屬性,再由另一條規則把那個變數改成實際顯示的顏色:
.ec-line :where(span[style^='--']:not([class])) { color: var(--0, inherit);}:where() 對特異性沒有任何貢獻,所以那條規則只算一個類別。這裡的選擇器有三個類別、一個屬性和一個元素,特異性比它高,會直接覆寫掉:
.ec-line.is-out .code span[style^="--"] { color: inherit; background-color: transparent; font-style: inherit; font-weight: inherit;}值得花這個工夫,因為指令的輸出行本來就應該是一個顏色。Bash 的斷詞會把 JSON 回應的標點著上各種顏色,替輸出捏造出它其實沒有的結構。鎖定 span[style^="--"],就不會觸發 text markers 外掛自己的 span。
驗證以及純文字版本
這兩個都不在算繪的範圍。而且他們都是等到真的在文章實際使用這些標記時,才會浮現的問題。
標記為什麼只接受結尾的那幾行
有六種寫法會讓這個建置失敗,第一種是順手就會寫出來的那種:把好幾組指令和結果放進同一個區塊。結果外框上排成 IN、OUT、IN、OUT、IN、OUT 全部擠在同個區塊裡。我不喜歡這樣,同一個區塊只能有一組 IN/OUT。
這是一條硬性規定而不是讓 AI 自行判斷,因為 IN/OUT 交錯的呈現真的很醜:
if (!outLines.has(lines.length - 1) || outLines.size !== lines.length - firstOut) { throw new Error( "`out=` marks one command and its result, so the output must be the " + "block's trailing lines. Split a block that pairs several commands " + "with their results into one block per pair. …", );}以三行的區塊來說,另外還有五種寫法會讓建置失敗:
| 標記 | 失敗的原因 |
|---|---|
out={x} | 並非數字。 |
out={4-2} | 範圍不可以反過來。 |
out={9} | 這超出區塊的結尾。 |
out={1-3} | 每一行都是輸出,就沒有指令了。 |
out=2 | 沒有大括號,getRanges 不會回傳它。 |
最後一種最不容易發現。沒有大括號的標記會被解析成另一種中繼選項,回傳的是一個空清單,看起來就跟一個從未要求標記的圍籬一模一樣。metaOptions.list("out") 不分種類,可以把這兩者分開。
之所以要大聲失敗,是因為一個什麼都沒對上的標記會把指令的輸出送回剪貼簿,這完全違背了我們當初的目的。
讓純文字版本保持可讀
圍籬上的標記會外洩到你所有其他的輸出。 這裡每篇文章都有一份 .md 純文字版本用於搜尋優化。而 out={…} 在純 Markdown 裡沒有任何意義。
所以產生器做兩件事:把 out={…} 從圍籬拿掉,並且把被標記的那幾行加上 #。不加 # 的話,輸出那一行在純文字版本裡看起來就像第二道指令。
這個轉換是直接對原始內容執行的,所以被引用在另一段圍籬裡的圍籬不受影響。說明這個語法的文章會把範例放進四個反引號的區塊,外層的圍籬會先被比對到,裡面的 out= 就不會被改寫:
```bash out={2}gh api repos/oharu121/<repo-name>/automated-security-fixes{"enabled":true,"paused":false}```我遇到的兩個障礙
其中一個外觀看起來正確、但複製到的內容卻是錯的。另一個則一看就知道不對,但原因卻藏得很深。
為什麼寫入 data-code 反而多出一個屬性
複製按鈕的改寫裡有個錯誤。把新內容寫進 properties["data-code"] 時,並不會取代原本那一個,而是生成第二個 data-code 屬性,而 HTML 遇到重複屬性時會採用先出現的那個。於是按鈕就這樣悄悄地繼續複製指令的輸出。
hastscript 會把 data-* 的名稱正規化成駝峰式,所以 frames 的 h("button", { "data-code": … }) 在樹裡會變成 dataCode,與字串形式的 "data-code" 不同,因此兩個都會被輸出:
<button data-code="gh api …{"enabled":true}" data-code="gh api …">外觀是正確的。 頁面算繪出來了,按鈕也能用,只是複製到的東西是錯的。我會發現是因為把一個區塊直接丟進 Expressive Code 裡跑,數了輸出 HTML 中的 data-code,發現有兩個。
修正的方法是覆寫實際存在的那個鍵,而不是先假設到底是哪一個:
const key = "data-code" in button.properties ? "data-code" : "dataCode";button.properties[key] = inputCode.replace(/\n/g, "\x7F");要複製的內容是從區塊自己的行重新組出來的,絕對不能從既有的屬性讀回去。frames 放進去的值取決於 removeCommentsWhenCopyingTerminalFrames,讀回去就等於把這個外掛的正確性綁在那個設定維持關閉上。\x7F 是 frames 自己用的換行替代字元,寫入剪貼簿之前會由它的用戶端腳本換回來。
以區塊本身為基準決定溝槽的間距
分隔線周圍的間距取自 Expressive Code 自己的 codePaddingBlock,而不是這個部落格的間距尺標。第一版用的是 --space-3 這個 12px 的設計權杖,但一眼就覺得不對勁:兩排文字離分隔線都比離區塊邊緣來得更近。
從數字上也看得出來,codePaddingBlock 是 1rem,所以每一排距離區塊邊緣 16px,距離分隔線只有 6px。取區塊本身內距的兩倍、再把線置中,這樣四段間距就都相等了:
.ec-line.is-io-boundary { padding-block-start: calc(2 * var(--ec-codePaddingBlock));}
.ec-line.is-io-boundary::before { content: ""; position: absolute; inset-inline: 0; top: var(--ec-codePaddingBlock); height: 1px;}剩下一半的工作,是讓標籤落在多行區段的正中間。溝槽元素逐行算繪,所以我把標籤先畫在區段的第一行,再往下移半個區段的距離:
.io-label { height: 100%; display: flex; align-items: center; transform: translateY(calc((var(--io-run, 1) - 1) * 50%));}這裡真正重要的是 height: 100%。 translateY 的百分比是以元素自己的高度為基準解析的,而 .gutter 是被拉伸的格線項目,剛好是一行程式碼的高度。如果交給內容決定,標籤就只有 --font-size-1 的一行高,每一段多行的內容都會位移不足。實際量測各段中央的偏移量:一行、兩行、三行、四行都是 0px。
總結
Expressive Code 的外掛,能客製化的範圍比 astro.config.mjs 裡那些樣式選項看起來的還要廣。撐起這個功能的是三個擴充點:
addGutterElement把內容放進行編號外掛所用的同一個溝槽,只有一個限制:每個儲存格寬度相同。- 外掛的執行順序讓你的
postprocessRenderedBlock排在內建的之後,因此外框和複製按鈕都已算繪完成,隨便你改。 - 核心樣式表裡特異性為零的
:where(),意味著一般的類別選擇器就能覆寫 Shiki 的著色,不需要!important。
有兩件事花費了我很多時間:
- 重複的
data-code屬性。 外觀看起來是對的,但複製到的文字卻是錯的;唯一能發現的是去數輸出 HTML 裡的這個屬性。 - 16px 對上 6px 的間距。 元件內部的間距如果來自外部的設計尺度,而不是它自己本身的內距,就會造成這樣的結果。





