Astroで目次機能を実装する際の落とし穴と解決法
Astroで記事に自動生成される目次(Table of Contents)機能を実装しようとしたところ、思わぬ落とし穴にハマった。本記事では、その実装過程で遭遇した課題と解決方法について詳しく解説する。
なぜ目次機能が必要なのか
目次機能は以下の点で重要である:
- ユーザー体験の向上 - 読者が必要な情報に素早くアクセスできる
- SEO効果 - Googleが記事構造を理解しやすくなり、検索結果に目次リンクが表示される可能性がある
- アドセンス審査 - コンテンツの質と構造化を示す重要な要素
- 滞在時間の増加 - 記事内をスムーズに移動できることで直帰率が下がる
実装しようとした機能
今回実装しようとした目次機能の要件は以下の通りである:
- Markdown記事の見出し(h2, h3)を自動で抽出
- クリックで該当セクションへジャンプ
- スクロール時に現在位置をハイライト
- レスポンシブ対応
- ダークモード/ライトモード対応
最大の落とし穴 - H1タイトルの位置問題
問題の発見
最初は簡単だと思っていた。TableOfContents.astro
コンポーネントを作成し、MarkDownPostLayout.astro
に組み込むだけ。しかし、実装してみると目次がH1タイトルの上に表示されるという問題が発生した。
<!-- 最初の実装 -->
<div class="post-body">
<TableOfContents headings={headings} />
<slot />
</div>
なぜこの問題が起きるのか
Astroの構造を理解する必要がある:
-
レイアウトファイル(MarkDownPostLayout.astro)
- ページ全体の構造を定義
- ヘッダー、フッター、広告などを配置
<slot />
でMarkdownコンテンツを挿入
-
Markdownファイル(各記事.md)
- frontmatterでメタデータを定義
- 本文の最初にH1タイトルを記述
- H1以降が
<slot />
として挿入される
つまり、H1タイトルは<slot />
の中に含まれているため、<slot />
の前に目次を配置すると、必然的にH1タイトルの上に表示されてしまうのである。
試行錯誤した解決策
1. 単純な配置変更(失敗)
<!-- H1はslotの中にあるので、これでは解決しない -->
<TableOfContents headings={headings} />
<slot /> <!-- H1タイトルはこの中 -->
2. CSSでの制御(部分的成功)
/* 視覚的には下に見えるが、DOM構造は変わらない */
.toc-container {
order: 2;
}
3. JavaScriptによる動的移動(成功)
最終的に、クライアントサイドのJavaScriptで目次を動的に移動させる方法で解決した。
document.addEventListener('DOMContentLoaded', () => {
const tocPlaceholder = document.getElementById('toc-placeholder');
const toc = tocPlaceholder?.querySelector('.toc-container');
const h1 = document.querySelector('.post-body h1');
if (toc && h1) {
// H1の直後に目次を挿入
h1.insertAdjacentElement('afterend', toc);
tocPlaceholder.remove();
}
});
実装のポイント
1. 見出しの抽出とIDの自動生成
Astroは標準でMarkdownの見出しにIDを生成しない。astro.config.mjs
での設定が必要である。
export default defineConfig({
markdown: {
syntaxHighlight: 'shiki',
shikiConfig: {
theme: 'dracula'
}
}
});
2. スムーススクロールの実装
単純なhref="#section"
では急激にジャンプしてしまうため、JavaScriptでスムーススクロールを実装する。
tocLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetId = link.getAttribute('href')?.slice(1);
const targetElement = document.getElementById(targetId || '');
if (targetElement) {
const headerHeight = 80; // ヘッダーの高さ分オフセット
const targetPosition = targetElement.offsetTop - headerHeight;
window.scrollTo({
top: targetPosition,
behavior: 'smooth'
});
}
});
});
3. 現在位置のハイライト
スクロール時のパフォーマンスを考慮し、requestAnimationFrame
を使用する。
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(updateActiveLink);
ticking = true;
}
});
学んだ教訓
1. SSGの制約を理解する
Astroのような静的サイトジェネレーターでは、ビルド時とランタイムの違いを理解することが重要である。Markdownの処理はビルド時に行われるため、レイアウトファイルから直接Markdown内のH1を制御することはできない。
2. プログレッシブエンハンスメント
最初から完璧を求めるのではなく、段階的に機能を追加していくアプローチが有効である:
- 基本的な目次表示
- スムーススクロール追加
- 現在位置ハイライト
- レスポンシブ対応
3. クライアントサイドJavaScriptの活用
SSGの制約を、クライアントサイドのJavaScriptで補完することで、より柔軟な実装が可能になる。ただし、JavaScriptが無効な環境でも基本機能が動作するよう配慮が必要である。
完成したコンポーネント
最終的に完成したTableOfContents.astro
は以下の機能を持つ。
- 自動的な見出し抽出(h2, h3)
- スムーススクロール
- 現在位置のハイライト
- アクセシビリティ考慮
まとめ
Astroで目次機能を実装する際の最大の落とし穴は、H1タイトルがMarkdownファイル内にあることである。この問題は、SSGの仕組みを理解し、適切にクライアントサイドのJavaScriptを組み合わせることで解決できる。
目次機能は実装に多少の工夫が必要であるが、ユーザー体験の向上やSEO効果を考えると、投資する価値は十分にある。特にGoogleアドセンスの審査を考えている方は、ぜひ実装を検討してほしい。