用 Expressive Code 外掛來標示指令輸出 — Gutter API 以及控制複製按鈕的複製內容

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

白底上一個深色程式碼視窗,標題列為紫色,程式碼以灰色與綠色長條表示,其中一段被高亮,下半部有一支彩虹色刷毛的畫筆掃過
本頁目錄

引言

Claude Code 在自己的對話紀錄裡,會將執行的指令標記為 IN、結果標為 OUT,我希望把相同的模式帶進這個部落格。這裡的 Bash 輸出一直是用 # 註解的方式來呈現。讀起來沒問題,但:

  1. 讀者分不出到底是註解還是指令的輸出。 因為兩者都寫成 #。
  2. 無論是註解還是指令的輸出, 複製按鈕都會一併複製。

Claude Code 的對話紀錄:第一行是 shell 指令,標示為 IN;水平分隔線下方是它的兩行輸出,標示為 OUT。兩個標籤都在左側細長的溝槽裡

這是使用 Claude Code 時的對話紀錄。IN/OUT 標籤在程式碼的左側將指令與輸出分開,而這正是我想借用的地方。

解決方法是利用 Expressive Code 的外掛,並在 ``` 圍籬加上 out={…}:

終端機視窗
輸入
gh api repos/oharu121/<repo-name>/automated-security-fixes
輸出
{"enabled":true,"paused":false}

它用到三個擴充點:

  1. 溝槽元素負責顯示 IN/OUT 標籤。
  2. 算繪後的改寫將指令輸出從複製按鈕的對象中移除。
  3. 特異性的覆寫去掉輸出行上 Shiki 的著色。

這篇文章會介紹這三個擴充點、標記時錯誤的寫法,以及我遇到的兩個障礙。

為什麼我關閉了 Expressive Code 的註解移除

Expressive Code 其實已經處理問題 2 了。它的 frames 外掛在複製終程式碼區塊時,會移除帶有 # 的註解行,而且預設是開啟的:

@expressive-code/plugin-frames/dist/index.js
if (options.removeCommentsWhenCopyingTerminalFrames && isTerminal) {
codeToCopy = codeToCopy.replace(/(?<=^|\n)\s*#.*($|\n+)/g, "").trim();
}

我刻意把它關掉:這裡的 # 大多是帶有實際意義的說明,並非雜訊。關掉之後這些說明就會一起被複製。不過代價是指令的輸出那幾行也一起被複製。

一個字元承擔兩種意義兩段終端機片段並排。兩者都有一行以 # 開頭。左邊那一行是讀者想連同指令一起貼上的說明;右邊那一行則是指令印出的 JSON。每段下方各有一個方框,顯示複製按鈕放進剪貼簿的內容,而兩個方框都含有 # 那一行。右邊被標示為錯誤,因為印出的結果被當成指令的一部分貼了進去。# 作為指示# 先啟用 APIgcloud services enable aiplatform複製按鈕放進剪貼簿的內容# 先啟用 APIgcloud services enable aiplatform✓正是所要的# 作為輸出gh api repos/OWNER/REPO/automated-security-fixes# {"enabled":true,"paused":false}複製按鈕放進剪貼簿的內容gh api repos/OWNER/REPO/automated-security-fixes# {"enabled":true,"paused":false}✗這不是指令同一個字元、同一種處理,用意卻相反
兩個區塊裡是同一個字元。註解移除關閉時,複製按鈕對兩者一視同仁,因此讀者複製的是一段尾端帶著 JSON 回應的指令。

到頭來,兩種設定都不對:

  • 關閉時會複製說明,也把指令輸出一起複製。
  • 開啟時會移除指令輸出,也把說明一起移除。

輸入和輸出必須分開標示,就像 Claude Code 那樣。輸入和輸出之間需要一條分隔線,複製按鈕就只會複製指令本身以及相關說明,而不會複製指令的輸出。Expressive Code 正好有擴充點可以實現這一點。

我做出的三個擴充點

實現這個功能的是 Expressive Code API 的三個模組,它們在算繪中各自有不同功能。

呈現標籤的溝槽元素

Expressive Code 把行編號外掛所用的溝槽公開出來了,任何外掛都能客製化。addGutterElement 接受三個參數:

  1. renderLine 產生每一行的儲存格:IN 標籤、OUT 標籤,或是一個空的標籤。
  2. renderPlaceholder 遇到引擎自行插入的行時產生儲存格,在這邊我們讓它永遠是空的。
  3. renderPhase 決定存在多個外掛時往溝槽添加東西的先後順序,在這裡我們用不到。
src/lib/expressive-code-io.ts
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", ""),
});
溝槽的哪一部分由哪個函式產生右側是一個算繪後的程式碼區塊,沿著它的左緣是溝槽欄。溝槽在指令旁有一個輸入儲存格,在兩行輸出旁有一個置中的輸出儲存格,其餘則是空儲存格。左側三個註記指向其中。renderLine 指向實際存在的那幾行,說明它會回傳輸入標籤、輸出標籤或一個空儲存格。renderPlaceholder 指向以虛線繪製的第四列,那是引擎插入而非來自原始檔的行。renderPhase 指向整個溝槽欄,說明當其他外掛也加入儲存格時,由它決定這一欄的位置。輸入git count-objects -vH輸出count: 2081size: 16.46 MiB插入的行renderPhase當其他外掛也加入儲存格時,這一欄該擺在哪裡。renderLine每個實際存在的行都會呼叫。回傳輸入標籤、輸出標籤,或一個空儲存格。renderPlaceholder只有引擎自行插入的行才會呼叫。
每行呼叫一次並回傳一個儲存格。這裡我們真正需要的只有 renderLine;renderPlaceholder 只在引擎自己插入的行才會觸發,而我們並沒有要讓 renderPhase 排序的對象。

有一個硬性條件會左右後面的 CSS:每一行的溝槽寬度都必須相同,否則程式碼就對不齊。沒有標籤的行仍然會畫出儲存格,並且寬度是指定的,與其承載的內容無關。

標籤走的是 PluginTexts,和 frames 外掛處理自己字串時用的是同一個類別,因此三個語系只要在 astro.config.mjs 註冊一次。getBlockLocale 本來就會從檔名解析語系,所以日文頁面不必額外做什麼就會顯示「入力」和「出力」。

外掛的執行順序,以及複製按鈕的改寫

註冊的外掛會在內建外掛之後執行:

expressive-code/dist/index.js
const pluginsWithDefaults = [...pluginsToPrepend, ...baseConfig.plugins || []];

pluginsToPrepend 的內容依序是 Shiki、text markers、frames。掛鉤依外掛順序執行,所以等到 plugins: [pluginIo()] 的掛鉤啟動時,這三個都已經處理過同一個區塊了。

內建外掛先執行,所以複製按鈕早已存在由左至右的管線,含四個外掛方框。前三個 Shiki、text markers、frames 被歸為「引擎預先插入」。第四個 pluginIo 是在 Astro 設定中註冊的,排在它們之後。第四個方框下方有兩個說明框,指出它所使用的掛鉤:行與中繼資料皆已確定的 annotateCode,以及外框與複製按鈕都已算繪成一棵外掛可修改之樹的 postprocessRenderedBlock。引擎預先插入Shiki以行內變數上色Text markersins / del / 標記Frames外框、標題、複製按鈕plugins: [pluginIo()]pluginIo溝槽、酬載、顏色annotateCode行與中繼資料皆已確定。驗證 out={…},加入溝槽元素。postprocessRenderedBlock外框與複製按鈕都已算繪完成。直接就地修改 HAST 樹。
這裡重要的掛鉤有兩個。在 annotateCode 這個階段,區塊的行與中繼資料都已經確定;在 postprocessRenderedBlock 這個階段,外框和它的複製按鈕已經是一棵後續外掛可以修改的 HAST 樹。

複製按鈕的改寫就是建立在這個順序上。這個外掛從來不負責產生複製按鈕;因為 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 交錯的呈現真的很醜:

src/lib/expressive-code-io.ts
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 …&#x7f;{&quot;enabled&quot;:true}" data-code="gh api …">

外觀是正確的。 頁面算繪出來了,按鈕也能用,只是複製到的東西是錯的。我會發現是因為把一個區塊直接丟進 Expressive Code 裡跑,數了輸出 HTML 中的 data-code,發現有兩個。

修正的方法是覆寫實際存在的那個鍵,而不是先假設到底是哪一個:

src/lib/expressive-code-io.ts
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;
}
溝槽必須同時滿足的條件一個左側帶有溝槽欄的程式碼區塊。第一行是指令,標示為輸入。水平分隔線之下有四行輸出,共用一個輸出標籤。圖上有三處註記:不論是否帶有標籤,每個溝槽儲存格的寬度都相同;分隔線上下的間距是區塊本身垂直內距的兩倍,上下各為十六像素;輸出標籤與其四行內容的中央同高,而不是位於第一行旁邊。輸入git count-objects -vH16px16px輸出count: 2081size: 16.46 MiBin-pack: 240size-pack: 437.45 KiB間距為 codePaddingBlock 的 2 倍,分隔線置於正中標籤位於整段的中央,而非第一行旁無論有沒有標籤,每個儲存格寬度都相同
溝槽必須同時滿足的三個條件。每個儲存格寬度相同、分隔線上下的間距和區塊本身的內距一致,以及標籤位於整段的中央而不是置頂。

剩下一半的工作,是讓標籤落在多行區段的正中間。溝槽元素逐行算繪,所以我把標籤先畫在區段的第一行,再往下移半個區段的距離:

.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 裡那些樣式選項看起來的還要廣。撐起這個功能的是三個擴充點:

  1. addGutterElement 把內容放進行編號外掛所用的同一個溝槽,只有一個限制:每個儲存格寬度相同。
  2. 外掛的執行順序讓你的 postprocessRenderedBlock 排在內建的之後,因此外框和複製按鈕都已算繪完成,隨便你改。
  3. 核心樣式表裡特異性為零的 :where(),意味著一般的類別選擇器就能覆寫 Shiki 的著色,不需要 !important。

有兩件事花費了我很多時間:

  1. 重複的 data-code 屬性。 外觀看起來是對的,但複製到的文字卻是錯的;唯一能發現的是去數輸出 HTML 裡的這個屬性。
  2. 16px 對上 6px 的間距。 元件內部的間距如果來自外部的設計尺度,而不是它自己本身的內距,就會造成這樣的結果。

參考連結

分享這篇文章