Astro + Svelte v5サイトで、静的サイト向け全文検索ライブラリ Pagefindを使ってみる

/

はじめに

今までサイト内検索では Algolia を使用していたのですが、検索 API の実行アクセス回数が少ないために無効にされてしまったので、サイト内検索機能を全文検索ライブラリの Pagefind で置換しました。

Pagefind とは

Pagefind は、ユーザーの帯域幅をできるだけ使用せず、インフラストラクチャをホストすることなく、大規模なサイトで優れたパフォーマンスを発揮することを目的とした完全に静的な検索ライブラリです。

Pagefind is a fully static search library that aims to perform well on large sites, while using as little of your users’ bandwidth as possible, and without hosting any infrastructure.

Pagefind の導入はとても簡単で、サイトのビルド時にインデックスファイルを生成し、そのファイルを利用するだけで検索機能を提供できます。

Algolia も有名で優れた検索サービスですが、(個人サイト程度では滅多に支払いは発生しないものの)従量課金制の有料サービスです。

実装

  • “astro”: “^4.15.5”
  • “svelte”: “^5.0.0-next.247”,

Astro ファイルに Pagefind を追加する場合は、Community Educational Content | Astro Docs で参照されている、Add Search To Your Astro Static Site というページに倣うのが良いと思います。

諸々の都合により Svelte で作成した component 中で pagefind を使用することにしました。

install Pagefind and add npm scripts

undefined
npm install -D pagefind

Pagefind はサイトビルド時に生成した静的コンテンツを利用してインデックスを作成します。通常の Astro のビルドコマンドを実行した後に、ビルド生成物のあるディレクトリを利用してインデックス作成する様に、npm scripts を記述します。

package.json
{
"scripts": {
"build": "astro build",
"postbuild": "npx pagefind --site dist",
"preview": "astro preview",
},
}

postbuild が完了すると、/dist/pagefind/ の様になります。

custom indexing

Pagefind はデフォルトで <body> 内にあるすべての要素をインデックス化に使用します。それではナビゲーション要素や noindex にしてあるページもインデックス化されてしまうので、適宜カスタマイズする必要があります。

ただし、<nav><header>, <footer>, <script>, <form> といった要素はデフォルトでインデックス化に使用されません。

index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta data-pagefind-default-meta="image[content]" content="/social.png" property="og:image">
<title>Document</title>
</head>
<body>
<header></header>
<main data-pagefind-body>
<article>
<h1>title</h1>
<time data-pagefind-meta="date:2022-06-01">update: 2022-06-01</time>
<div class="toc" data-pagefind-ignore>
<ul>
<li>h2</li>
</ul>
</div>
<div>contents</div>
</article>
</main>
<footer></footer>
</body>
</html>

add data-pagefind-body

data-pagefind-body 属性を付加した要素の中にある要素がインデックスされるようになります。この属性が付加された場合は、付加されていない全てページが使用されない様になります。

add data-pagefind-ignore

data-pagefind-body 属性を付加した要素の中に、インデックス化に使用しないものがある場合は data-pagefind-ignore を付加する必要があります。

add data-pagefind-meta

Pagefind は各ページ中の各要素からメタデータを自動的に取得します。

  • title: h1
  • image: h1以降、最初の画像の src
  • image_alt: h1以降、最初の画像の alt
example.html
<h1 data-pagefind-meta="title">Hello World</h1>
<img data-pagefind-meta="image[src], image_alt[alt]" src="/hero.png" alt="Hero Alt Text" />
<time data-pagefind-meta="date:2022-06-01">update: 2022-06-01</time>
results[0].data()
{
"meta": {
"title": "Hello world",
"image": "/hero.png",
"image_alt": "Hero Alt Text",
"date": "2022-06-01"
}
}

create Pagefind Search component

Pagefind には型定義が用意されていません。自分で型定義を自分で用意するか、@ts-ignore 等で握りつぶす必要があります。

code: types/pagefind.ts
/types/pagefind.ts
export type Pagefind = {
init: () => void;
search: (query: string, options: Partial<Option>) => Promise<SearchResponse>;
debouncedSearch: (
query: string,
options: Partial<Option>,
miliseconds: number,
) => Promise<SearchResponse>;
};
export type SearchResponse = {
filters: Record<string, any>;
results: Result[];
timings: Record<"preload" | "search" | "total", number>[];
totalFilters: Record<string, any>;
unfilteredResultCount: number;
};
export type Result = {
id: string;
data: () => Promise<DataReturn>;
score: number;
words: Array<any>;
};
export type DataReturn = {
url: string;
content: string;
word_count: number;
filters: Record<string, any>;
meta: {
title: string;
image: string;
date: string;
};
anchors: Array<any>;
weight_locations: WeightLocation[];
locations: number[];
raw_content: string;
raw_url: string;
excerpt: string;
sub_results: SubResult[];
};
export type SubResult = {
title: string;
url: string;
weight_locations: WeightLocation[];
locations: number[];
excerpt: string;
};

Svelte v5 で作成しました。

/components/Search.svelte
<script lang="ts">
import { onMount } from "svelte";
import type { Pagefind, SearchResponse } from "@/types/pagefind";
let pagefind: Pagefind | null = $state(null);
let query = $state("");
async function search(query: string, options: Partial<Record<string, any>>, miliseconds: number) {
if (!pagefind || !query) return [];
const search: SearchResponse = await pagefind.debouncedSearch(query, options, miliseconds);
if (!search) return [];
const result = await Promise.all(search.results.map((result) => result.data()));
return result;
}
const prefix = import.meta.env.DEV ? "/dist" : "";
const url = `${prefix}/pagefind/pagefind.js`;
onMount(async () => {
const _pagefind: Pagefind = await import(/* @vite-ignore */ url);
if (!_pagefind) return;
pagefind = _pagefind;
pagefind.init();
});
const results = $derived(search(query, {}, 300));
</script>
<div class="search">
<input class="search-input" type="search" name="q" placeholder="検索" bind:value={query} />
<div class="search-results">
{#if !!pagefind && !!query}
{#await results then results}
{#each results as result}
{@const {meta, raw_url, sub_results} = result}
{@const {title, date} = meta}
<a href={raw_url}>
<h2>{title}</h2>
<time class="date" datetime={date}>{date.split("T")[0]}</time>
{#if sub_results.length}
<p>has sub_results</p>
{/if}
</a>
{/each}
{:catch err}
<pre>{JSON.stringify(err, null, 2)}</pre>
{/await}
{/if}
</div>
</div>
/pages/demo.astro
---
import Layout from "@/layouts/Layout.astro";
import Search from "@/components/Search.svelte";
---
<!-- Pagefindによるインデックス化を無効にする -->
<Layout title="Home" pagefind={false}>
<Search client:only="svelte" />
</Layout>

Image from Gyazo

おわりに

Algolia から Pagefind への移行は非常に簡単でした。Highlight や Filtering、Sorting といった機能は利用していないので、あとで検索機能を充実させるつもりです。