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 ? '' : 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 ? '' : undefined}
data-pagefind-meta={searchable ? 'title' : undefined}
>
{title}
</h1>
<!-- 日付・タグ・目次の表示は省略 -->
<div
class="markdown-body p-6 sm:p-10 lg:p-12"
data-pagefind-body={searchable ? '' : undefined}
>
<slot />
</div>
同じページに複数のdata-pagefind-bodyがある場合、それらは一つのページの本文として扱われます。
そのため、タイトルと本文を検索しながら、間にある日付・タグ・目次は対象から外せます。
本文中の見出しやコードブロックのテキストは、そのまま対象に含まれます。
詳しくはPagefindの索引範囲の指定で確認できます。
data-pagefind-meta="title"は、検索結果に使うタイトルを明示する指定です。
このレイアウトにはタイトルを複製したh1もあるため、メインのh1から取得することを明確にしています。
検索モーダルを作成
検索画面は、SearchModal.astroというコンポーネントにまとめました。
---
import PagefindConfig from 'astro-pagefind/components/PagefindConfig.astro';
import '../styles/search.css';
---
<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('[data-search-control]').forEach((control) => {
control.addEventListener('pagefind-error', () => {
control.querySelectorAll('.pf-error, .pf-summary').forEach((message) => {
message.textContent =
'検索データを読み込めませんでした。ページを再読み込みして、もう一度お試しください。';
});
});
});
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 './scripts/check-search-index.mjs';
// integrations配列のpagefind()より後に登録
{
name: 'validate-search-index',
hooks: {
'astro:build:done': 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を中心に調整できる構成です。