タグ
白地に置かれた紫のタイトルバーを持つ暗いコードウィンドウ。コード行はグレーと緑のバーで表され、一段がハイライトされている。下半分を虹色の毛先の絵筆が横切っている

AstroブログにExpressive Codeを導入する — ファイル名タブ・ファイルアイコンと、読めていなかった97個のコードフェンス

Astro 7でExpressive Codeを導入した記録。ファイル名タブ、テーマ別のファイルアイコン、Sätteriプロセッサの落とし穴、そしてShikiが言語名として読んでいた97個のコードフェンス。

目次

はじめに

このサイトはビルドのたびに、 [Shiki] The language "ts:src/lib/blog.ts" doesn't exist, falling back to "plaintext" のような行を100行ほど出力していました。私はそれを他人のツールが出すノイズとして扱ってきました。違いました。その書き方を指示していたのは、自分のスタイルガイドだったのです。つまり私が書く記事はすべてこのバグを忠実に再現し、サイトができたときからそうし続けていました。

問題は警告そのものではありません。97個のコードブロックが書式のないプレーンテキストとして描画され、それぞれに付けたはずのファイル名はページに一度も届いていませんでした。コードが大半を占めるブログで、コードフェンスが果たすべき役割の両方が黙って捨てられていたことになります。

解決策はExpressive Codeの導入と、全コードフェンスへの1行の書き換えでした。そこに至るまでに4回遠回りをしています。一度も適用されなかったCSSルール、URIのプレフィックス欠落で失敗したビルド、エージェントが2回続けて外した診断、そして存在しなかったバグの「修正」です。

本記事では、Astroブログのスニペット UI が実際に満たすべき条件、プラグインが肩代わりしてくれる部分、そして逆に抵抗してくる部分を整理します。

コードブロックが担うもの

何かを選ぶ前に、構成要素をはっきりさせておく価値があります。以降の説明でその名前を使うためです。

コードブロックを構成する要素レンダリングされたコードブロックが 2 つ。上はエディタ枠で、左上のファイル名タブにファイル種別アイコンと pyproject.toml という名前が入り、本文の右上にコピーボタン、その下にシンタックスハイライトされた TOML が入る。下はターミナル枠で、ウィンドウドットが 3 つあるだけでタイトルはなく、シェルコマンドが入る。この形の違いが、ファイルを見ているのかセッションを見ているのかを読者に伝える。pyproject.tomlファイル名タブハイライトされた本文ファイル種別アイコンコピーボタンターミナル枠シェルはタイトルなし
ファイルにはタブと名前が付きます。シェルのセッションにはウィンドウドットが付き、名前は付きません。形そのものが手がかりになります。

重要度が判明した順に、要件は4つでした。

  1. シンタックスハイライト。これはAstroがShiki経由で最初から提供します。
  2. ファイル名。そのスニペットの主眼がファイルの同一性にある場合に必要です。
  3. 両方のテーマ。このサイトはlight-dark()でライトとダークを切り替えるので、それを無視するコードブロックは白いページ上の黒い長方形になります。
  4. コピーボタン。スニペットは実行されることを前提に載せているからです。

Astroが提供するのは1つ目だけです。残る3つが作業の中身になります。

コードフェンスの書き方はZennの流儀だった

サイトではコードフェンスを ```ts:src/lib/blog.ts と書いていました。これはZennやQiitaの書き方で、実際に打ちやすい書式です。同時に、Astroのパイプラインではどこもこれを解釈しません。Astroはこのトークン全体を言語名としてShikiに渡し、Shikiはts:src/lib/blog.tsという言語を見つけられず、プレーンテキストにフォールバックします。

同じスニペットの、フェンス書き換え前後同じスニペットの 2 通りの描画。左はバッククォート 3 つに続けて ts:src/lib/blog.ts と書いた場合で、ファイル名タブはなく、テキストは一様な灰色、data-language 属性は plaintext、ビルド警告を 1 行出していた。右は同じフェンスを title="…" で書いた場合で、ファイル種別アイコン付きのファイル名タブが付き、コードには色が付き、data-language は ts になり、警告は出ない。公開されていたもの```ts:src/lib/blog.tsdata-languageplaintextシンタックスハイライトなしファイル名は一度も描画されないビルドごと、ブロックごとに警告 1 行現在公開されているもの```ts title="src/lib/blog.ts"src/lib/blog.tsdata-languagetsTypeScript としてハイライトタブにファイル名とアイコン警告なし
左の列が実際に公開されていたものです。コードフェンスに書いたファイル名は、HTMLのどこにも現れていませんでした。

重要なのは、これが見た目だけの警告ではないという点です。コードフェンスが宣言していたファイル名でビルド出力を検索しても、ヒットは0件でした。ブロックにはdata-language="plaintext"が付いており、ハイライトは間違っていたのではなく、そもそも効いていませんでした。

この書き方のコードフェンスは、3ロケール・11言語にまたがる18ファイルに94個ありました。

ターミナルウィンドウ
grep -rhoE '^```[A-Za-z0-9_+#-]+:' src/content/blog | sort | uniq -c | sort -rn
48 ```ts:
12 ```python:
10 ```css:
6 ```toml:
5 ```astro:

さらに3個が4つ目の書き方、つまり1行目のコメントとしてファイル名を書く形になっており、合計は97個でした。

コードフェンスそのものより、ルールのほうが重要でした。 house-style.mdを直さずに97個のブロックだけ直しても得るものはありません。次に書く記事が、また古いルールに従って書かれるからです。

ブロックを描画する仕組みを選ぶ

エージェントは3つの選択肢を挙げました。小さなremarkプラグインを書けばlang:pathを言語とタイトルに分割でき、既存の書式を保ったまま記事に一切手を入れずに済みます。Expressive Codeを採用する場合は、97個すべてをtitle="…"に書き換える必要があります。これはDocusaurus、rehype-pretty-code、Expressive Codeのいずれもが解釈する書式です。

私はExpressive Codeを選びました。エコシステムのどのツールも解釈しない独自書式を抱え続けたことが、そもそもの発端です。そしてこのプラグインは、描画コードを1行も書かずにファイル名ヘッダー、両テーマ、コピーボタン、ターミナル枠を提供します。

astro.config.mjs
integrations: [
expressiveCode({
themes: ['github-light', 'github-dark'],
themeCssSelector: (theme) => `[data-theme='${theme.type}']`,
}),
mdx(),
]

このうち2行は立ち止まる価値があります。expressiveCode()mdx()よりに置く必要があり、インテグレーション自身がそれを検査します。そしてthemeCssSelectorは上書きが必須です。既定ではtheme.nameを参照して[data-theme='github-dark']を出力しますが、このサイトのトグルが書き込むのはlightdarkだからです。

作らなかったタブグループ

Expressive Codeにタブグループはなく、エージェントは追加に反対しました。その理屈は妥当でした。このブログで2つのコードブロックが間に文章を挟まず隣接している12箇所のうち、選択肢の組になっているものは1つもありません。ファイルとそれを使うコマンド、コマンドとその出力、整合させる必要がある2つの設定ファイルです。タブは「AかBか」のためのものです。「AとB」の片方をクリックの向こうに隠すことは、その箇所が示そうとしている対比そのものを壊します。

何も起きなくなっていたはずの落とし穴

Astro 7を使っているなら、ここが一番知っておく価値のある部分です。

Astro 7は既定のMarkdownプロセッサをunifiedからSätteriに変更しました。そして@astrojs/mdxmarkdown.rehypePluginsを統合するのは、プロセッサがunifiedのときだけです。つまりドキュメントどおりの方法で自身を登録するインテグレーションは、既定のAstro 7環境ではどこにも届きません。警告も出ません。導入は成功し、そして何も描画しません。

コードフェンスが Expressive Code に届く経路と、届かない経路.mdx 内のコードフェンスは @astrojs/mdx を経由し、どの Markdown プロセッサが設定されているかで分岐する。unified 側では Astro が markdown.rehypePlugins を統合するため拡張が動く。Astro 7 の既定である Sätteri 側では、それらは完全に無視されるため、その経路だけで登録する拡張は導入に成功したまま何も描画しない。Expressive Code は Sätteri が読む options.hastPlugins にも登録するため動作し、フェンスは figcaption と pre を含む figure になる。.mdx 内のコードフェンス@astrojs/mdxどの Markdown プロセッサかunifiedmarkdown.rehypePlugins を統合する拡張が動くoptions.hastPlugins に登録するSätteriAstro 7 の既定完全に無視する拡張が動かない導入は成功し、何も描画しないfigure › figcaption › pre › code
右側の分岐が既定の経路です。左側しか知らないインテグレーションは、何も言わずに失敗します。

Expressive Codeに決める前に、エージェントはドキュメントを信用せず、公開されているバンドルを直接確認しました。

ターミナルウィンドウ
grep -c -i satteri node_modules/astro-expressive-code/dist/index.js

isSatteriProcessorの分岐があり、Sätteriが実際に読むoptions.hastPluginsへ登録していました。この確認が空振りだったなら、移行作業は問題なく完了し、見た目は何も変わらなかったはずです。そういう失敗の仕方は、ブラウザで気づくより10秒で見つけたいものです。

プラグイン自身のCSSに負けた3つのルール

Expressive Codeは自分のブロックをホスト側のCSSから遮断しており、その遮断は見た目より強力です。サイト側のルールが3つ別々に負け、そのどれもビルド出力からは見えませんでした

同じ遮断レイヤーに負けた 3 つのルールサイト側の指定とプラグイン側に既にあった指定を並べた 3 行。タブ幅では .expressive-code pre への指定が詳細度 0-1-1 でプラグインのリセットと同値になり、読み込み順で負けた。コピーボタンではセレクタが完全に同一で、やはり同値だった。ファイル名タブではサイト側が editorTabBarBorderBottomColor を指定したが、輪郭を描いていたのは editorTabBarBorderColor で、輪郭は残ったまま、代わりにコードブロックの上辺が消えた。いずれも差は詳細度 1 段以内で、同値はどのスタイルシートが後に読み込まれるかで決まる。サイト側が指定したものプラグイン側に既にあったもの結果タブ幅.expressive-code prevs.expressive-code *:not(:is(svg, svg *))負けコピーボタン.expressive-code .copy buttonvs.expressive-code .copy button負けファイル名タブeditorTabBarBorderBottomColorvseditorTabBarBorderColor負け同値の場合は読み込み順で決まる。それは誰かが下した設計判断ではない。
3つのうち2つは詳細度が同点でした。同点はどのスタイルシートが後に読み込まれるかで決まります。

最初のものが本命でした。この記事群はこのリポジトリ自身のソースを引用しており、そのソースはタブでインデントされています。以前の修正でtab-size: 2を設定し、8桁ではなく1インデント分で描画されるようにしてありました。移行計画ではそのルールを.expressive-code preに付け替えたのですが、黙って効かなくなりました。

src/styles/global.css
/* 負けるほう。Expressive Codeが全子孫要素のtab-sizeをリセットする */
.expressive-code pre {
tab-size: 2;
}

何も失敗しません。pnpm checkpnpm buildも緑でした。これが表に出たのは、実際のブラウザで計算済みスタイルを測り、tabSize8で返ってきたときだけです。同じルールを実行時に注入しても効かず、インラインスタイルなら効いたことから、読み込み順ではなく詳細度の問題だと特定できました。

勝てるルールは、プラグイン自身の:not()を繰り返します。

src/styles/global.css
.expressive-code pre:not(:is(svg, svg *)) {
tab-size: 2;
}

このセレクタは不格好で、それは意図的です。Expressive Codeはtab-sizeの設定項目を公開していないため、タブを保つ唯一の方法は、ホストのCSSを届かせないために書かれたルールを詳細度で上回ることでした。

一般化できる部分はこうです。ビルドが緑であることは、CSSが適用された証拠にはなりません。 この3つの戦いはいずれも、このプロジェクトが持つすべてのチェックを通過していました。

画像に色を焼き込むとテーマを手放すことになる

私は、エディタのファイルツリーのようなフルカラーのファイル種別アイコンを求めました。エージェントはSVGのデータURIとして、SVGのバイト列の中にブランドカラーを入れる形で実装し、言語をキーにしました。

src/styles/code-file-icons.css
.frame.has-title:has(pre[data-language='ts']) .title::before {
background-image: url("data:image/svg+xml,…");
}

ここでは:has()が実際に効いています。言語が乗っているのは<pre>で、これはキャプションの後ろにある兄弟要素です。そのためセレクタは、タブから下のブロックへ前方に手を伸ばす必要があります。マークはCC0のsimple-iconsから取っているので、帰属表示の義務は発生しません。

問題は構造的なものです。background-imageはCSSで塗り替えられないため、各アイコンはちょうど1色だけを持ちます。一方でそれが乗るタブは、ほぼ白とほぼ黒のあいだで切り替わります。ブランドカラーは片方の背景を前提に選ばれています。JavaScriptの黄色は暗いエディタを、CSSの紫は明るいページを想定しています。

マーク ライトのタブ上 ダークのタブ上
js #F7DF1E 1.29:1 13.12:1
css #663399 8.03:1 2.11:1
toml #9C4121 6.29:1 2.70:1

11個中3個が、非テキストのコントラスト下限である3:1を下回り、うち1つは事実上見えませんでした。

エージェントの対処は、それらを補正することでした。ビルド時に全マークを両方のタブ背景に対して測定し、下回るものを閾値に達するまで黒または白へ寄せ、2つの補正結果が異なる場合はダークテーマ用のルールを追加で出力しました。

私はそれを見た瞬間に却下しました。白いタブ上で基準を満たすまで暗くしたJavaScriptのバッジは濁ったオリーブ色で、一瞬で識別されることだけが仕事のアイコンが、名指ししている当のものに見えなくなっていました。比率は満たされ、機能のほうが失われていたわけです。

そこでマークは両テーマとも本来のブランドカラーで出力し、ジェネレータは補正するはずだった内容を報告するだけにしました。

src/styles/code-file-icons.css: 11 icons, 14949 bytes
3 mark(s) below 3:1, kept at brand colour:
js #F7DF1E — 1.29:1 on the light tab
css #663399 — 2.11:1 on the dark tab
toml #9C4121 — 2.70:1 on the dark tab

これは実在するアクセシビリティ上のコストであり、言い逃れるのではなく書き残しています。結果を変えないと決めた測定値でも、出力には残す価値があります。そうしないと、後から誰も見つけられない判断になり、半年後の保守担当がそれを「修正」して元に戻すからです。

1つだけ上書きしたままのマークがあります。JSONのブランドカラーは純粋な#000000で、ダークのタブ上では1.06:1です。これは弱いのではなく、存在していません。ここには識別性の議論が守るべきものが残っていません。

レビューが捕まえたもの

プルリクエストを開く前にコードレビューを依頼しました。レビューは、エージェントが検証したうえでなお外していたものを2つ見つけました。

1つ目は前述の見えないjsアイコンです。2つ目はもっと厄介で、エージェントは存在しないバグを「修正」していました。

エージェントは、Expressive Codeがコピーボタンをホバー時にのみ表示し(hover: none)のフォールバックを持たないため、タッチ環境では到達不能だと報告していました。この読み取りは、スタイルシートを平坦化して囲みのアットルールを落としてしまうスクリプトから来ていました。実際に出力されているのは次のとおりです。

.expressive-code .copy button { opacity: 0.75; width: 2.5rem }
@media (hover: hover) {
.expressive-code .copy button { opacity: 0; width: 2rem }
}

非表示にする指定はメディアクエリの内側にあります。タッチ環境はもとから問題なく、むしろ「修正」がベースのルールを0.5で上書きし、タッチ環境では以前より暗くしていました。(hover: hover)に限定することが、必要だった修正です。

この移行で遠回りした4回のうち2回は、同じ癖から来ています。CSSをブラウザで測らず、スクリプトで読んだことです。tab-sizeのルールは詳細度を机上で計算したために負け、コピーボタンは正規表現がメディアクエリなしのルールを見たために「修正」されました。

実際に何かを証明する検証

最初の症状が最も安上がりなチェックで、これは何も出力しないことが条件です。

ターミナルウィンドウ
pnpm build 2>&1 | grep -i shiki

続いて、コードフェンスの書き換えが実行されただけでなく反映されたことを示す2つです。

ターミナルウィンドウ
grep -rcE '^```[A-Za-z0-9_+#-]+:[^ ]+$' src/content # コロン形式のコードフェンスは0
grep -rl 'astro-code' dist/ # 空。Shikiのマークアップは消えた

そして、内容が黙って失われるのを捕まえるものです。Expressive Codeには、先頭4行のコメントからファイル名を読み取り、一致した行を削除するヒューリスティックがあります。このブログではコメントで始まるコードフェンスが42個あり、うち3個は文字どおり# pyproject.tomlです。この機能は無効にしてあり、無効のままであることを示すのがこれです。

ターミナルウィンドウ
grep -c 'the Vertex AI API has to be enabled' dist/blog/aimock-*/index.html

生成ファイルには、約束事ではなくずれ検知を付けます。pnpm icons:checkはアイコンのCSSをメモリ上で再生成して比較し、コミット済みのファイルが古ければ失敗します。これは既存のサムネイルチェックの隣で、pnpm checkの一部として動きます。

コピーボタンは3言語のうち2言語で英語のままだった

この移行から数か月後、共有リンク用に2つ目のコピー操作を作っていて、こちらのボタンがずっとやっていたことに気づきました。@expressive-code/plugin-framesが持つ翻訳は英語とドイツ語だけで、しかもここではgetBlockLocaleを設定していなかったため、プラグインはページがどの言語なのかを知る手段がありませんでした。日本語と繁体字中国語の記事はすべて、導入した日からコピーボタンにCopy to clipboardと表示し、Copied!と答えていたことになります。

修正は、記事のファイル名からロケールを取り出し、サイト自身の UI 辞書から文言を登録することです。

astro.config.mjs
for (const locale of LOCALES) {
pluginFramesTexts.addLocale(locale, {
terminalWindowFallbackTitle: UI.terminalWindow[locale],
copyButtonTooltip: UI.copyCode[locale],
copyButtonCopied: UI.copied[locale],
});
}
getBlockLocale: ({ file }) => file.path.match(LOCALE_FILENAME)?.[1] ?? DEFAULT_LOCALE,

間違えやすい点が2つあります。addLocaleはそのロケールの文言をマージではなく丸ごと置き換えるので、3つのキーをすべて渡す必要があります。terminalWindowFallbackTitleを省くと、その文字列だけがすべてのターミナル枠で英語のまま残ります。もう1つは、登録先をzhではなくzh-twにすることです。検索は['zh', 'zh-tw']の順に走って最初に見つかったものを返すため、zhの登録があると繁体字中国語の指定が隠れてしまいます。

これがこの記事に属するのは、失敗の形がまったく同じだからです。pnpm checkは緑で、astro checkはヒントを1件も出さず、ビルドも成功していました。描画されたボタンが何語で書かれているかは、このパイプラインのどこも見ていません。

まとめ

Astroブログでスニペット UI を作るなら、今回得られた実践は次のとおりです。

  • ファイル名はtitle="…"に書く。 それがエコシステムの解釈する書式です。自分のエディタしか理解しない独自書式は言語名として読まれ、静かに失敗します。
  • コードフェンスの規則はスタイルガイドに書き、まずガイドのほうを直す。 自分でバグを生み続ける規則は、片付けるたびにまた生成されます。
  • Astro 7では、インテグレーションがSätteriに対応しているかを先に確認する。 ドキュメントどおりのrehypePlugins経路は既定環境ではどこにも届かず、警告も出ません。
  • CSSはスクリプトではなくブラウザで測る。 今回の4つの失敗のうち2つは読み取りの誤りでした。片方は詳細度を机上で計算し、もう片方はメディアクエリを落としていました。
  • 焼き込んだ色はテーマに追従できない。 全マークを両方の背景に対してビルド時に測り、そのうえで意識的に選びます。色を補正するか、mask-imageに切り替えて色を諦めるか、ブランドマークを保ったままコストを記録するか。唯一の誤りは、知らないままにすることです。
  • 生成ファイルはコメントではなくチェックで守る。 icons:checkは10行程度で、「再実行を忘れた」という種類の問題をまとめて消します。

何度も立ち返ってしまうのは、ここに挙げたすべてが緑のビルドを通過していたという点です。タブ幅も、見えないアイコンも、暗くなったコピーボタンも、発端となった100個近いブロックも、ツールから見れば何の問題もありませんでした。

参考リンク

この記事をシェア