我在 Astro 行內程式碼的色碼旁加上了色塊 — 為什麼選 rehype 外掛而不是 Swatch 元件?

這是一個用在 Astro MDX 上的 rehype 外掛,會在每個寫成行內程式碼的十六進位色碼前畫出色塊。我選了 rehype 外掛而不是 Swatch 元件,這樣一來既有文章都不用修改。

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

引言

我在重讀我之前寫的 logo 文章時,看到其中一段主張網站強調色 #1d4ed8 和印章顏色 #d8452a 互相衝突。我這才發現,這段文字要讀者比較兩個根本看不到的顏色。VS Code 處理 CSS 等檔案時也會遇到同一個問題,它的做法是在每個十六進位色碼前面畫一個該顏色的小方塊。

我用 rehype 外掛在這個部落格上實現了同樣的效果。現在所有既有文章在三種語言版本裡的色碼前面都有了色塊,讓讀者一眼就能看出色碼代表的顏色。

讓每個行內色碼都帶有色塊

VS Code 把這個功能稱為 color preview,可以用 editor.colorDecorators 設定開啟或關閉。下圖是這個功能套用在這個部落格圖表用的 token 上的樣子:

VS Code 在一串使用 light-dark() 的自訂屬性中,於每個十六進位值前顯示一個小色塊

VS Code 在 src/styles/global.css 的 --fig-* token 裡,於每個色碼前顯示 color preview。

部落格這邊的實作是 src/lib/rehype-color-swatch.ts 裡的 rehypeColorSwatch。Astro 透過 unified 這套語法樹處理工具把 Markdown 轉成頁面,rehype 是其中處理 HTML 的階段。rehype 外掛是一個函式,會以 hast(HTML 語法樹)的形式接收頁面,並直接修改這棵樹。

色塊外掛在 unified 裡的執行位置由左到右有四個階段。文章原始碼的反引號裡寫著 #1d4ed8,unified 會先把原始碼轉成 Markdown 語法樹 mdast,再轉成 HTML 語法樹 hast。三個 rehype 外掛會在 hast 上依序執行:rehypeTableScroll、rehypeLinkIcons 和被標示出來的 rehypeColorSwatch。hast 最後變成建置後的頁面,也就是帶有色塊的 HTML。標著 unified 的括號橫跨前三個階段。unified文章原始碼`#1d4ed8`寫在反引號裡mdastMarkdown 語法樹remark 階段hastHTML 語法樹rehypeTableScrollrehypeLinkIconsrehypeColorSwatch建置後的頁面帶有色塊的 HTML
unified 會把文章原始碼依序轉成這幾個階段,最後產生頁面。色塊外掛和這個部落格原本就有的兩個外掛一起修改 HTML 語法樹。

外掛遇到行內 <code> 時,只要整段文字都是十六進位色碼,就會在文字前插入一個空的 <span>,再把顏色當成自訂屬性交給 CSS。以 #1d4ed8 為例,建置後的 HTML 如下:

一個行內色碼建置後的 HTML
<code>
<span class="color-swatch" aria-hidden="true" style="--swatch:#1d4ed8"></span>
#1d4ed8
</code>

這個 span 裡沒有文字,又帶有 aria-hidden。因此複製程式碼時只會複製到 #1d4ed8,螢幕閱讀器也只會念出色碼。方塊由一條 CSS 規則畫出,尺寸以 em 指定,所以會像字形一樣跟著程式碼的字體大小縮放:

src/styles/global.css
.color-swatch {
display: inline-block;
width: 0.8em;
height: 0.8em;
margin-inline-end: 0.35em;
vertical-align: -0.05em;
background: var(--swatch);
border: 1px solid var(--text-3);
border-radius: 2px;
}

註冊外掛只需要加一行 import,再在 Markdown 處理器的外掛陣列裡加一個項目:

astro.config.mjs
import { rehypeColorSwatch } from "./src/lib/rehype-color-swatch.ts";
// …
markdown: {
processor: unified({ rehypePlugins: [rehypeTableScroll, rehypeLinkIcons, rehypeColorSwatch] }),
},

@astrojs/mdx 預設會沿用這份 Markdown 設定(除非設成 extendMarkdownConfig: false),所以這一個項目就涵蓋了所有 .mdx 文章。日文版和繁體中文版的檔案裡,也有和英文版相同的反引號。因此這兩個版本也得到了相同的色塊。

為什麼選 rehype 外掛而不是 <Swatch> 元件?

元件只會在寫了元件的地方畫出色塊,外掛則會在任何放進反引號的色碼旁畫出色塊。這個部落格一直都是把色碼寫在反引號裡。因此只要加上這個外掛,就不需要再修改已經發布的文章。

rehype 外掛<Swatch> 元件
作者要寫的內容跟以前一樣寫 #1d4ed8<Swatch color="#1d4ed8" />,再加一行 import
既有文章下一次建置就有色塊每個色碼都要手動改寫
日文版和中文版同樣的反引號,同樣的色塊每個檔案裡的色碼都要再改寫一次
之後用一般反引號寫的色碼會有色塊只會顯示成一般的程式碼

如果用元件,一般的反引號會繼續顯示成一般的程式碼。這樣一來,往後任何一篇文章有沒有色塊,都取決於作者記不記得要用元件。用 AI 寫文章時,也要確保 AI 用元件而不是反引號來寫色碼,但就算定了規則,AI 也不一定會嚴格遵守。

外掛會檢查樹狀結構的哪些地方?

外掛會走訪 <pre> 以外的所有元素,檢查走到的每個 <code> 的文字,也會走進 MDX 元件的內部。

色塊外掛在文章樹狀結構中走訪的範圍樹狀結構以 root 為頂端,下面有四個子節點。外掛會走訪兩個段落,並檢查其中的行內程式碼:#1d4ed8 加上了色塊,color: #fff 不是整段色碼,所以沒有加。pre 元素被略過,裡面的程式碼不會被走到。Callout 的節點型別是 mdxJsxFlowElement 而不是 element,外掛會走進它的內部,段落裡的 #059669 也加上了色塊。root<p>element走訪<p>element走訪<pre>element略過<Callout>mdxJsxFlowElement走進內部<code>#1d4ed8加上色塊<code>color: #fff不加:不是整段色碼<code>#0b1120不會走到<p>element走訪<code>#059669加上色塊
圖中標出了外掛在文章樹狀結構裡走訪、略過和檢查的節點。
  1. 只處理行內程式碼。走訪不會進入 <pre>。這個部落格的程式碼區塊由 Expressive Code 這個外掛算繪,區塊裡的內容都由它處理。因此程式碼區塊裡的色碼會維持原樣。
  2. 只處理整段文字都是色碼的情況。符合條件的 <code> 只能有一個子節點,而且是符合十六進位色碼的文字節點。因此 color: #fff 不會有色塊。在一段較長的程式碼裡,外掛分不出 #fade 是顏色還是 CSS 的 id 選擇器,因為它不像 VS Code 那樣會解析程式碼的語言。
  3. 會走進 MDX 元件。<Callout> 這類元件在樹狀結構中不是 element 節點,走訪時必須指名它的型別才能進入。

元件裡面的 Markdown 仍然是一般的 hast,但如果走訪只往 element 節點裡走,就會停在元件的邊界上。結果就是提示框裡的色碼沒有色塊,段落裡同樣的色碼卻有。

走訪用手寫的遞迴實作,下面標示出來的分支負責走進元件:

src/lib/rehype-color-swatch.ts
// 三位數和四位數的寫法必須含有英文字母,所以 `#447` 這種 issue 編號不會被加上色塊
const HEX = /^#(?:[0-9a-f]{6}|[0-9a-f]{8}|(?=[0-9]*[a-f])[0-9a-f]{3,4})$/i;
function addSwatches(node: HastNode): void {
for (const child of node.children ?? []) {
if (child.type === "mdxJsxFlowElement" || child.type === "mdxJsxTextElement") {
addSwatches(child);
continue;
}
if (child.type !== "element") continue;
if (child.tagName === "pre") continue;
if (child.tagName === "code") {
const color = colorOf(child);
if (color) {
(child.children ??= []).unshift({ /* 建立 class="color-swatch" 的 span 節點,程式碼省略 */ });
}
continue;
}
addSwatches(child);
}
}

在深色和淺色背景上都看得見的外框

這個部落格原本就有一個 --border token,用來畫細分隔線,行內程式碼的外框用的也是它。色塊位在行內程式碼的底色上,但這個底色和 #0b1120 色塊在深色模式下的對比度只有 1.06:1,和 #ffffff 色塊在淺色模式下只有 1.05:1。

遇到這類接近頁面背景的顏色時,填色等於什麼都沒畫,看得見的只有外框,而 --border 外框和這兩個色塊的對比度也只有 1.28:1 和 1.23:1。

所以色塊的外框我改用 --text-3,也就是這個部落格用在次要文字上的灰色。這樣一來,就算顏色很接近頁面背景,色塊也還是會顯示成一個有外框的方塊。下表列出兩個 token 的外框和這兩個色塊之間的對比度:

外框 token深色模式下對 #0b1120淺色模式下對 #ffffff
--border1.28:11.23:1
--text-33.96:14.76:1

根據網頁無障礙標準 WCAG 2.2 要求,要讓讀者看得到圖形元素,其對比度至少要達到 3:1。--text-3 在兩種主題下都達標,--border 則兩種都沒有達到。

深色與淺色頁面上各有 #0b1120 和 #ffffff 兩個行內程式碼,兩個色塊都只用淡淡的分隔線顏色描邊

使用 --border 時,與頁面背景同色的色塊幾乎看不見。

同樣兩個行內程式碼放在深色與淺色頁面上,兩個色塊都用中灰色的外框描邊

使用 --text-3 時,兩個色塊在兩種主題下都看得出是方塊。

顏色透過 style 屬性傳遞

文章中可能使用任何十六進位色碼,而我不可能為每個顏色都準備一個 class。所以外掛把顏色寫進 style="--swatch: #0b1120",CSS 規則再用 background: var(--swatch) 取出 #0b1120 並套用這個顏色。

行內的 style 屬性受頁面的 Content Security Policy(CSP)管制。CSP 是伺服器透過回應標頭告訴瀏覽器,頁面可以載入和執行哪些內容的機制。這個部落格送出的 CSP 是 style-src 'self'; style-src-attr 'unsafe-inline'。

Shiki 是 Expressive Code 內部使用的語法標示工具,它會把顏色寫在程式碼區塊的 style 屬性上。所以 style-src-attr 'unsafe-inline' 原本就在政策裡,這個決定我在之前寫的 CSP 文章裡說明過。因此這次新增的外掛完全不需要修改這份政策。

如果一個網站沒有 style-src-attr,style-src 裡也沒有 'unsafe-inline',瀏覽器就會擋下行內的 style 屬性,所有色塊都只會顯示成一個空的外框方塊。

總結

現在這個部落格裡,只要把十六進位色碼寫成行內程式碼,前面就會顯示該顏色的色塊。色塊是由 rehype 外掛畫出來的,而不是元件。所以既有文章不需要任何修改,就都有了色塊。具體的做法如下:

  1. 把外掛註冊在 markdown.processor,@astrojs/mdx 會沿用它。
  2. 只處理整段文字都是色碼的行內 <code>。
  3. 略過 <pre>,並走進 mdxJsxFlowElement 和 mdxJsxTextElement 的內部。
  4. 色塊外框選用的 token,在深色和淺色背景上的對比度都要達到 3:1。
  5. 在有 CSP 的網站上,若要把顏色放在 style 的自訂屬性裡傳遞,需要讓 style-src-attr 允許行內屬性。

參考連結

分享這篇文章