[Astro #03] Pagefind検索と動的目次(TOC)の実装

はじめに

本日はAstroに「検索機能(Pagefind)」と「動的目次(TOC)」の実装。

ただ機能をポン付けするだけでなく、500記事・3.5万ワードの大規模環境でもブラウザがフリーズしないための「軽量化」と「エラーハンドリング」の備忘録メモ。

前回の記事:

1. MDXコンポーネントの「未定義エラー」を一括解決

検索を実装する前に、立ちはだかったのがMDXのレンダリングエラーだ。 過去記事の文中で <Chat><Image> といったタグを使っていると、Astro が「そのコンポーネントはインポートされていない」と怒り出し、ビルドが停止してしまう。532記事すべてに import 文を書き足すのは現実的ではない。

【解決策】 記事詳細を出力する大元のテンプレート(src/pages/blog/[id].astro)でコンポーネントを一括で読み込み、<Content> レンダリング時に components プロパティとして渡すことで解決した。

---
import { getCollection, render } from 'astro:content';
import Chat from '../../components/Chat.astro';
import { Image } from 'astro:assets';

const { post } = Astro.props;
const { Content } = await render(post);
---

{/* 記事内の全MDXにChatとImageコンポーネントを自動適用 */}
<Content components={{ Chat, Image }} />

これで、過去記事内で使われている独自タグがすべて正常にレンダリングされるようになった。

2. Pagefind によるクライアントサイド爆速検索の実装

Astro の静的生成(SSG)の恩恵を活かしつつ、爆速の検索体験を提供するため Pagefind を導入した。ビルド後に静的な検索インデックス(地図)を生成する仕組みだ。

① インデックス生成の自動化と開発環境の罠

package.json の build スクリプトを以下のように変更し、デプロイ時に自動でインデックスが作られるように設定した。

"scripts": {
  "build": "astro build && pagefind --site dist"
}

ここで一つ罠があった。Pagefind は dist フォルダに生成されるため、ローカルの開発サーバー(npm run dev)では pagefind.js が 404 になってしまう。これを回避するため、ビルド後に生成された dist/pagefind フォルダを、そのまま public/pagefind に手動コピーすることで開発環境でも検索が動くよう調整した。

② 検索の「激重」問題を解消する軽量化ロジック

500記事を超える環境で、文字を入力するたびに検索を走らせて全件をDOMに描画しようとすると、ブラウザが悲鳴を上げてフリーズしてしまう。そこで、検索スクリプトに以下の 2つの最適化(デバウンスと件数制限) を施した。

// 1. デバウンス処理(入力が止まるまで300ms待つ)
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {

  const search = await pagefind.search(query);

  // 2. ヒット数が多くても、上位15件だけを抽出して描画負荷を抑える
  const results = await Promise.all(
    search.results.slice(0, 15).map(r => r.data())
  );

  // 結果を描画する処理...
}, 300);

③ Null ガードによる実行時エラーの根絶

サイドバーに検索窓を置いたことで、「記事一覧」以外のページ(記事詳細など)で検索を実行すると、結果を表示する先(#default-list#search-results)が存在せず TypeError: Cannot read properties of null が発生してしまった。

対象のDOMが存在しないページでは処理を中断する「安全装置」を設けることで、このエラーを完全に封じ込めた。

const defaultList = document.querySelector('#default-list');
const resultsList = document.querySelector('#search-results');

// 検索結果を表示する箱がないページなら何もしない
if (!defaultList || !resultsList) return;

3. Intersection Observer による動的目次(TOC)

長い技術記事でも現在地を見失わないよう、スクロールに追従して発光するサイバーな目次を実装した。

Astro の render() 関数から抽出できる headings データを元に目次のリストを生成し、クライアント側の JavaScript で IntersectionObserver を用いて監視を行う。

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      // 全てのリンクから active を外し、現在の見出しにだけ付与
      document.querySelectorAll('.toc a').forEach(link => link.classList.remove('active'));
      const activeLink = document.querySelector(`.toc a[href="#${entry.target.id}"]`);
      if (activeLink) activeLink.classList.add('active');
    }
  });
}, { rootMargin: '-100px 0px -70% 0px' }); // 画面上部でのみ判定

CSS側で .active クラスに対して PROTOCOL.LAIN のテーマカラーであるマゼンタ(#ff0055)のテキストシャドウと、::before 擬似要素による > ポインタを付与することで、システムライクなUIが完成した。