デプロイした Astro サイトで、コードブロックが枠もシンタックスハイライトもない素のテキストになっていた。

一方、手元の環境ではコードブロックが正常に表示されていた。

直接の原因は、Astro のコンテンツレイヤーのキャッシュに、expressive-code が生成した古いスタイルシート名への参照が残ることにある。
astro-expressive-code1 を使っていてこの症状が出たら、astro build --force でキャッシュを捨ててビルドし直すと直る。
ただし、デプロイのたびに必ず --force が要るわけではない。
どういうときに起きるのかは、調査の経過とあわせて最後に整理する。
症状の切り分け
まずデプロイされた HTML を curl で確認した。
expressive-code はコードブロックのスタイルを一つの CSS ファイルにまとめて出力し、コードブロックを含む各ページの HTML に <link> を注入する。
その参照先が 404 を返していた。
$ curl -s -o /dev/null -w '%{http_code}\n' https://<preview>/_astro/ec.bim6f.css404$ curl -s -o /dev/null -w '%{http_code}\n' https://<preview>/_astro/ec.0vx5m.js200CSS だけが落ちて、JS は読めている。 枠と色が消えて未スタイルのコピーボタンだけが四角く残る見た目とも符合する。
手元では本当に壊れていないのか
手元で astro build し直して dist を調べると、同じ不整合があった。
出力された CSS は一つだけなのに、ページからの参照が二種類に割れている。
$ ls dist/_astro | grep '^ec\.'ec.0vx5m.jsec.b2pup.css$ grep -roh 'ec\.[a-z0-9]*\.css' dist | sort | uniq -c 1 ec.b2pup.css 3 ec.hc4nc.css実在するファイルを参照していたのは、直近で本文を書いた記事だけだった。 本文を変えていない古い記事は、存在しないファイル名を参照していた。
二つのキャッシュ
この割れ方は、二つの仕組みの組み合わせで説明できる。
expressive-code が出力する CSS のファイル名は、内容から計算したハッシュを含む。 テーマや styleOverrides を変えれば CSS の内容が変わるので、ファイル名ごと変わる。
一方、コンテンツレイヤーは Markdown のレンダリング結果をキャッシュする。
キャッシュには expressive-code が注入した <link> もそのまま含まれる。
そして再利用するかどうかの判定は記事本文の変更を見るだけで、expressive-code の設定変更は検知されない2。
だから、expressive-code の設定を変えた後のビルドでは、新しいファイル名の CSS が一つだけ出力されるのに、本文が変わっていない記事はキャッシュされた HTML のまま古いファイル名を参照し続ける。

開発サーバーで崩れなかったのは、キャッシュが二系統あるからだった。
dev は .astro/ のストアを使い、こちらは現行のファイル名で揃っていた。
ビルドは node_modules/.astro/ の別のストアを使い、古い参照はこちらに残っていた。
デプロイ先の参照は手元のどちらとも違うハッシュだったから、CI のビルドキャッシュには、さらに古い時点のレンダリング結果が残っていたのだろう。
発生条件と対処の選び方
整理すると、この問題は次の三つが揃ったときに起きる。
- expressive-code がスタイルをハッシュ付きの外部 CSS として出力している(デフォルトの挙動)
- 前回ビルドのコンテンツレイヤーキャッシュが残っていて、本文を変えていない記事に再利用される(手元の連続ビルドや、CI のビルドキャッシュ)
- 前回ビルドから、生成される CSS の内容が変わった(テーマや styleOverrides の変更、expressive-code 自体の更新)
裏を返せば、設定もバージョンも変えない限りは起きない。
だから「デプロイのたびに必ず --force すべき」とまでは言えない。
--force はコンテンツレイヤーキャッシュを毎回捨てるので、記事数が多いサイトでは、本文を変えていない記事の再レンダリング分だけビルドが遅くなる。
問題は、三つ目の条件を人が追跡し続けられるかである。
設定の変更は自覚できるが、expressive-code 自体の更新は依存関係の更新に紛れて入ってくる。
変更のたびに --force を思い出す運用は、忘れた頃に一度失敗する。
このサイトは 9 ページで、キャッシュを捨ててもビルドは 5 秒で終わる。
だからビルドスクリプト自体を --force にして、条件の追跡ごとやめた。
"build": "astro build --force"記事数が多くてキャッシュを捨てられないサイトなら、expressive-code に関わる変更を入れたビルドだけ --force する(CI ならビルドキャッシュをクリアする)ことになる3。
Footnotes
-
expressive-code の Astro インテグレーション。コードブロックをエディタ風のフレームで描画する。 ↩
-
Astro 自体は設定変更でキャッシュを無効化する修正(astro#12767)を入れている。ただし、少なくともこのサイトで使っている Astro 5.18.2 では、関数や循環参照を含みうる
integrationsがハッシュ対象から除外されている。expressive-code の設定はその中に書かれるため、その変更だけではキャッシュが無効化されない。 ↩ -
キャッシュキーを自分で書ける CI なら中間解がある。たとえば GitHub Actions で
node_modules/.astroをキャッシュする際、キーにhashFiles('pnpm-lock.yaml', 'astro.config.mjs')を含めれば、トリガーになるファイルが変わったビルドだけキャッシュが自動で捨てられ、平常時はキャッシュが効く。変更の検知を人間ではなくキャッシュキーにやらせる形である。Cloudflare Pages の組み込みビルドキャッシュはキーを制御できないため、この手は使えない。CI に触らない別解として、ビルド後に dist 内の参照と実ファイルを突き合わせ、ずれていたときだけ--forceで自動的にビルドし直す自己修復ビルドも考えられる(平常時はキャッシュが効き、ずれたビルドだけ二回分の時間を払う)。 ↩