Harada's Diary

Astro SSGブログにサイト内検索を実装した話

検索検索ゥ!

前回の記事で検索機能はつけない!(血泣)とか言ってましたが、いれました。
元々ヘッダーにはAboutリンクがありましたが、まぁいらんページなので、検索アイコンに変更しました。

採用した構成

PageFindという検索ライブラリを使いました。
「Astro SSG 検索ライブラリ」で調べたら結構ヒットしたのですが、なかなか品質がよいみたいだったので採用しました。

Pagefindは大規模なサイトでも高速に動作し、帯域幅も最小限にすると謳っていて、HTMLファイルを読み込んでインデックスを作成、検索UIもデフォルトで提供される検索用ライブラリです。
通常のフレームワークのビルド成果物を対象にインデックスを生成するので、とても簡単に導入することができます。

検索エンジンとAstro連携を上記のライブラリを使いましたが、検索UIだけ新規で作成しました。

導入

1. パッケージを導入

npm install astro-pagefind

2. Astroの設定にintegrationを追加

astro.config.mjspagefind() を追加します。

import { defineConfig } from "astro/config";
import tailwind from "@astrojs/tailwind";
import pagefind from "astro-pagefind";

export default defineConfig({
  site: "https://example.com/",
  integrations: [tailwind(), pagefind()],
});

site は自分の公開URLに置き換えます。

3. ヘッダーを検索アイコン導線に変更

Aboutリンクを外し、検索アイコンから /search?q= へ遷移させます。
最初は検索バーおいてましたが、アイコンに変更してすっきりさせました。

---
const { title } = Astro.props;
---

<header class="bg-background border-b">
  <div class="container mx-auto px-4 md:px-6 py-4 flex items-center justify-between gap-4">
    <a href="/" class="flex items-center gap-2">
      <span class="text-lg font-semibold">{title}</span>
    </a>

    <a
      href="/search?q="
      aria-label="検索ページへ"
      class="inline-flex h-10 w-10 items-center justify-center rounded-md border border-gray-300 text-gray-700 transition hover:bg-gray-100"
    >
      <svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        class="h-5 w-5"
        aria-hidden="true"
      >
        <circle cx="11" cy="11" r="8"></circle>
        <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
      </svg>
    </a>
  </div>
</header>

4. 検索ページを作成

src/pages/search.astro を作ります。
/search?q=foo で初期検索が可能で、SSG環境で動作します。

---
import Layout from "@layouts/Layout.astro";

export const prerender = true;

const Meta = {
  title: "検索",
  description: "ブログ記事の検索ページ",
};
---

<Layout Meta={Meta}>
  <section class="space-y-6 md:col-span-2">
    <h1 class="text-3xl font-semibold">サイト内検索</h1>

    <form action="/search" method="get" class="max-w-2xl">
      <label for="search-input" class="sr-only">検索ワード</label>
      <div class="flex gap-2">
        <input
          id="search-input"
          name="q"
          type="search"
          placeholder="タイトル・本文で検索"
          class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-gray-500 focus:outline-none"
        />
        <button
          type="submit"
          class="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
        >
          検索
        </button>
      </div>
    </form>

    <p id="search-summary" class="text-sm text-gray-600">検索ワードを入力してください。</p>
    <ul id="search-results" class="space-y-3"></ul>
  </section>

  <script>
    const input = document.getElementById("search-input");
    const summary = document.getElementById("search-summary");
    const results = document.getElementById("search-results");
    const query = new URLSearchParams(window.location.search).get("q")?.trim() || "";

    if (!(input instanceof HTMLInputElement) || !summary || !results) {
      throw new Error("Search UI initialization failed.");
    }

    input.value = query;

    const clearResults = () => {
      results.replaceChildren();
    };

    const renderSummary = (text) => {
      summary.textContent = text;
    };

    const renderItem = (item) => {
      const li = document.createElement("li");
      li.className = "rounded-md border border-gray-200 p-4 hover:bg-gray-50 transition";

      const link = document.createElement("a");
      link.href = item.url;
      link.className = "text-lg font-medium hover:underline";
      link.textContent = item.meta?.title || item.url;
      li.appendChild(link);

      if (item.excerpt) {
        const excerpt = document.createElement("p");
        excerpt.className = "mt-2 text-sm text-gray-700";
        excerpt.innerHTML = item.excerpt;
        li.appendChild(excerpt);
      }

      results.appendChild(li);
    };

    const searchInBlog = async (term) => {
      if (!term) {
        clearResults();
        renderSummary("検索ワードを入力してください。");
        return;
      }

      renderSummary(`「${term}」を検索中...`);
      clearResults();

      const pagefindPath = `${import.meta.env.BASE_URL}pagefind/pagefind.js`;
      const pagefind = await import(/* @vite-ignore */ pagefindPath);
      await pagefind.options({ excerptLength: 25 });

      const searched = await pagefind.search(term);
      const allData = await Promise.all(searched.results.map((r) => r.data()));

      // /blog 配下だけを表示
      const blogOnly = allData.filter((item) => item.url.startsWith("/blog/"));

      if (blogOnly.length === 0) {
        renderSummary(`「${term}」に一致するブログ記事はありません。`);
        return;
      }

      renderSummary(`「${term}」の検索結果: ${blogOnly.length}件(/blog配下のみ)`);
      blogOnly.forEach(renderItem);
    };

    if (query) {
      searchInBlog(query);
    }
  </script>
</Layout>

5. インデックス対象も絞る

ここまでで実装完了ではあるんですが、デフォルトだとすべてのページが検索対象になるので、Categoryやページネーションまで検索結果にでてしまいます。
今回は /blog配下の個別ページのみ検索結果に表示させたかったので、インデックスの対象も絞り込みしました。

表示時のフィルタだけでなく、インデックス時にも除外したい場合は data-pagefind-ignore を使います。

---
const isBlogDetail = Astro.url.pathname.startsWith("/blog/");
---

<body data-pagefind-ignore={isBlogDetail ? undefined : true}>

6. ビルド確認

astro buildをすると、Pagefindのインデックス生成のステップが増えますので、ビルド時間もその分長くなります。
私のブログはそこまで大量の記事を抱えてないので大丈夫ですが、膨大なコンテンツがあるサイトの場合はどうなるかわからぬ・・・

npm run build

ハマりどころ

1) Viteの動的import解決エラー

import("/pagefind/pagefind.js") をそのまま書くと、ビルド時に解決エラーになることがあります。

対策:

  • import.meta.env.BASE_URL を使ってランタイムパス化
  • /* @vite-ignore */ を付与

完成

めっちゃ簡単で高速に検索エンジン導入できました💩
ざっとみた感じ日本語が弱そうですが、英単語に関しては文句のない精度です。

懸念

Pagefindはビルド時に静的HTMLをクロールしてインデックスを生成する仕組みなので、ISRなどのインクリメンタルな方式には対応してなさそうです。
もしISRに対応させるのであれば、Algolia / MeiliSearch などの外部検索サービスを使うか、Fuse.js などのクライアントサイド全文検索を使う必要がありそう??

まーいまの私のブログには関係ないがな。ガハハ。