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設定でオン・オフを切り替えます。このサイト自身の図版用トークンでは、次のように表示されます。

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の構文木)として受け取り、その場で書き換える関数です。

unifiedの中で色見本プラグインが動く場所左から右へ4つの段階が並ぶ。バッククォート内に#1d4ed8を含む記事のソースは、Markdownの構文木であるmdastに変換される。mdastはHTMLの構文木であるhastに変換され、そこでrehypeTableScroll、rehypeLinkIcons、rehypeColorSwatchの3つのrehypeプラグインが順に動く。rehypeColorSwatchは強調表示されている。hastは色見本付きのHTMLであるビルド後のページになる。unifiedと書かれた括弧が最初の3つの段階にまたがる。unified記事のソース`#1d4ed8`バッククォート内mdastMarkdownの構文木remarkの段階hastHTMLの構文木rehypeTableScrollrehypeLinkIconsrehypeColorSwatchビルド後のページ色見本付きのHTML
記事のソースからページまでにunifiedが通る段階。色見本プラグインは、このブログに以前からある2つのプラグインと並んでHTMLの構文木を編集します。

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

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

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

astro.config.mjs
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のコンポーネントの中にも入っていきます。

記事のツリーで色見本プラグインが走査する範囲root を頂点に 4 つの子を持つツリー。2 つの段落は走査され、中のインラインコードが判定される。#1d4ed8 には色見本が付き、color: #fff は全体が色ではないので付かない。pre 要素はスキップされ、中のコードには到達しない。ノードの型が element ではなく mdxJsxFlowElement である Callout は中まで走査され、その段落内の #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. テキスト全体がカラーコードの場合だけが対象です。子が16進カラーに一致するテキストノード1つだけの<code>が該当します。color: #fffに色見本は付きません。長いコードの中では、#fadeが色なのかCSSのidセレクタなのかをプラグインは判別できません。VS Codeと違い、そのコードが何の言語かを解析しないからです。
  3. MDXのコンポーネントの中にも入ります。<Callout>のようなコンポーネントは構文木上でelementノードではないので、ノードの型を名指しして中に入る必要があります。

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

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

src/lib/rehype-color-swatch.ts
// 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に対して
--border1.28:11.23:1
--text-33.96:14.76:1

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

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

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

同じ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'です。

style-src-attr 'unsafe-inline'は、Expressive Codeが内部で使うハイライターのShikiがコードに書き込む色のために、もともと入れていました。このディレクティブを入れた経緯はCSPの記事で書いています。プラグインのためにポリシーを変更する必要はありませんでした。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でインライン属性を許可しておく。

参考リンク

この記事をシェア