Thinking in the Grey

Astro製ブログにPagefindで検索機能を追加してみた

はじめに

AstroとmicroCMSで作成したこのブログに、Pagefindを使って記事検索を追加しました。
ヘッダーの検索ボタンを押すとモーダルが開き、記事のタイトルと本文から検索できます。
今回は、Astro向けのインテグレーションであるastro-pagefindと、Pagefind公式のComponent UIを組み合わせています。
この記事では、導入から検索対象の指定、モーダルの配置、ダークモードやスタイルの調整までを解説します。

技術スタック

  • フレームワーク:Astro 7.1.4
  • Astroとの連携:astro-pagefind 2.0.1
  • 検索エンジン:Pagefind 1.6.0
  • 検索UI:@pagefind/component-ui 1.6.0

Pagefind本体とComponent UIは、astro-pagefindの依存パッケージとしてインストールされています。

追加した機能

  • 記事タイトル・本文・コードブロックの検索
  • ヘッダーから開く検索モーダル
  • Ctrl+K / Cmd+Kによる起動
  • サイトのテーマ切替に連動するダークモード

構成

Pagefindで検索する仕組み

Pagefindは、生成済みのHTMLを読み取り、検索用の索引とブラウザで使うファイルを出力します。
索引は、どの記事にどのような語が含まれるかを検索するためのデータです。
基本的な流れはPagefindのGetting Startedに説明されています。

このブログでは、ビルド時に次の順番で処理します。

microCMSから記事を取得
  ↓
Astroが記事のHTMLを生成
  ↓
astro-pagefindが検索用の索引を生成
  ↓
HTMLと検索用ファイルをdist/に出力

公開後の検索処理はブラウザで行います。
検索のたびにmicroCMSへ問い合わせる処理や、検索専用のサーバーは追加していません。
記事の本文はHTMLとして出力済みなので、検索のためにCMSのデータを別の形式へ変換し直す必要もありません。

Astroのファイル構成

今回の検索機能に関わるファイルは、次のとおりです。

/
├── astro.config.mjs             # Pagefindの登録とビルド後の検証
├── package.json                 # 依存パッケージと実行コマンド
├── scripts/
│   └── check-search-index.mjs   # 検索用ファイルと記事数の検証
├── tests/
│   └── search-index.test.mjs    # 索引の対象範囲などの自動テスト
└── src/
    ├── components/
    │   ├── BaseHead.astro       # サイトとPagefindのテーマを同期
    │   ├── Header.astro         # 検索ボタンとテーマトグルを配置
    │   ├── SearchModal.astro    # 検索設定・起動ボタン・モーダル
    │   └── ThemeToggle.astro    # 既存のテーマ切替ボタン
    ├── layouts/
    │   └── BlogPost.astro       # 検索するページと本文範囲を指定
    ├── pages/
    │   ├── about.astro          # 記事と同じレイアウトを使用
    │   └── blog/[...slug].astro # 記事を検索対象として指定
    └── styles/
        ├── global.css           # 共通の配色変数
        └── search.css           # Pagefindの上書きスタイル

検索の設定はastro.config.mjs、画面の構成はSearchModal.astro、見た目の調整はsearch.cssに分けています。
ボタンの大きさやフォーカス色を変えるときに、検索処理まで編集せずに済む構成です。

実装

astro-pagefindを導入

まず、astro-pagefindをインストールします。

npm install --save-exact astro-pagefind@2.0.1

続いて、Astroのintegrationsへ登録します。
以下は、設定ファイルのうち検索に関わる部分を抜粋したものです。

import pagefind from 'astro-pagefind';

// defineConfig()内
integrations: [
  mdx(),
  sitemap(),
  pagefind({
    indexConfig: {
      rootSelector: 'html[data-pagefind-index]',
    },
  }),
  // この後に、後述する検索用ファイルの検証を登録
],

astro-pagefindがAstroのビルドに合わせて索引を生成するので、ビルドコマンドは既存のastro buildのままです。
別途、Pagefind CLIを後から実行するコマンドは追加していません。

{
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "test": "node --test tests/search-index.test.mjs",
    "astro": "astro"
  }
}

インテグレーションの導入方法はastro-pagefindのREADMEで確認できます。

検索対象のページを指定

このブログでは、記事詳細だけを検索対象にしています。
記事一覧ページとAboutページは対象外です。
BlogPostにsearchableというPropsを追加しました。
省略したときの値はfalseです。

type Props = {
  // タイトルや公開日など、既存のPropsは省略
  searchable?: boolean;
};

const {
  // 既存の値の取り出しは省略
  searchable = false,
} = Astro.props;

記事詳細のページからだけ、searchableを渡します。
Astroでは値を省略した真偽値の属性はtrueになるため、searchable={true}と同じ指定です。

<BlogPost
  searchable
  title={post.data.title}
  metaTitle={`${post.data.title} | ${SITE_TITLE}`}
  description={description}
  pubDate={pubDate}
  tags={post.data.tags}
  headings={headings}
>
  <div set:html={html} />
</BlogPost>

レイアウト側では、searchableがtrueの場合だけhtmlにdata-pagefind-indexを付けます。

<html lang="ja" data-pagefind-index={searchable ? &apos;&apos; : undefined}>
  <!-- headとbodyの内容は省略 -->
</html>

空文字列を渡すと属性が出力され、undefinedを渡した場合は出力されません。
Aboutではsearchableを渡していないため、この属性は付きません。

data-pagefind-indexは、今回の実装で独自に付けた目印です。
Pagefindの組み込み属性ではなく、先ほどのrootSelectorから参照しています。
rootSelectorは、Pagefindが処理するHTMLのルートを選ぶ設定です。

ルートを記事のhtml要素にすることで、lang="ja"やタイトルのメタデータも同じ範囲に残せます。
設定できる項目はPagefindのNode APIに掲載されています。

ページ内の検索範囲を指定

検索するページが決まったら、次はページのどの部分を検索するかを指定します。

記事レイアウトには、本文だけでなく目次や追従メニューがあります。
追従メニューにはタイトルと目次がもう一度入っているため、article全体を検索すると同じ語が重複します。
mainには関連記事もあるため、こちらも丸ごと対象にはしません。

今回は、メインのタイトルと本文の2か所へdata-pagefind-bodyを付けました。

<h1
  class="p-6 sm:p-10 lg:p-12 font-display text-3xl font-bold leading-tight text-ink md:text-4xl"
  data-pagefind-body={searchable ? &apos;&apos; : undefined}
  data-pagefind-meta={searchable ? &apos;title&apos; : undefined}
>
  {title}
</h1>

<!-- 日付・タグ・目次の表示は省略 -->

<div
  class="markdown-body p-6 sm:p-10 lg:p-12"
  data-pagefind-body={searchable ? &apos;&apos; : undefined}
>
  <slot />
</div>

同じページに複数のdata-pagefind-bodyがある場合、それらは一つのページの本文として扱われます。
そのため、タイトルと本文を検索しながら、間にある日付・タグ・目次は対象から外せます。
本文中の見出しやコードブロックのテキストは、そのまま対象に含まれます。
詳しくはPagefindの索引範囲の指定で確認できます。

data-pagefind-meta="title"は、検索結果に使うタイトルを明示する指定です。
このレイアウトにはタイトルを複製したh1もあるため、メインのh1から取得することを明確にしています。

検索モーダルを作成

検索画面は、SearchModal.astroというコンポーネントにまとめました。

---
import PagefindConfig from &apos;astro-pagefind/components/PagefindConfig.astro&apos;;
import &apos;../styles/search.css&apos;;
---

<div
  class="site-control search-control"
  data-search-control
  data-pagefind-ignore="all"
>
  <PagefindConfig instance="site-search" />
  <pagefind-modal-trigger
    instance="site-search"
    placeholder="記事を検索"
    compact
    hide-shortcut
  ></pagefind-modal-trigger>
  <pagefind-modal instance="site-search"></pagefind-modal>
  <noscript>
    <span class="sr-only">記事検索にはJavaScriptが必要です。</span>
  </noscript>
</div>

PagefindConfigは、astro-pagefindが提供する設定用のコンポーネントです。
検索用ファイルのパスを設定し、公式Component UIのCSSとJavaScriptも読み込みます。
今回はこのコンポーネントを使うため、生成されるJavaScriptへのscriptタグを別に書く必要はありません。

astro-pagefindにはSearch.astroというコンポーネントもありますが、新規導入では公式Component UIを直接使う構成が推奨されています。
astro-pagefindのUIに関する説明

pagefind-modal-triggerは、検索モーダルを開くボタンです。
compactで文字を出さずアイコンだけにし、hide-shortcutでショートカットの表示を隠しています。
ショートカット機能自体は有効で、Ctrl+KまたはCmd+Kから開けます。
placeholderの「記事を検索」は、ボタンのアクセシブルな名前にも使われます。起動ボタンの仕様

設定・ボタン・モーダルのinstanceは、すべてsite-searchに揃えています。
同じinstanceを指定した部品を、一つの検索UIとして接続するためです。

pagefind-modalは、中身を空にしておくと検索入力・結果件数・結果一覧などを自動生成します。
開閉、入力欄へのフォーカス移動、Escapeで閉じる処理などは公式の実装に任せています。
内部の構成を変えたい場合は、モーダルのドキュメントを参考に、ヘッダー・本文・フッターを明示する形へ変更できます。

モバイルでの表示

今回導入したComponent UI 1.6.0の標準CSSでは、画面幅が640px以下になるとモーダルが全画面表示になります。

モバイルではヘッダーに閉じるボタンが表示されます。
これはpagefind-modal-headerの標準動作です。
周囲に余白を残す表示に変える場合は、標準CSSのモバイル向けの幅・高さ・余白も上書きする必要があります。
現在の実装では、全画面表示の設定を維持しています。

ダークモードを連動

このブログのテーマは、BaseHead.astroのapply()で適用しています。
既存のdata-themeに加え、Pagefind用のdata-pf-themeも同じタイミングで更新します。

document.documentElement.dataset.theme = scheme;
document.documentElement.dataset.pfTheme = scheme;
document.documentElement.style.colorScheme = scheme;

dataset.pfThemeに設定した値は、HTMLではdata-pf-theme属性になります。
schemeがdarkなら、Pagefindの暗色用の配色も有効になります。

Component UIはOSの配色設定へ自動追従する仕組みではないため、サイト側のテーマに合わせて属性を更新しています。
これにより、OS設定だけでなく、テーマトグルで手動選択した配色にも揃えられます。
Pagefindのダークモード

見た目をサイトと揃える

見た目の上書きはsearch.cssにまとめています。

.search-control {
  align-items: center;
  --pf-font: var(--font-sans);
  --pf-input-height: 40px;
}

読み込みに失敗したときの表示

検索用ファイルを取得できない場合は、モーダル内のメッセージを日本語の案内へ置き換えています。

document.querySelectorAll(&apos;[data-search-control]&apos;).forEach((control) => {
  control.addEventListener(&apos;pagefind-error&apos;, () => {
    control.querySelectorAll(&apos;.pf-error, .pf-summary&apos;).forEach((message) => {
      message.textContent =
        &apos;検索データを読み込めませんでした。ページを再読み込みして、もう一度お試しください。&apos;;
    });
  });
});

Pagefindの生成するエラー表示と検索状況の表示を対象にしています。
検索の処理自体は変更せず、利用者向けの案内を補う処理です。

JavaScriptが使えない場合は、次のCSSで未定義の検索部品を非表示にします。

.search-control :is(pagefind-modal-trigger, pagefind-modal):not(:defined) {
  display: none;
}

検索ボタンはJavaScriptで生成されるため、動作しないボタンをHTMLに残す構成にはしていません。 SearchModal.astroのnoscript内には、スクリーンリーダー向けの案内も置いています。

検索用ファイルをビルド後に検証

HTMLのビルドが成功していても、検索用ファイルが不足していれば検索は動きません。
astro-pagefind 2.0.1の実装には、索引生成のエラーをログへ出力して処理を終了する箇所があります。
そこで、Pagefindの処理後に生成物を検証するフックを追加しました。

import { validateSearchIndex } from &apos;./scripts/check-search-index.mjs&apos;;

// integrations配列のpagefind()より後に登録
{
  name: &apos;validate-search-index&apos;,
  hooks: {
    &apos;astro:build:done&apos;: async ({ dir, logger }) => {
      const count = await validateSearchIndex(dir);
      logger.info(`Verified search index for ${count} Japanese articles`);
    },
  },
},

検証処理はscripts/check-search-index.mjsに分けています。
出力されたHTMLからdata-pagefind-indexを持つページを数え、索引に記録された日本語の記事数と比較します。

const language = entry.languages?.ja;

if (
  Object.keys(entry.languages ?? {}).length !== 1 ||
  language?.page_count !== expectedCount
) {
  throw new Error(
    `Search index must contain exactly ${expectedCount} Japanese articles.`,
  );
}

さらに、pagefind.js、言語ごとのメタデータ、WASM、Worker用JavaScriptが存在し、空のファイルでないことを確認します。
問題があれば例外を投げてビルドを失敗させます。

このブログでは記事が0件の場合も失敗させています。
これはPagefindの必須条件ではなく、公開するブログに検索対象の記事があることを確認するための、この実装の方針です。

検証で参照するファイル名やpagefind-entry.jsonの構造は、今回のPagefind 1.6.0の出力に基づいています。 Pagefindを更新するときは、生成物の形式が変わっていないかも確認します。

デプロイ

HTMLと検索用ファイルをまとめて公開

Cloudflare Pagesへ公開するときは、Astroが生成したHTMLとdist/pagefind/を同じビルドの成果物として配信します。
astro-pagefindを導入したため、Astroのビルドの中で索引の生成と検証まで実行されます。

おわりに

astro-pagefindと公式Component UIを使い、既存のブログへ検索モーダルを追加しました。

この構成で先に決めたのは、どのページの、どの部分を検索するかです。 記事とAboutでレイアウトを共用している点や、目次・関連記事が同じページ内にある点を考慮して、ページの選択と本文の範囲を分けて指定しました。

検索の仕組みはPagefindに任せつつ、テーマやボタンの見た目は既存のサイトへ合わせています。 今後スタイルを変える場合も、search.cssを中心に調整できる構成です。

Astro製ブログにPagefindで検索機能を追加してみた

Related Articles