前回のサイト制作シリーズでは Astro でのブログ構築 Tips をお話ししました。 今回はその番外編として、シンタックスハイライトが丸ごと消えたトラブルの一部始終を記録しておきます。

2026年2月14日。「ちょっとプラグインを追加するだけ」のつもりが、気づいたらコードブロックのスタイルが全滅していました。

Expressive Code とは

このブログでは astro-expressive-code を使ってシンタックスハイライトを実現しています。Astro 標準の Shiki を内包しつつ、コードブロックの UI もまとめて提供してくれるライブラリです。

素の Shiki との大きな違いは「コードブロック全体のUIまで面倒を見てくれる」点です。

  • コピーボタン
  • 行番号の表示
  • セクションの折りたたみ
  • ファイル名タブ

これらをゼロから実装するのは地味に手間なので、最初から Expressive Code を選んでおいたのは正解でした。基本的な設定はこちらです。

astro.config.mjs
import expressiveCode from 'astro-expressive-code';
import { pluginCollapsibleSections } from '@expressive-code/plugin-collapsible-sections';
import { pluginLineNumbers } from '@expressive-code/plugin-line-numbers';
expressiveCode({
themes: ['dracula', 'github-light'],
useThemedScrollbars: false,
themeCssSelector: (theme) => `[data-theme="${theme.type}"]`,
plugins: [pluginCollapsibleSections(), pluginLineNumbers()],
defaultProps: {
showLineNumbers: true,
},
}),

themeCssSelector[data-theme="dark"] / [data-theme="light"] 属性でテーマを切り替えるための設定で、このブログのダークモード切替の仕組みに合わせています。

外部リンクに自動で target="_blank"rel="noopener noreferrer" を付与するため、rehype-external-links を追加しました。当初は mdx() インテグレーションのオプションとして設定していました。

astro.config.mjs(壊れていた設定)
mdx({
rehypePlugins: [
[rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }],
],
}),

ビルドして確認したところ、コードブロックのシンタックスハイライトが完全に消えていました。

背景色もボーダーもなく、ただのプレーンテキストが <pre> タグの中に並んでいる状態です。コピーボタンも行番号も消滅。見た目が完全に崩壊していました。

最初の仮説:プラグインの順序が悪い?

真っ先に疑ったのは integrations 配列内の順序です。Astro のインテグレーションは宣言順に処理されるため、expressiveCode()mdx() の前に置くべきなのかと思い、順序を入れ替えてみました。

astro.config.mjs(試行錯誤)
// パターン①
integrations: [icon(), mdx(), expressiveCode(), sitemap()]
// パターン②
integrations: [icon(), expressiveCode(), mdx(), sitemap()]

どちらも 効果なし

原因:処理レイヤーが根本的に違った

調べていくうちに、問題の本質が見えてきました。

Expressive Code は Astro Integration として動作します。つまりビルドパイプラインの前処理として、コードブロックを <figure class="expressive-code"> という専用の HTML 構造に変換します。このときスタイルも <style> タグとして出力されます。

一方の rehype プラグインは Markdown / MDX の変換パイプライン上で動作します。Expressive Code が生成した HTML に対して、後から処理を加える形になります。

rehype-external-links は「http で始まるリンクにはすべて属性を付ける」という動作をするため、Expressive Code が生成した <figure> 内部のリンク(コードブロック内の URL)にも手を加えてしまいました。結果として Expressive Code が期待する HTML 構造が崩れ、スタイルのセレクタが一致しなくなったというわけです。

Expressive Code(Integration)
コードブロック → <figure class="expressive-code">...</figure>
rehype-external-links(Markdownパイプライン)
<figure> 内部のリンクにも target="_blank" を付与
→ HTML 構造が変化
Expressive Code の CSS セレクタが一致しなくなる
→ スタイル崩壊

integrations の順序を変えても意味がなかったのは、そもそも同じ処理レイヤーで競合していたわけではなかったからです。

解決策:コードブロック内を除外セレクタで逃がす

rehype-external-links には selectors オプションがあり、対象にするリンクの CSS セレクタを指定できます。ここで .expressive-code 内のリンクを除外することで解決しました。

あわせて mdx() の rehype オプションとしてではなく、トップレベルの markdown.rehypePlugins に移動しています。

astro.config.mjs(修正後)
integrations: [
icon(),
expressiveCode({ /* ... */ }),
mdx(),
sitemap(),
],
markdown: {
rehypePlugins: [
[
rehypeExternalLinks,
{
target: '_blank',
rel: ['noopener', 'noreferrer'],
// pre・code・.expressive-code 内のリンクは除外
selectors: [
"a[href^=http]:not(:where(pre, code, .expressive-code) a)",
],
},
],
],
},

not(:where(...)) を使うことで、除外セレクタが他のスタイルの特異性(specificity)に影響しないようにしています。

これで、コードブロック外の外部リンクだけに target="_blank" が付き、Expressive Code の HTML 構造は保たれます。

おまけ:言語指定は小文字で

同じ調査中に別のバグも見つけました。C言語のコードブロックで言語指定を C(大文字)にしていたところシンタックスハイライトが効かない問題です。Expressive Code(内部的には Shiki)は言語名の大文字・小文字を区別するため c(小文字)に変更して解決しました。地味なハマりポイントです。

今回は「ちょっとプラグインを追加するだけ」のつもりが半日溶けました。同じ場所でつまずく人が一人でも減れば幸いです。

次回は、このブログのコピーボタン日本語化に取り組んだ話(sed で強引に解決 → プラグイン API で正攻法へ)をお届けします。