Astro MDX ビルドエラー 3 大原因と根本対策|acorn パース・ランタイム・import 解決

Astro MDX ビルドエラー acorn Rollup フロントエンド トラブルシューティング
結論:MDX ビルドエラーは 3 つの別フェーズで起きる
  • Phase 1 — acorn パース: 未閉じコードブロック、\text{} 等のエスケープ → @mdx-js/mdx 事前コンパイルで検出
  • Phase 2 — JSX ランタイム評価: $W_{min}$ が JS 式として実行され min is not defined$...$ パターン走査で検出
  • Phase 3 — Rollup import 解決: 存在しない .astro コンポーネントの import → ファイル存在チェックで検出

なぜ「1 件直す → ビルド → 次のエラー」の無限ループに陥るのか

Astro(Vite)のビルドは、最初に検出した 1 件のコンパイルエラーで全体を即時中断します。205 件の MDX ファイルに潜在エラーが 5 件散らばっていても、ログに出るのは常に「その時最初につっかえた 1 件」だけです。

# ビルドログには 1 件しか出ない
[ERROR] [vite] ✗ Build failed in 8.00s
[@mdx-js/rollup] Could not parse expression with acorn
file: src/content/blog/fps-calculator-30fps-to-ms.mdx:57:10

この仕様のため、エンジニアは「1 件修正 → ビルド → 次の 1 件が出る → 修正 → ビルド → …」というもぐらたたきに陥りがちです。記事数が 10 件なら耐えられますが、100 件を超えるプロジェクトでは現実的ではありません。

解決策は、ビルドを回す前に全件のエラーを一括で洗い出す事前監査スクリプトを導入することです。


Phase 1 — acorn パースエラー(構文解析フェーズ)

原因

Astro の MDX パイプラインは内部で @mdx-js/mdxacorn(JavaScript パーサー)を使っています。MDX ファイルのコードフェンス外に以下のような記述があると、acorn が JavaScript として解釈できずにクラッシュします。

パターンエラーメッセージ例
未閉じコードブロック(``` が奇数)Unexpected end of input
\text{FPS} などの LaTeX コマンドExpecting Unicode escape sequence \uXXXX
コードフェンス外の }</style>Unexpected token
16ms のような数値直後の識別子Identifier directly after number

事前検出スクリプト

Astro が内部で使う @mdx-js/mdx そのもので全ファイルをコンパイルし、エラーを一括取得します。

// scripts/_mdx_compile_audit.mjs
import { compile } from '@mdx-js/mdx';
import fs from 'fs';
import path from 'path';

const blogDir = path.resolve('src/content/blog');
const files = fs.readdirSync(blogDir).filter(f => f.endsWith('.mdx'));
const errors = [];

for (const file of files) {
  const content = fs.readFileSync(path.join(blogDir, file), 'utf8');
  // YAML frontmatter を除去(MDX コンパイラは YAML を解釈しない)
  const stripped = content.replace(/^---[\s\S]*?---\r?\n/, '');

  try {
    await compile(stripped, { jsx: true, format: 'mdx' });
  } catch (err) {
    errors.push({
      file,
      line: err.line ?? err.position?.line ?? '?',
      message: (err.message ?? String(err)).split('\n')[0],
    });
  }
}

if (errors.length === 0) {
  console.log('ALL FILES PASS — safe to build');
} else {
  errors.forEach(e => console.log(`FAIL ${e.file}:${e.line} → ${e.message}`));
  process.exit(1);
}
# プロジェクトルートで実行(node_modules の @mdx-js/mdx を使用)
node scripts/_mdx_compile_audit.mjs

このスクリプトは npm run build同じコンパイラを使うため、パースフェーズのエラーは 100% 事前検出できます。


Phase 2 — JSX ランタイム評価エラー(静的生成フェーズ)

原因

Phase 1 をパスしても、Astro が静的 HTML を生成する段階でもう 1 つの罠があります。MDX はコードフェンス外の $...$インライン JSX 式として評価します。

<!-- これは acorn のパースは通るが、ランタイムで死ぬ -->
最小ビューポート $W_{min}$ で最小フォント $V_{min}$ にしたい場合:

acorn は $W_{min}$ を文法的に合法な JavaScript 式(W_ の後に {min} というブロック)として解析できますが、実行時に min という変数が存在しないため min is not defined でクラッシュします。

# ビルドログ — パースは通っているのにランタイムで落ちる
/blog/fluid-typography-作り方-clamp/index.html
  min is not defined
  Hint: This issue often occurs when your MDX component encounters runtime errors.

なぜ厄介か

  • @mdx-js/mdxcompile()パースだけを行い、実行はしません
  • そのため Phase 1 の監査スクリプトでは検出不可能です
  • npm run build を実際に走らせて初めて発覚します

対策

MDX ファイル内でインライン数式を使いたい場合は、$...$ ではなくコードフェンスまたはプレーンテキストで記述してください。

<!-- MDX では JSX 式として評価されてしまう -->
$W_{min}$ $V_{max}$ を求める

<!-- コードフェンスで囲む -->
`W_min` `V_max` を求める

<!-- プレーンテキストで書く -->
W_min V_max を求める

<!-- $$...$$ LaTeX ブロックもコードブロックに変換 -->

1フレームの所要時間 (ms) = 1000 / FPS

全ファイルを一括走査して $識別子$ パターンを検出するワンライナーは以下のとおりです:

# コードフェンス外の $IDENTIFIER$ パターンを検出
grep -Pn '(?<!`)\$[A-Za-z][A-Za-z0-9_{}^\\]+\$' src/content/blog/*.mdx

Phase 3 — Rollup import 解決エラー(モジュールバンドルフェーズ)

原因

MDX ファイルの先頭で import しているコンポーネントファイルが物理的に存在しない場合、Rollup のモジュール解決フェーズでビルドが失敗します。

<!-- このファイルが存在しないとビルドが落ちる -->
import ColorConverterEmbed from '@/components/blog/embedded-tools/ColorConverterEmbed.astro';
[vite]: Rollup failed to resolve import
  "@/components/blog/embedded-tools/ColorConverterEmbed.astro"
  from "src/content/blog/color-converter-hex-rgb-hsl.mdx"

なぜ厄介か

  • Phase 1(acorn パース)でも Phase 2(JSX 評価)でもない、第 3 のフェーズで発生します
  • @mdx-js/mdxcompile() は import 文を構文として記録するだけで、ファイルの存在を確認しません
  • そのため監査スクリプトが「エラー 0」と報告しても、このエラーは残ります

事前検出スクリプト

// scripts/_check_mdx_imports.mjs
import fs from 'fs';
import path from 'path';

const blogDir = path.resolve('src/content/blog');
const embedDir = path.resolve('src/components/blog/embedded-tools');
const existing = new Set(fs.readdirSync(embedDir));
const files = fs.readdirSync(blogDir).filter(f => f.endsWith('.mdx'));
const missing = [];

for (const file of files) {
  const content = fs.readFileSync(path.join(blogDir, file), 'utf8');
  const imports = [...content.matchAll(
    /import\s+\w+\s+from\s+['"]@\/components\/blog\/embedded-tools\/([^'"]+)['"]/g
  )];
  for (const [, component] of imports) {
    if (!existing.has(component)) {
      missing.push({ file, component });
    }
  }
}

if (missing.length === 0) {
  console.log('ALL IMPORTS RESOLVED');
} else {
  missing.forEach(m => console.log(`MISSING ${m.file} → ${m.component}`));
  process.exit(1);
}

3 つのチェックを統合する

3 つのフェーズのエラーはそれぞれ別の検出手段が必要です。ビルド前に必ず 3 つとも通すことで、もぐらたたきを構造的に排除できます。

Phase検出手段検出できるもの検出できないもの
1. acorn パース@mdx-js/mdx compile構文エラー全般ランタイム評価・import
2. JSX ランタイム$識別子$ パターン grep未定義変数の JSX 式import・構文エラー
3. Rollup importimport 先ファイル存在チェック欠落コンポーネント構文・ランタイム
# ビルド前の 3 段チェック
node scripts/_mdx_compile_audit.mjs   # Phase 1
grep -rPn '(?<!`)\$[A-Za-z][A-Za-z0-9_{}^\\]+\$' src/content/blog/*.mdx  # Phase 2
node scripts/_check_mdx_imports.mjs   # Phase 3

# 全パスしてからビルド
npm run build

実際に 205 記事で起きたエラーの内訳

今回のポートフォリオ(205 記事)で発生したエラーの実績値:

Phase該当ファイル数代表的な原因
Phase 1 (acorn)6 ファイル未閉じ ```(5 件)、\text{} LaTeX コマンド(1 件)
Phase 2 (ランタイム)1 ファイル$W_{min}$ のインライン LaTeX
Phase 3 (import)4 ファイルEmbed コンポーネント未作成

合計 11 件のエラーが 3 つのフェーズに分散していたため、ビルドを回すたびに「1 件目で止まる → 直す → 次の 1 件 → …」のループが最低 11 回発生する構造でしました。事前監査で全件一括検出した上で一括修正し、ビルドは 1 回で成功させることができましました。


うまくいかないとき

  • @mdx-js/mdx がインストールされていない: Astro プロジェクトなら node_modules/@mdx-js/mdx@astrojs/mdx の依存として既に入っているはず。ls node_modules/@mdx-js/mdx で確認
  • 監査スクリプトが ERR_MODULE_NOT_FOUND: スクリプトをプロジェクトルートの scripts/ 以下に置きます。node_modules から遠いディレクトリでは依存解決できない
  • remark / rehype プラグインの影響: Astro 側で特殊なプラグインを使っている場合、compile() にも同じプラグインを渡す必要があります。astro.config.mjsmarkdown.remarkPlugins を確認