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

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

- Source: https://oharu121.com/zh-tw/blog/astro-rehype-plugin-hex-color-swatch-inline-code-mdx/
- Published: 2026-10-31T22:57:23+09:00
- Tags: Astro, CSS, MDX, rehype, CSP, Expressive Code

---
## 引言

我在重讀[我之前寫的 logo 文章](/zh-tw/blog/ai-agent-logo-design-fonttools-svg-path-bugs-jetbrains-mono/)時，看到其中一段主張網站強調色 `#1d4ed8` 和印章顏色 `#d8452a` 互相衝突。我這才發現，這段文字要讀者比較兩個根本看不到的顏色。VS Code 處理 CSS 等檔案時也會遇到同一個問題，它的做法是在每個十六進位色碼前面畫一個該顏色的小方塊。

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

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

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

*Image: 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 語法樹）的形式接收頁面，並直接修改這棵樹。

*Figure — Pipeline: unified 會把文章原始碼依序轉成這幾個階段，最後產生頁面。色塊外掛和這個部落格原本就有的兩個外掛一起修改 HTML 語法樹。*

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

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

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

```css title="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 處理器的外掛陣列裡加一個項目：

```js title="astro.config.mjs" ins={1,4}
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 元件的內部**。

*Figure — TreeWalk: 圖中標出了外掛在文章樹狀結構裡走訪、略過和檢查的節點。*

1. **只處理行內程式碼**。走訪不會進入 `<pre>`。這個部落格的程式碼區塊由 Expressive Code 這個外掛算繪，區塊裡的內容都由它處理。因此程式碼區塊裡的色碼會維持原樣。
2. **只處理整段文字都是色碼的情況**。符合條件的 `<code>` 只能有一個子節點，而且是符合十六進位色碼的文字節點。因此 `color: #fff` 不會有色塊。在一段較長的程式碼裡，**外掛分不出 `#fade` 是顏色還是 CSS 的 id 選擇器**，因為它不像 VS Code 那樣會解析程式碼的語言。
3. **會走進 MDX 元件**。`<Callout>` 這類元件在樹狀結構中不是 `element` 節點，走訪時必須指名它的型別才能進入。

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

> **話說**
>
> **這個提示框就是一個 mdxJsxFlowElement**
>
> MDX 不會把元件轉成 `element` 節點，而是用兩種專屬的節點型別之一來表示：
>
> 1. `mdxJsxFlowElement`：單獨寫成一個區塊的元件，例如這個提示框。
> 2. `mdxJsxTextElement`：寫在句子中間的元件，例如 `文字 <Badge /> 文字`。
>
> 走訪會進入這個節點，所以 `#059669` 旁邊畫出了色塊。

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

```ts title="src/lib/rehype-color-swatch.ts" {6-9}
// 三位數和四位數的寫法必須含有英文字母，所以 `#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` |
| --- | --- | --- |
| `--border` | 1.28:1 | 1.23:1 |
| `--text-3` | 3.96:1 | 4.76:1 |

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

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

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

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

*使用 `--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'`。

> **話說**
>
> **這兩條指令各管什麼**
>
> - `style-src 'self'`：管樣式表檔案，只允許從這個部落格本身載入，不許從第三方網址匯入。
> - `style-src-attr 'unsafe-inline'`：只管元素上的 `style="…"` 屬性，允許這些屬性生效。
>
> 色塊的顏色寫在 `<span>` 的 `style` 屬性上，所以色塊靠的是 `style-src-attr 'unsafe-inline'`。

Shiki 是 Expressive Code 內部使用的語法標示工具，它會把顏色寫在程式碼區塊的 `style` 屬性上。所以 `style-src-attr 'unsafe-inline'` 原本就在政策裡，這個決定我在[之前寫的 CSP 文章](/zh-tw/blog/astro-vercel-security-csp-service-worker-hash/)裡說明過。因此**這次新增的外掛完全不需要修改這份政策**。

如果一個網站沒有 `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` 允許行內屬性。

## 參考連結

- [hast：rehype 外掛所編輯的 HTML 語法樹格式](https://github.com/syntax-tree/hast)
- [mdast-util-mdx-jsx：定義了 mdxJsxFlowElement 和 mdxJsxTextElement 節點型別](https://github.com/syntax-tree/mdast-util-mdx-jsx)
- [MDN：CSP 的 style-src-attr 指令，以及它如何退回到 style-src](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/style-src-attr)
- [WCAG 2.2 成功準則 1.4.11「非文字對比」說明](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)
- [VS Code 的 CSS 文件：語法上色與 color preview，包含 editor.colorDecorators](https://code.visualstudio.com/docs/languages/css#_syntax-coloring-color-preview)
