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

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

- Source: https://oharu121.com/zh-tw/blog/expressive-code-in-out-gutter-labels-copy-button-plugin/
- Published: 2026-09-10T20:34:34+09:00
- Tags: Astro, TypeScript, Expressive Code

---
## 引言

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

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

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

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

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

```bash
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 外掛在複製終程式碼區塊時，會移除帶有 `#` 的註解行，而且預設是開啟的：

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

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

*Figure — OverloadedHash: 兩個區塊裡是同一個字元。註解移除關閉時，複製按鈕對兩者一視同仁，因此讀者複製的是一段尾端帶著 JSON 回應的指令。*

到頭來，**兩種設定都不對**：

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

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

## 我做出的三個擴充點

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

### 呈現標籤的溝槽元素

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

1. `renderLine` 產生每一行的儲存格：IN 標籤、OUT 標籤，或是一個空的標籤。
2. `renderPlaceholder` 遇到引擎自行插入的行時產生儲存格，在這邊我們讓它永遠是空的。
3. `renderPhase` 決定存在多個外掛時往溝槽添加東西的先後順序，在這裡我們用不到。

```ts title="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", ""),
});
```

*Figure — GutterApi: 每行呼叫一次並回傳一個儲存格。這裡我們真正需要的只有 renderLine；renderPlaceholder 只在引擎自己插入的行才會觸發，而我們並沒有要讓 renderPhase 排序的對象。*

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

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

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

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

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

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

*Figure — PluginOrder: 這裡重要的掛鉤有兩個。在 annotateCode 這個階段，區塊的行與中繼資料都已經確定；在 postprocessRenderedBlock 這個階段，外框和它的複製按鈕已經是一棵後續外掛可以修改的 HAST 樹。*

複製按鈕的改寫就是建立在這個順序上。**這個外掛從來不負責產生複製按鈕**；因為 frames 早就做好了。`postprocessRenderedBlock` 只是伸手進算繪好的樹裡，替換掉複製的內容。

`annotateCode` 負責驗證。在它之前的階段，外掛還可以增減區塊的行數；它是這些階段跑完後的第一個掛鉤，所以**它檢查過的行號不會再被後面的外掛改掉**。

### 覆寫 Shiki 著色的特異性

輸出行會去掉語法著色，而且**用一般的類別選擇器就辦得到，不需要 `!important`**。Shiki 不會直接在 span 上寫顏色：它用的是一個行內的自訂屬性，再由另一條規則把那個變數改成實際顯示的顏色：

```css
.ec-line :where(span[style^='--']:not([class])) {
  color: var(--0, inherit);
}
```

**`:where()` 對特異性沒有任何貢獻**，所以那條規則只算一個類別。這裡的選擇器有三個類別、一個屬性和一個元素，特異性比它高，會直接覆寫掉：

```css
.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 交錯的呈現真的很醜：

```ts title="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=` 就不會被改寫：

````markdown
```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"` 不同，因此兩個都會被輸出：

```html
<button data-code="gh api …&#x7f;{&quot;enabled&quot;:true}" data-code="gh api …">
```

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

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

```ts title="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**。取區塊本身內距的兩倍、再把線置中，這樣四段間距就都相等了：

```css
.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;
}
```

*Figure — GutterAnatomy: 溝槽必須同時滿足的三個條件。每個儲存格寬度相同、分隔線上下的間距和區塊本身的內距一致，以及標籤位於整段的中央而不是置頂。*

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

```css
.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 的間距。** 元件內部的間距如果來自外部的設計尺度，而不是它自己本身的內距，就會造成這樣的結果。

## 參考連結

- [Expressive Code：外掛 API 參考，含 `addGutterElement` 與掛鉤清單](https://expressive-code.com/reference/plugin-api/)
- [Expressive Code：frames 外掛與 `removeCommentsWhenCopyingTerminalFrames` 選項](https://expressive-code.com/key-features/frames/)
- [MDN：HTML 解析時重複的屬性以第一個為準](https://developer.mozilla.org/zh-TW/docs/Web/API/Element/setAttribute)
- [MDN：`:where()` 及其為零的特異性](https://developer.mozilla.org/zh-TW/docs/Web/CSS/:where)
- [hastscript：把 `data-*` 屬性名稱正規化為駝峰式屬性](https://github.com/syntax-tree/hastscript)
