# Astroのインラインコードで16進カラーの横に色見本を表示する — Swatchコンポーネントではなくrehypeプラグインを選んだ理由

> Astro MDXのインラインコードに書いた16進カラーの前に色見本を描くrehypeプラグイン。Swatchコンポーネントではなくこちらを選んだので、既存の記事は書き換えずに済みます。

- Source: https://oharu121.com/ja/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

---
## はじめに

[ロゴの記事](/ja/blog/ai-agent-logo-design-fonttools-svg-path-bugs-jetbrains-mono/)を読み返していたとき、サイトのアクセント色`#1d4ed8`と印章の色`#d8452a`がぶつかると論じる段落で手が止まりました。読者に、目に見えない2つの色を比べさせていたからです。VS CodeはCSSなどのファイルで同じ問題を解決していて、16進のカラーコードの前にその色の小さな四角を描きます。

**このブログでも、rehypeプラグインで同じことを実現しました**。既存のすべての記事の3言語すべてで、カラーコードの前に色見本が付き、どの色を指しているかが一目でわかるようになりました。

## インラインの16進カラーコードすべてに色見本を付ける

VS Codeはこの機能をcolor previewと呼び、`editor.colorDecorators`設定でオン・オフを切り替えます。このサイト自身の図版用トークンでは、次のように表示されます。

*Image: light-dark()を使ったカスタムプロパティの一覧で、各16進値の前に小さな色付きの四角を表示しているVS Code*

*`src/styles/global.css`の`--fig-*`トークンに対するVS Codeのcolor preview。*

ブログ側の実装は`src/lib/rehype-color-swatch.ts`の`rehypeColorSwatch`です。AstroはunifiedというMarkdownの構文木を扱うツール群でページを生成しており、rehypeはそのうちHTMLを扱う段階です。rehypeプラグインは、ページをhast（HTMLの構文木）として受け取り、その場で書き換える関数です。

*Figure — Pipeline: 記事のソースからページまでにunifiedが通る段階。色見本プラグインは、このブログに以前からある2つのプラグインと並んでHTMLの構文木を編集します。*

**テキスト全体が16進カラーであるインラインの`<code>`を見つけるたびに、プラグインはテキストの前に空の`<span>`を挿入し**、色をカスタムプロパティとしてCSSに渡します。`#1d4ed8`のビルド後のHTMLは次のとおりです。

```html title="インラインのカラーコード1つ分のビルド後HTML"
<code>
  <span class="color-swatch" aria-hidden="true" style="--swatch:#1d4ed8"></span>
  #1d4ed8
</code>
```

spanはテキストを持たず、`aria-hidden`も付いています。そのため、コードをコピーしても得られるのは`#1d4ed8`だけで、スクリーンリーダーも16進値だけを読み上げます。四角はCSSルール1つで描いており、サイズを`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 1行と、Markdownプロセッサのプラグイン配列への追加1つで済みます。

```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`は、`extendMarkdownConfig: false`で無効にしない限りこのMarkdown設定を引き継ぐので、この1つの登録ですべての`.mdx`記事に効きます。日本語版と繁体字中国語版のファイルにも英語版と同じバッククォートがあるので、同じ色見本が付きます。

## `<Swatch>`コンポーネントではなくrehypeプラグインを選んだ理由

**コンポーネントは書き手が書いた場所にしか色見本を描きませんが、プラグインはカラーコードがバッククォートに入っている場所ならどこにでも描きます**。このブログでは、カラーコードをずっとバッククォートで書いてきました。そのため、プラグインを追加するだけで、公開済みの記事を書き換える必要はありませんでした。

| | rehypeプラグイン | `<Swatch>`コンポーネント |
| --- | --- | --- |
| 書き手が書くもの | これまでどおり`#1d4ed8` | `<Swatch color="#1d4ed8" />`とimport |
| 既存の記事 | 次のビルドで色見本が付く | すべての出現箇所を手で書き換える |
| 日本語版と中国語版 | 同じバッククォートに同じ色見本 | 各ファイルですべての出現箇所を書き換える |
| 後から普通のバッククォートで書いたカラーコード | 色見本が付く | ただのコードとして表示される |

コンポーネントにした場合、普通のバッククォートはただのコードとして表示され続けます。そのため今後の記事に色見本が付くかどうかは、書き手がコンポーネントを思い出すかどうかに左右されます。AIで記事を書く場合も、バッククォートではなくコンポーネントでカラーコードを書かせる必要がありますが、ルールを決めても厳密に守られるとは限りません。

## プラグインは構文木のどこを見るか

**プラグインは`<pre>`以外のすべての要素をたどり、たどり着いた各`<code>`のテキストを判定し、MDXのコンポーネントの中にも入っていきます**。

*Figure — TreeWalk: 記事の構文木のうち、プラグインがたどるノード、スキップするノード、判定するノード。*

1. **インラインコードだけが対象です**。`<pre>`の中には入りません。このサイトのコードブロックはExpressive Codeというプラグインがレンダリングしており、中身をすべて管理しているので、コードブロック内のカラーコードは書いたとおりに残ります。
2. **テキスト全体がカラーコードの場合だけが対象です**。子が16進カラーに一致するテキストノード1つだけの`<code>`が該当します。`color: #fff`に色見本は付きません。長いコードの中では、**`#fade`が色なのかCSSのidセレクタなのかをプラグインは判別できません**。VS Codeと違い、そのコードが何の言語かを解析しないからです。
3. **MDXのコンポーネントの中にも入ります**。`<Callout>`のようなコンポーネントは構文木上で`element`ノードではないので、ノードの型を名指しして中に入る必要があります。

コンポーネントの中のMarkdownは普通のhastのままですが、`element`ノードにしか降りていかない走査はコンポーネントの境界で止まります。その結果、**コールアウトの中のカラーコードには色見本が付かず、段落にある同じコードには付く**ことになります。

> **ちなみに**
>
> **このコールアウトはmdxJsxFlowElementです**
>
> MDXはコンポーネントを`element`ノードに変換せず、専用の2つのノード型のどちらかで表します。
>
> 1. `mdxJsxFlowElement`：このコールアウトのように、独立した行に書いたコンポーネント。
> 2. `mdxJsxTextElement`：`テキスト <Badge /> テキスト`のように、文の中に書いたコンポーネント。
>
> `#059669`の横に色見本が描かれているのは、走査がこのコールアウトの中にも入るからです。

走査は手書きの再帰です。ハイライトした分岐が、コンポーネントの中に入る部分です。

```ts title="src/lib/rehype-color-swatch.ts" {6-9}
// 3桁と4桁の形式は英字を含む場合だけ対象にし、`#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`トークンがあり、コードの背景の枠にもこれが使われています。色見本はコードの背景の上に乗っており、その背景色と`#0b1120`の色見本のコントラストはダークモードで1.06:1、`#ffffff`の色見本とはライトモードで1.05:1しかありません。

こうした色では塗りがコードの背景に溶け込んでしまうので、見えるのは枠線だけですが、`--border`の枠線とこれらの色見本のコントラストも1.28:1と1.23:1しかありません。

そこで色見本の枠線には、このブログで補助的なテキストに使うグレーの`--text-3`を使いました。**ページの背景に近い色でも、枠付きの四角として見えます**。各トークンの枠線と、これら2つの色見本とのコントラストは次のとおりです。

| 枠線のトークン | ダークモードの`#0b1120`に対して | ライトモードの`#ffffff`に対して |
| --- | --- | --- |
| `--border` | 1.28:1 | 1.23:1 |
| `--text-3` | 3.96:1 | 4.76:1 |

Webアクセシビリティの標準であるWCAG 2.2は、読者が認識する必要のあるグラフィックに3:1のコントラストを求めています。**`--text-3`は両方のテーマでこれを満たし、`--border`はどちらでも届きません**。

*Image: ダークとライトのページに置いた#0b1120と#ffffffの2つのインラインコード。どちらの色見本も淡い区切り線の色で縁取られている*

*`--border`では、ページの背景と同じ色の色見本がほとんど見えなくなります。*

*Image: 同じ2つのインラインコードをダークとライトのページに置いたもの。どちらの色見本も中間のグレーの枠線で縁取られている*

*`--text-3`では、両方のテーマで2つの色見本がどちらも四角として見えます。*

## 色はstyle属性で渡す

記事ではどんな16進カラーでも使われる可能性があり、そのすべてにクラスを用意することはできません。**そこでプラグインは色を`style="--swatch: #0b1120"`に書き込み**、CSSルールが`background: var(--swatch)`で`#0b1120`を取り出して適用します。

インラインの`style`属性は、ページのContent Security Policy（CSP）の対象になります。CSPは、ページが読み込み・実行してよいものをレスポンスヘッダーでブラウザに伝える仕組みです。このブログが送っているCSPは`style-src 'self'; style-src-attr 'unsafe-inline'`です。

> **ちなみに**
>
> **2つのディレクティブの担当範囲**
>
> - `style-src 'self'`：スタイルシートのファイルを担当し、このブログ自身からの読み込みだけを許可します。第三者のURLからは読み込めません。
> - `style-src-attr 'unsafe-inline'`：要素の`style="…"`属性だけを担当し、それを許可します。
>
> 色見本の色は`<span>`の`style`属性に書かれているので、色見本が頼っているのは`style-src-attr 'unsafe-inline'`です。

`style-src-attr 'unsafe-inline'`は、Expressive Codeが内部で使うハイライターのShikiがコードに書き込む色のために、もともと入れていました。このディレクティブを入れた経緯は[CSPの記事](/ja/blog/astro-vercel-security-csp-service-worker-hash/)で書いています。**プラグインのためにポリシーを変更する必要はありませんでした**。`style-src-attr`がなく、`style-src`にも`'unsafe-inline'`がないサイトでは、ブラウザがインラインの`style`属性をブロックするので、色見本はすべて中身のない枠だけの四角になります。

## まとめ

このブログでは、インラインコードに書いた16進カラーの前に、その色の色見本が表示されるようになりました。描いているのはコンポーネントではなくrehypeプラグインなので、すべての記事で、バッククォートを書き換えることなくカラーコードに色見本が付きました。具体的な手順は次のとおりです。

1. `markdown.processor`に登録する（`@astrojs/mdx`はこの設定を引き継ぐ）。
2. テキスト全体が色であるインラインの`<code>`に一致させる。
3. `<pre>`はスキップし、`mdxJsxFlowElement`と`mdxJsxTextElement`の中には入る。
4. 色見本の枠線には、ダークとライトの両方のページ背景に対して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/ja/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)
