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

目次
はじめに
ロゴの記事を読み返していたとき、サイトのアクセント色#1d4ed8と印章の色#d8452aがぶつかると論じる段落で手が止まりました。読者に、目に見えない2つの色を比べさせていたからです。VS CodeはCSSなどのファイルで同じ問題を解決していて、16進のカラーコードの前にその色の小さな四角を描きます。
このブログでも、rehypeプラグインで同じことを実現しました。既存のすべての記事の3言語すべてで、カラーコードの前に色見本が付き、どの色を指しているかが一目でわかるようになりました。
インラインの16進カラーコードすべてに色見本を付ける
VS Codeはこの機能をcolor previewと呼び、editor.colorDecorators設定でオン・オフを切り替えます。このサイト自身の図版用トークンでは、次のように表示されます。

src/styles/global.cssの--fig-*トークンに対するVS Codeのcolor preview。ブログ側の実装はsrc/lib/rehype-color-swatch.tsのrehypeColorSwatchです。AstroはunifiedというMarkdownの構文木を扱うツール群でページを生成しており、rehypeはそのうちHTMLを扱う段階です。rehypeプラグインは、ページをhast(HTMLの構文木)として受け取り、その場で書き換える関数です。
テキスト全体が16進カラーであるインラインの<code>を見つけるたびに、プラグインはテキストの前に空の<span>を挿入し、色をカスタムプロパティとしてCSSに渡します。#1d4ed8のビルド後のHTMLは次のとおりです。
<code> <span class="color-swatch" aria-hidden="true" style="--swatch:#1d4ed8"></span> #1d4ed8</code>spanはテキストを持たず、aria-hiddenも付いています。そのため、コードをコピーしても得られるのは#1d4ed8だけで、スクリーンリーダーも16進値だけを読み上げます。四角はCSSルール1つで描いており、サイズをemで指定しているので、グリフと同じようにコードのフォントに合わせて拡大縮小します。
.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つで済みます。
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のコンポーネントの中にも入っていきます。
- インラインコードだけが対象です。
<pre>の中には入りません。このサイトのコードブロックはExpressive Codeというプラグインがレンダリングしており、中身をすべて管理しているので、コードブロック内のカラーコードは書いたとおりに残ります。 - テキスト全体がカラーコードの場合だけが対象です。子が16進カラーに一致するテキストノード1つだけの
<code>が該当します。color: #fffに色見本は付きません。長いコードの中では、#fadeが色なのかCSSのidセレクタなのかをプラグインは判別できません。VS Codeと違い、そのコードが何の言語かを解析しないからです。 - MDXのコンポーネントの中にも入ります。
<Callout>のようなコンポーネントは構文木上でelementノードではないので、ノードの型を名指しして中に入る必要があります。
コンポーネントの中のMarkdownは普通のhastのままですが、elementノードにしか降りていかない走査はコンポーネントの境界で止まります。その結果、コールアウトの中のカラーコードには色見本が付かず、段落にある同じコードには付くことになります。
走査は手書きの再帰です。ハイライトした分岐が、コンポーネントの中に入る部分です。
// 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はどちらでも届きません。

--borderでは、ページの背景と同じ色の色見本がほとんど見えなくなります。
--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'です。
style-src-attr 'unsafe-inline'は、Expressive Codeが内部で使うハイライターのShikiがコードに書き込む色のために、もともと入れていました。このディレクティブを入れた経緯はCSPの記事で書いています。プラグインのためにポリシーを変更する必要はありませんでした。style-src-attrがなく、style-srcにも'unsafe-inline'がないサイトでは、ブラウザがインラインのstyle属性をブロックするので、色見本はすべて中身のない枠だけの四角になります。
まとめ
このブログでは、インラインコードに書いた16進カラーの前に、その色の色見本が表示されるようになりました。描いているのはコンポーネントではなくrehypeプラグインなので、すべての記事で、バッククォートを書き換えることなくカラーコードに色見本が付きました。具体的な手順は次のとおりです。
markdown.processorに登録する(@astrojs/mdxはこの設定を引き継ぐ)。- テキスト全体が色であるインラインの
<code>に一致させる。 <pre>はスキップし、mdxJsxFlowElementとmdxJsxTextElementの中には入る。- 色見本の枠線には、ダークとライトの両方のページ背景に対して3:1に届くトークンを使う。
- CSPのあるサイトで色を
styleのカスタムプロパティで渡すには、style-src-attrでインライン属性を許可しておく。

