Astroで目次機能を実装する際の落とし穴と解決法

Astroで記事に自動生成される目次(Table of Contents)機能を実装しようとしたところ、思わぬ落とし穴にハマった。本記事では、その実装過程で遭遇した課題と解決方法について詳しく解説する。

なぜ目次機能が必要なのか

目次機能は以下の点で重要である:

  1. ユーザー体験の向上 - 読者が必要な情報に素早くアクセスできる
  2. SEO効果 - Googleが記事構造を理解しやすくなり、検索結果に目次リンクが表示される可能性がある
  3. アドセンス審査 - コンテンツの質と構造化を示す重要な要素
  4. 滞在時間の増加 - 記事内をスムーズに移動できることで直帰率が下がる

実装しようとした機能

今回実装しようとした目次機能の要件は以下の通りである:

  • Markdown記事の見出し(h2, h3)を自動で抽出
  • クリックで該当セクションへジャンプ
  • スクロール時に現在位置をハイライト
  • レスポンシブ対応
  • ダークモード/ライトモード対応

最大の落とし穴 - H1タイトルの位置問題

問題の発見

最初は簡単だと思っていた。TableOfContents.astroコンポーネントを作成し、MarkDownPostLayout.astroに組み込むだけ。しかし、実装してみると目次がH1タイトルの上に表示されるという問題が発生した。

<!-- 最初の実装 -->
<div class="post-body">
  <TableOfContents headings={headings} />
  <slot />
</div>

なぜこの問題が起きるのか

Astroの構造を理解する必要がある:

  1. レイアウトファイル(MarkDownPostLayout.astro)

    • ページ全体の構造を定義
    • ヘッダー、フッター、広告などを配置
    • <slot /> でMarkdownコンテンツを挿入
  2. 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. プログレッシブエンハンスメント

最初から完璧を求めるのではなく、段階的に機能を追加していくアプローチが有効である:

  1. 基本的な目次表示
  2. スムーススクロール追加
  3. 現在位置ハイライト
  4. レスポンシブ対応

3. クライアントサイドJavaScriptの活用

SSGの制約を、クライアントサイドのJavaScriptで補完することで、より柔軟な実装が可能になる。ただし、JavaScriptが無効な環境でも基本機能が動作するよう配慮が必要である。

完成したコンポーネント

最終的に完成したTableOfContents.astroは以下の機能を持つ。

  • 自動的な見出し抽出(h2, h3)
  • スムーススクロール
  • 現在位置のハイライト
  • アクセシビリティ考慮

まとめ

Astroで目次機能を実装する際の最大の落とし穴は、H1タイトルがMarkdownファイル内にあることである。この問題は、SSGの仕組みを理解し、適切にクライアントサイドのJavaScriptを組み合わせることで解決できる。

目次機能は実装に多少の工夫が必要であるが、ユーザー体験の向上やSEO効果を考えると、投資する価値は十分にある。特にGoogleアドセンスの審査を考えている方は、ぜひ実装を検討してほしい。

参考リンク