Astro MDX ビルドエラー 3 大原因と根本対策|acorn パース・ランタイム・import 解決
- 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/mdx → acorn(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/mdxのcompile()はパースだけを行い、実行はしません- そのため 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/mdxのcompile()は 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 import | import 先ファイル存在チェック | 欠落コンポーネント | 構文・ランタイム |
# ビルド前の 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.mjsのmarkdown.remarkPluginsを確認