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

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

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

本頁目錄

引言

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

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

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

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

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

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

一個程式碼區塊的組成兩個算繪後的程式碼區塊。上面是編輯器外框:左上的檔名分頁帶有檔案類型圖示與 pyproject.toml 這個名稱,內容區右上有複製按鈕,下方是語法高亮的 TOML。下面是終端機外框:只有三個視窗圓點、沒有標題,內含一行 shell 指令。這兩種形狀就是在告訴讀者,看的是一個檔案還是一段工作階段。pyproject.toml檔名分頁語法高亮的內容檔案類型圖示複製按鈕終端機外框shell 區塊無標題
檔案會有分頁與名稱。shell 工作階段會有視窗圓點、沒有名稱。形狀本身就是訊號。

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

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

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

圍籬的寫法是 Zenn 的慣例

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

同一段程式碼在改寫圍籬前後的樣子同一段程式碼的兩種算繪。左邊是把圍籬寫成三個反引號接 ts:src/lib/blog.ts 的情形:沒有檔名分頁,文字是一片平坦的灰色,data-language 屬性是 plaintext,並且會產生一行建置警告。右邊是把同一個圍籬寫成 title="…" 的情形:區塊帶有檔案類型圖示與檔名分頁,程式碼有顏色,data-language 是 ts,也不再產生警告。當時上線的樣子```ts:src/lib/blog.tsdata-languageplaintext沒有語法高亮檔名從未被算繪出來每次建置、每個區塊各一行警告現在上線的樣子```ts title="src/lib/blog.ts"src/lib/blog.tsdata-languagets以 TypeScript 高亮分頁上有檔名與圖示沒有警告
左欄是當時實際上線的樣子。寫在圍籬裡的檔名,在 HTML 的任何地方都沒有出現過。

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

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

終端機視窗
grep -rhoE '^```[A-Za-z0-9_+#-]+:' src/content/blog | sort | uniq -c | sort -rn
48 ```ts:
12 ```python:
10 ```css:
6 ```toml:
5 ```astro:

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

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

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

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

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

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'],但這個網站的切換器寫入的是 lightdark

沒有做的分頁群組

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

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

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

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

程式碼圍籬如何抵達 Expressive Code,以及在哪裡抵達不了.mdx 檔內的程式碼圍籬經過 @astrojs/mdx 後分岔:取決於設定了哪一種 Markdown 處理器。在 unified 這一支,Astro 會合併 markdown.rehypePlugins,整合因此會執行。在 Sätteri 這一支,也就是 Astro 7 的預設值,這些外掛會被完全忽略,所以只用這條路徑註冊的整合會安裝成功卻什麼都沒算繪。Expressive Code 之所以能運作,是因為它同時寫入 Sätteri 會讀取的 options.hastPlugins,圍籬最終成為一個包含 figcaption 與 pre 的 figure。.mdx 檔內的程式碼圍籬@astrojs/mdx使用哪一種 Markdown 處理器?unified會合併 markdown.rehypePlugins整合會執行寫入 options.hastPluginsSätteriAstro 7 的預設值完全忽略它們整合不會執行安裝成功,卻什麼都沒算繪figure › figcaption › pre › code
右邊那一支才是預設路徑。只認識左邊那條路的整合套件,會在不出聲的情況下失敗。

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

終端機視窗
grep -c -i satteri node_modules/astro-expressive-code/dist/index.js

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

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

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

敗給同一層隔離樣式的三條規則三列,把網站這邊指定的與外掛那邊原本就有的並排比較。定位點寬度上,寫在 .expressive-code pre 的規則特異性為 0-1-1,與外掛的重置規則同分,因而輸在載入順序。複製按鈕上,兩邊選擇器完全相同,再次同分。檔名分頁上,網站設定的是 editorTabBarBorderBottomColor,但畫出輪廓的是 editorTabBarBorderColor,於是輪廓留著沒動,反而是程式碼區塊的上緣不見了。三者的差距都在一級特異性之內,而同分是由哪一份樣式表較晚載入來決定。網站這邊指定的外掛那邊原本就有的結果定位點寬度.expressive-code prevs.expressive-code *:not(:is(svg, svg *))落敗複製按鈕.expressive-code .copy buttonvs.expressive-code .copy button落敗檔名分頁editorTabBarBorderBottomColorvseditorTabBarBorderColor落敗同分時由載入順序決定,而那並不是任何人做過的設計決策。
三者中有兩者是特異性同分。同分由哪一份樣式表較晚載入來決定。

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

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

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

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

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

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

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

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

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

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,所以不會產生任何標示出處的義務。

問題是結構性的。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 徽章,是一種混濁的橄欖色,而一個唯一職責就是讓人瞬間認出來的圖示,已經不再看起來像它所指的那個東西。比率達標了,功能卻不見了。

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

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 丟掉的指令碼。它實際輸出的是:

.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 那條規則之所以輸掉,是因為特異性是在紙上算的;複製按鈕之所以被「修正」,是因為正規表示式看到了一條沒有媒體查詢的規則。

真的能證明什麼的驗證

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

終端機視窗
pnpm build 2>&1 | grep -i shiki

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

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

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

終端機視窗
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 字典註冊進去:

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 大概十行,卻消掉了「有人忘了重跑」這整類問題。

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

參考連結

分享這篇文章