# 在 Astro 部落格導入 Expressive Code — 檔名分頁、檔案圖示，以及 97 個讀不出來的程式碼圍籬

> 在 Astro 7 導入 Expressive Code 的紀錄：檔名分頁、依主題調整的檔案圖示、Sätteri 處理器的陷阱，以及被 Shiki 當成語言名稱的 97 個程式碼圍籬。

- Source: https://oharu121.com/zh-tw/blog/astro-expressive-code-snippet-ui-filename-tabs-file-icons/
- Published: 2026-08-12T22:12:24+09:00
- Tags: Astro, TypeScript, Expressive Code

---
## 引言

這個網站每次建置都會印出上百行像這樣的訊息：
`[Shiki] The language "ts:src/lib/blog.ts" doesn't exist, falling back to "plaintext"`。
我一直把它當成別人的工具產生的雜訊。並不是。**產生這些訊息的圍籬寫法，就寫在我自己的風格指南裡**，意思是我寫的每一篇文章都忠實地重現了這個 bug，而且從這個網站存在以來一直如此。

真正的損害不是那些警告。有 97 個程式碼區塊被算繪成沒有格式的純文字，而每一個標上的檔名，從來沒有真正抵達頁面。在一個內容大半是程式碼的部落格上，圍籬該做的兩件事都被靜靜地丟掉了。

修正的方式是導入 Expressive Code，加上每一個圍籬一行的改寫。走到那裡之前繞了四次遠路：一條從未生效的 CSS 規則、一次因為少了 URI 前綴而失敗的建置、一個代理連續兩次判斷錯誤的診斷，以及一個它「修好」了、但從來不存在的 bug。

本文整理 Astro 部落格的程式碼片段 UI 實際上該做到什麼、哪些部分外掛會替你處理，以及哪些部分會反過來跟你作對。

## 一個程式碼區塊要承載什麼

在選擇任何方案之前，先把組成元件講清楚是值得的，因為本文後面都會用到這些名稱。

*Figure — BlockAnatomy: 檔案會有分頁與名稱。shell 工作階段會有視窗圓點、沒有名稱。形狀本身就是訊號。*

依照後來發現的重要性排序，需求有四項：

1. **語法高亮**，這是 Astro 透過 Shiki 免費提供的。
2. **檔名**，當這段程式碼的重點就是它是哪個檔案的時候。
3. **兩種主題。** 這個網站以 `light-dark()` 切換亮色與暗色，忽略這件事的程式碼區塊，在白色頁面上就是一塊黑色長方形。
4. **複製按鈕**，因為這些程式碼片段本來就是要拿去執行的。

Astro 提供第一項。剩下三項才是工作所在。

## 圍籬的寫法是 Zenn 的慣例

這個網站把圍籬寫成 ` ```ts:src/lib/blog.ts `。那是 [Zenn](https://zenn.dev) 與 Qiita 的寫法，打起來確實順手。但在 Astro 的流程裡沒有任何一環會解析它。Astro 把整個 token 當成*語言名稱*交給 Shiki，Shiki 找不到叫做 `ts:src/lib/blog.ts` 的語言，於是退回純文字。

<Figure>
  <WhatTheReaderLost />
  <Fragment slot="caption">左欄是當時實際上線的樣子。寫在圍籬裡的檔名，在 HTML 的任何地方都沒有出現過。</Fragment>
</Figure>

重點在於，這**不是一個關於外觀的警告**。拿圍籬宣告過的檔名去搜尋建置輸出，結果是零筆。那些區塊還帶著 `data-language="plaintext"`，所以高亮不是標錯，而是根本沒有作用。

以這種寫法寫成的圍籬，散佈在 3 個語系、11 種語言、共 18 個檔案裡，總共 94 個：

```bash
grep -rhoE '^```[A-Za-z0-9_+#-]+:' src/content/blog | sort | uniq -c | sort -rn
```

```text
  48 ```ts:
  12 ```python:
  10 ```css:
   6 ```toml:
   5 ```astro:
```

另外還有 3 個用了第四種寫法，把檔名寫成第一行的註解，所以總數是 97 個。

**規則比圍籬本身更重要。** 不改 `house-style.md` 而只修好這 97 個區塊，等於什麼都沒買到，因為下一篇文章還是會照舊規則來寫。

## 選擇由什麼來算繪這個區塊

代理列出了三個選項。寫一個小的 remark 外掛可以把 `lang:path` 拆成語言與標題，保留現有寫法，一篇文章都不用動。採用 [Expressive Code](https://expressive-code.com) 則要把全部 97 個圍籬改寫成 `title="…"`，這是 Docusaurus、rehype-pretty-code 與 Expressive Code 都能解析的形式。

我選了 Expressive Code。抱著一個生態系裡沒有工具會解析的自訂寫法，正是這個問題的起點；而這個外掛不用寫一行算繪程式碼，就帶來檔名標頭、兩種主題、複製按鈕與終端機外框。

```js title="astro.config.mjs"
integrations: [
	expressiveCode({
		themes: ['github-light', 'github-dark'],
		themeCssSelector: (theme) => `[data-theme='${theme.type}']`,
	}),
	mdx(),
]
```

其中兩行值得停下來看。`expressiveCode()` 必須排在 `mdx()` **前面**，整合套件本身會檢查這一點。而 `themeCssSelector` 一定要覆寫，因為預設值讀的是 `theme.name`，會輸出 `[data-theme='github-dark']`，但這個網站的切換器寫入的是 `light` 與 `dark`。

### 沒有做的分頁群組

Expressive Code 沒有分頁群組，而代理反對加上它。它的理由站得住腳：這個部落格裡有 12 處是兩個程式碼區塊之間沒有任何文字、直接相鄰的，而**其中沒有任何一組是替代選項**。它們是一個檔案與使用它的指令、一個指令與它的輸出、兩個必須彼此吻合的設定檔。分頁是給「A 或 B」用的。把「A 和 B」的其中一半藏在點擊之後，會毀掉那個段落正在做的對照。

## 那個會讓它什麼都不做的陷阱

如果你在用 Astro 7，這是最值得知道的一段。

Astro 7 把預設的 Markdown 處理器從 unified 換成了 Sätteri，而 `@astrojs/mdx` 只有在處理器是 unified 時才會合併 `markdown.rehypePlugins`。也就是說，一個照文件所寫的方式註冊自己的整合套件，在預設的 Astro 7 環境裡**根本什麼都碰不到**。它不會警告。它會安裝成功，然後什麼都不算繪。

*Figure — FenceToDom: 右邊那一支才是預設路徑。只認識左邊那條路的整合套件，會在不出聲的情況下失敗。*

在決定採用 Expressive Code 之前，代理沒有相信文件，而是直接檢查了已發布的套件內容：

```bash
grep -c -i satteri node_modules/astro-expressive-code/dist/index.js
```

它有一個 `isSatteriProcessor` 分支，會寫入 Sätteri 真正會讀取的 `options.hastPlugins`。如果這個檢查是空的，整個遷移會安裝成功、而畫面上什麼都不會改變。那種失敗方式，我寧可用十秒找出來，也不想在瀏覽器裡才發現。

## 敗給外掛自己 CSS 的三條規則

Expressive Code 會把自己的區塊與外部 CSS 隔離，而那層隔離比看起來更強勢。網站這邊有三條規則各自輸掉，而且**每一次落敗在建置輸出裡都看不見**。

*Figure — ThreeCssFights: 三者中有兩者是特異性同分。同分由哪一份樣式表較晚載入來決定。*

第一條才是關鍵。這裡的文章會引用這個儲存庫自己的原始碼，而那些原始碼是用定位點縮排的。先前的修正設過 `tab-size: 2`，讓這些片段以一個縮排單位、而不是八欄來算繪。遷移計畫把那條規則改寫到 `.expressive-code pre` 上，然後它就安靜地失效了：

```css title="src/styles/global.css"
/* 會輸的那條。Expressive Code 會重設所有後代元素的 tab-size */
.expressive-code pre {
	tab-size: 2;
}
```

沒有任何東西失敗。`pnpm check` 與 `pnpm build` 都是綠的。唯一讓它浮上檯面的方式，是在真正的瀏覽器裡量測計算後樣式，看到 `tabSize` 回傳 `8`。在執行期注入一條一模一樣的規則同樣無效，而行內樣式卻有效，這才確認問題出在特異性，而不是載入順序。

會贏的那條規則，重複了外掛自己的 `:not()`：

```css title="src/styles/global.css"
.expressive-code pre:not(:is(svg, svg *)) {
	tab-size: 2;
}
```

這個選擇器很不好看，而且是刻意的。Expressive Code 沒有提供 tab-size 的設定選項，所以要保住定位點，唯一的辦法就是在特異性上壓過一條專門為了擋住外部 CSS 而寫的規則。

**可以一般化的部分是：建置是綠的，不足以證明你的 CSS 生效了。** 這三場交手全都通過了這個專案擁有的每一項檢查。

## 把顏色燒進圖片，等於放棄主題

我要求做出全彩的檔案類型圖示，就像編輯器檔案樹上那種。代理把它們做成 SVG 的 data URI，品牌色直接寫在 SVG 的位元組裡，並以語言作為鍵：

```css title="src/styles/code-file-icons.css"
.frame.has-title:has(pre[data-language='ts']) .title::before {
	background-image: url("data:image/svg+xml,…");
}
```

這裡的 `:has()` 是真的在做事。語言掛在 `<pre>` 上，而它是說明文字*後面的兄弟元素*，所以選擇器必須從分頁往前伸到下方的區塊。這些標誌來自 CC0 授權的 [`simple-icons`](https://simpleicons.org)，所以不會產生任何標示出處的義務。

問題是結構性的。`background-image` 沒辦法用 CSS 重新上色，所以每個圖示只帶一種顏色，而它所在的分頁卻會在接近白色與接近黑色之間切換。品牌色是針對其中一種背景挑出來的。JavaScript 的黃色假設的是暗色編輯器，CSS 的紫色假設的是亮色頁面。

| 標誌 | 在亮色分頁上 | 在暗色分頁上 |
| --- | --- | --- |
| `js` `#F7DF1E` | **1.29:1** | 13.12:1 |
| `css` `#663399` | 8.03:1 | **2.11:1** |
| `toml` `#9C4121` | 6.29:1 | **2.70:1** |

11 個裡有 3 個低於非文字內容 3:1 的對比度下限，其中一個實際上等於看不見。

代理的處理方式是把它們修正掉。它在建置時把每個標誌對兩種分頁背景各量一次，把不合格的往黑色或白色混合，直到通過門檻為止，並在兩邊修正結果不同時額外輸出一條暗色主題的規則。

我一看到就否決了。為了在白色分頁上達標而調暗的 JavaScript 徽章，是一種混濁的橄欖色，**而一個唯一職責就是讓人瞬間認出來的圖示，已經不再看起來像它所指的那個東西**。比率達標了，功能卻不見了。

所以這些標誌在兩種主題下都以真正的品牌色輸出，而產生器只負責回報它原本會改動的內容：

```text
src/styles/code-file-icons.css: 11 icons, 14949 bytes
  3 mark(s) below 3:1, kept at brand colour:
    js #F7DF1E — 1.29:1 on the light tab
    css #663399 — 2.11:1 on the dark tab
    toml #9C4121 — 2.70:1 on the dark tab
```

這是一項真實存在的無障礙成本，而它被寫下來，而不是被辯掉。**一個不會改變結果的量測值，仍然值得留在輸出裡**，否則這就變成一個之後沒有人找得到的決定，而半年後接手的人會把它「修」回去。

還有一個標誌維持覆寫：JSON 的品牌色是純粹的 `#000000`，在暗色分頁上是 1.06:1。那不是弱，是不存在，而辨識度這個論點在這裡沒有東西可以保護。

## 程式碼審查抓到的東西

在開 pull request 之前，我要求做一次程式碼審查。它找出兩件代理已經驗證過、卻仍然弄錯的事。

第一件是前面那個看不見的 `js` 圖示。第二件更糟：代理「修好」了一個不存在的 bug。

代理先前回報，Expressive Code 只在滑鼠停留時才顯示複製按鈕，且沒有 `(hover: none)` 的回退，因此在觸控裝置上永遠碰不到。這個判讀來自一段把樣式表攤平、並把外層 at-rule 丟掉的指令碼。它實際輸出的是：

```css
.expressive-code .copy button              { opacity: 0.75; width: 2.5rem }
@media (hover: hover) {
	.expressive-code .copy button          { opacity: 0; width: 2rem }
}
```

隱藏的那條指定就在媒體查詢*裡面*。觸控裝置本來就沒問題，反而是那個「修正」把基礎規則覆寫成 `0.5`，讓按鈕在**觸控裝置上比原本更暗**。把它限制在 `(hover: hover)` 之內，才是真正需要的修正。

這次遷移繞的四次遠路裡，有兩次來自同一個習慣：用指令碼讀 CSS，而不是在瀏覽器裡量它。tab-size 那條規則之所以輸掉，是因為特異性是在紙上算的；複製按鈕之所以被「修正」，是因為正規表示式看到了一條沒有媒體查詢的規則。

## 真的能證明什麼的驗證

最初的症狀是最便宜的檢查，而且它必須什麼都不印出來：

```bash
pnpm build 2>&1 | grep -i shiki
```

接著是兩項證明圍籬改寫不只是跑過、而是真的生效的檢查：

```bash
grep -rcE '^```[A-Za-z0-9_+#-]+:[^ ]+$' src/content   # 冒號形式的圍籬剩 0 個
grep -rl 'astro-code' dist/                           # 空的：Shiki 的標記已消失
```

還有一項是用來抓內容被靜靜刪掉的情況。Expressive Code 有一個啟發式規則，會從前四行的註解裡讀出檔名，並且**刪掉它比對到的那一行**。這個部落格裡有 42 個圍籬是以註解開頭的，其中 3 個字面上就是 `# pyproject.toml`。這個選項已經關掉，而這就是證明它維持關閉的方式：

```bash
grep -c 'the Vertex AI API has to be enabled' dist/blog/aimock-*/index.html
```

產生出來的檔案要用偏移偵測來守住，而不是靠約定。`pnpm icons:check` 會在記憶體裡重新產生圖示 CSS 並做比對，若已提交的檔案過期就失敗。它跟既有的縮圖檢查並列，作為 `pnpm check` 的一部分執行。

## 複製按鈕在三個語系裡有兩個是英文

這次搬遷上線幾個月後，我在為分享連結做第二個複製操作時，才發現這顆按鈕一直都是這樣。`@expressive-code/plugin-frames` 內建的翻譯**只有英文與德文**，而這裡又沒有設定 `getBlockLocale`，外掛因此無從得知一個頁面是什麼語言。從導入的那天起，所有日文與繁體中文文章的複製按鈕都標示著 `Copy to clipboard`，並回應 `Copied!`。

修法是從文章的檔名取出語系，再把文案從網站自己的 UI 字典註冊進去：

```js title="astro.config.mjs"
for (const locale of LOCALES) {
	pluginFramesTexts.addLocale(locale, {
		terminalWindowFallbackTitle: UI.terminalWindow[locale],
		copyButtonTooltip: UI.copyCode[locale],
		copyButtonCopied: UI.copied[locale],
	});
}

getBlockLocale: ({ file }) => file.path.match(LOCALE_FILENAME)?.[1] ?? DEFAULT_LOCALE,
```

有兩個地方很容易做錯。`addLocale` 是整包取代該語系的文案，而不是合併，所以三個鍵都必須給齊；漏掉 `terminalWindowFallbackTitle`，那個字串就會在每個終端機框裡維持英文。另一個是要註冊在 `zh-tw` 而不是 `zh`，因為查找會依 `['zh', 'zh-tw']` 的順序取第一個命中的結果，一旦有 `zh` 的登錄，繁體中文的設定就會被蓋掉。

它之所以屬於這篇文章，是因為失敗的形狀完全相同。`pnpm check` 是綠的，`astro check` 一則提示都沒有，建置也成功。算繪出來的按鈕是用什麼語言寫的，這條管線裡沒有任何一段看得到。

## 總結

如果你正在為 Astro 部落格做程式碼片段 UI，這次得到的實作準則如下：

- **檔名寫在 `title="…"` 裡。** 那是生態系會解析的形式。只有你的編輯器看得懂的自訂寫法，會被當成語言名稱讀取，然後安靜地失敗。
- **把圍籬規則寫進風格指南，而且先修指南。** 一個會自己生出 bug 的慣例，在每次清理之後都會再生一次。
- **在 Astro 7 上，先確認你的整合套件有處理 Sätteri** 再往上蓋。文件所寫的 `rehypePlugins` 路徑在預設環境裡什麼都碰不到，而且不會警告。
- **在瀏覽器裡量 CSS，不要用指令碼讀。** 這次四個失敗裡有兩個是判讀錯誤：一個在紙上算特異性，另一個把媒體查詢丟掉了。
- **燒進去的顏色沒辦法跟著主題走。** 在建置時把每個標誌對兩種背景都量一次，然後刻意做選擇：修正顏色、改用 `mask-image` 並放棄顏色，或是保留品牌標誌並把代價記錄下來。唯一錯誤的選項是不知道。
- **用檢查而不是註解來守住產生的檔案。** `icons:check` 大概十行，卻消掉了「有人忘了重跑」這整類問題。

我一直回想的是，上面每一件事都通過了綠色的建置。定位點寬度、看不見的圖示、變暗的複製按鈕，以及一開始那近百個區塊，在工具看來全都沒有問題。

## 參考連結

- [Expressive Code](https://expressive-code.com) 及其 [frames 文件](https://expressive-code.com/key-features/frames/)，涵蓋 `title=`、檔名註解的啟發式規則，以及終端機外框
- [Astro 語法高亮](https://docs.astro.build/en/guides/syntax-highlighting/)，包含 `shikiConfig` 與雙主題設定
- [simple-icons](https://simpleicons.org)，CC0，檔案類型標誌的來源
- [WCAG 2.2 非文字內容對比度](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)，圖示所對照的 3:1 下限
