Hugo + Algolia + Instantsearch.jsで静的サイトに全文検索を導入

Hugo + Algolia + Instantsearch.jsで、静的サイトに全文検索を導入するまでの一部始終をまとめてみました。

完成版

メニューバー右側のテキストボックスに検索キーワードを入力して、Enterキーを押します。

検索したキーワードにヒットした記事が表示されます。

最低限の実装です。テーマに合わせた検索結果の表示のみこだわりました。しかし、検索したキーワードをハイライトする、そもそも検索したキーワードを表示するなど、改善の余地があります。

Algoliaとは

https://www.algolia.com/

検索エンジンを自ホストで持つ必要がないため、Hugoなどで構築された静的サイトと相性がとても良いです。もちろん、動的サイトでも同様の実装は可能です。Hugoの公式ドキュメントもAlgoliaによる検索エンジンが採用されています。

無償プランの制限事項は以下の通りです。インデックスとして登録できるレコード件数が10,000件、月間のリクエスト数が10,000件と制限があります。ブログの場合、リクエスト数が無償プランを利用するかどうかの検討材料になりそうです。また、どれくらい全文検索が利用されているかどうかをテストしてみると良いでしょう。

  • Forever free
  • No credit card required
  • 10,000 Records
  • 10,000 Search requests/mo
  • 10,000 Recommend requests/mo

また、無償プランの場合、検索結果の画面にAlgoliaのブランドロゴを表示するように求められていますので、忘れないように表示しましょう。

  • The Free Plan requires you to display the Algolia logo next to the search results.

Algoliaでインデックスを作成する

  1. Algoliaのインデックスを更新するためのJSONファイルを作成する
  2. 作成したJSONファイルを用いてインデックスを登録する
  3. ブログ更新時にAlgoliaのインデックスを更新する

Algoliaへ登録するためのJSONファイルを作成する

Algoliaのインデックスへ登録するために、JSONファイルを作成する必要があります。Hugoによるビルド時に、毎回自動的にJSONファイルが作成されるようにconfig.yaml(今回はYAML形式)の記載を変更します。具体的には以下を追記します。outputs配下の項目は、テーマによっては既に記入されている場合があります。その場合、フォーマットの末尾に「Algolia」追加してください。

outputFormats:
   Algolia:
     baseName: algolia
     isPlainText: true
     mediaType: application/json
     notAlternative: true
 outputs:
   home: [HTML, RSS, Algolia]

続いて、JSONファイルを自動生成するためのテンプレートを作成します。テーマのルートディレクトリ配下で、layouts/list.algolia.jsonを作成してください。

{{/* Generates a valid Algolia search index */}}
{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
     {{- $.Scratch.Add "index" (dict "objectID" .File.UniqueID "title" .Title "tags" .Params.tags "categories" .Params.categories "permalink" .Permalink "summary" .Summary "publish_date" .PublishDate) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

objectIDは、Algoliaでレコードを一意に識別するためのキーです。Hugoの場合、ファイルのパスに応じてハッシュ値を生成する.UniqueIDが便利です。一意に識別するキーがない場合、インデックス登録時にエラーになりますので注意してください。対象は、.Site.RegularPagesのみで、カテゴリーやタグ毎に生成される固有のページは除外しています。

また、本文をすべてインデックスに含む場合、レコード毎のバイト数制限(10KB)を超過する可能性があるため除外し、代替手段として.Summaryを追加しています。

https://www.algolia.com/doc/faq/basics/is-there-a-size-limit-for-my-index-records/

Algoliaへインデックスを登録する

専用のCLIが用意されているため、そちらを利用するのが便利です。npmコマンドでatomic-algoliaをインストールします。プロジェクトのルートディレクトリ配下で、以下のコマンドを実行してください。ここでは、npmコマンドの使い方は割愛します。

npm install -D atomic-algolia

また、package.json内のscriptsに、atomic-algoliaコマンドが実行されるよう登録しておきます。抜粋版のpackage.jsonは、以下の通りです。npm run algoliaでコマンドが実行されるようになります。

{
  "name": "ottanxyz",
  "scripts": {
    "algolia": "atomic-algolia"
  },
  "dependencies": {
    "algoliasearch": "^3.35.1",
    "atomic-algolia": "^0.3.19"
  }
}

続いて、Algoliaのダッシュボードの「API Keys」から、以下の項目を取得します。なお、Admin API Keyは、文字通りAlgoliaに登録したインデックスに関する全ての権限(インデックスの登録から削除まで)を有する重要なアクセスキーです。外部へ流出しないよう十分に注意しましょう。

  • Application ID
  • Search-Only API Key
  • Admin API Key

atomic-algoliaコマンドで使用するために、.envファイルをプロジェクトのルートディレクトリ直下に作成します。また、前述の「Admin API Key」を含みますので、誤ってGitHub等のGitリポジトリへ登録されないよう、.gitignoreへ登録するのを忘れないようにしましょう。

ALGOLIA_APP_ID=取得したApplication ID
ALGOLIA_ADMIN_KEY=取得したAdmin API Key
ALGOLIA_INDEX_NAME=作成するインデックス名
ALGOLIA_INDEX_FILE=public/algolia.json

インデックス名は、他のインデックスと区別できればなんでも良いですが、わかりやすい名前にしておきましょう。実際の検索時に、ここで作成したインデックス名を使用します。また、これまでの設定通りであれば、publicディレクトリ直下へalgolia.jsonという名前のファイルが生成されるようになります。

ここまで準備ができたら、いったんhugoコマンドでビルドし、以下のコマンドを実行し、Algoliaへインデックスを登録してみましょう。エラーが発生する場合、APIキーや、aloglia.jsonのビルドに失敗している可能性がありますので、設定を見直してみてください。

npm run algolia

Algoliaの設定

Algoliaのダッシュボードを参照します。意図したインデックスが構築されているかどうかを確認しましょう。今回は、「Configuration」→「Searchable attributes」で、「title」「summary」を検索対象として設定しました。その他、Algoliaでは検索のアルゴリズムをカスタマイズすることが可能ですが、デフォルトのまま進みました。

Algoliaのインデックス自動更新

ブログ更新の都度、毎回手動でAlgoliaのインデックスを更新するのは面倒です。hugoコマンドによるビルドに加えて、npm run algoliaが実行されるようにしておきましょう。(例:hugo && npm run algolia

Instantsearch.jsによる検索画面の作成

https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/js/

Instantsearch.jsによる、バニラなJavaScriptでのフロント部分を実装します。

ここまでで、検索のバックエンドは完成しました。続いて、フロント部分を作成していきます。まず、Hugoのテーマディレクトリに、content/search.mdを作成します。typelayoutで参照するテンプレートを指定するだけのファイルです。テンプレートとして、layouts/static/search.htmlが参照されます。また、サイトマップに含まれないようにpriorityを極端に低い数値にしていますが、これがベストかどうかはわからないので調査予定です。

—-
title: "検索結果"
sitemap:
  priority : 0.1
type: static
layout: search
—-

続いて、テンプレートとなるlayouts/static/search.htmlを作成します。ここは、テーマのテンプレートにもよりますので、必須部分のみ抜粋して後ほど解説します。

{{ define "main" }}
<section class="hero is-dark">
  <div class="hero-body">
    <div class="container has-text-centered">
      <h1 class="title has-text-weight-bold">
        検索結果<span id="search-term"></span>
      </h1>
    </div>
  </div>
</section>
<section class="section is-hidden">
  <div class="container">
    <div id="searchbox"></div>
  </div>
</section>
<section class="section">
  <div class="container">
    <div id="hits" class="columns is-multiline"></div>
  </div>
</section>
{{ end }}
{{ define "addscripts" }}
<script
  src="https://cdn.jsdelivr.net/npm/algoliasearch@4.5.1/dist/algoliasearch-lite.umd.js"
  integrity="sha256-EXPXz4W6pQgfYY3yTpnDa3OH8/EPn16ciVsPQ/ypsjk="
  crossorigin="anonymous"
></script>
<script
  src="https://cdn.jsdelivr.net/npm/instantsearch.js@4.8.3/dist/instantsearch.production.min.js"
  integrity="sha256-LAGhRRdtVoD6RLo2qDQsU2mp+XVSciKRC8XPOBWmofM="
  crossorigin="anonymous"
></script>
<script>
  let params = new URLSearchParams(document.location.search.substring(1));

  const searchClient = algoliasearch(
    "WHA9JKWNW0",
    "5b2c83156b29a7d67868e8440fc800b7"
  );

  const search = instantsearch({
    indexName: "index",
    searchClient,
  });

  search.addWidgets([
    instantsearch.widgets.searchBox({
      container: "#searchbox",
    }),

    instantsearch.widgets.hits({
      container: "#hits",
      templates: {
        empty: "No results",
        item: ``,
      },
    }),
  ]);

  const renderHits = (renderOptions, isFirstRender) => {
    const { hits, widgetParams } = renderOptions;

    widgetParams.container.innerHTML = `
    ${hits
      .map(
        (item) =>
          `<div class="column is-12-mobile is-6-tablet is-4-desktop">
            <div class="card">
                <div class="card-content">
                    <div class="title is-4"><a class="has-text-black-bis has-text-weight-bold" href="${item.permalink}">${item.title}</a></div>
                    <div class="mb-2">${item.summary}</div>
                    <footer class="card-footer">
                        <div class="card-footer-item">
                        <time datetime=${item.publish_date}>
                            <i class="far fa-calendar-alt"></i>&nbsp;${item.publish_date}
                        </time>
                        </div>
                    </footer>
                </div>
            </div>
        </div>`
      )
      .join("")}
  `;
  };

  const customHits = instantsearch.connectors.connectHits(renderHits);

  search.addWidgets([
    customHits({
      container: document.querySelector("#hits"),
    }),
  ]);

  search.start();
  search.setUiState({
    index: {
      query: params.get("q"),
    },
  });
</script>
{{ end }}

まず、表示用のHTMLを記述します。

<div id="searchbox"></div>
<div id="hits" class="columns is-multiline"></div>

指定するIDが重要です。後ほど、Instantsearch.jsから参照します。IDを変更したい場合は、Instantsearch.js側も変更してください。searchboxが検索フォーム、hitsが検索結果を表示するHTMLエレメントのルート要素となります。

ただし、今回はURLのクエリ文字列に渡された文字列をキーワードとして検索したいので、searchboxをCSSで表示しないよう制御しています。searchboxウィジェットを使用せず、検索する方法が用意されているかもしれません。同ウィジェットへ渡した文字列が検索キーワードとなるため、ウィジェット自体は表示しないようにして残しています。後ほど、このウィジェットに直接クエリ文字列を指定します。

<script
  src="https://cdn.jsdelivr.net/npm/algoliasearch@4.5.1/dist/algoliasearch-lite.umd.js"
  integrity="sha256-EXPXz4W6pQgfYY3yTpnDa3OH8/EPn16ciVsPQ/ypsjk="
  crossorigin="anonymous"
></script>
<script
  src="https://cdn.jsdelivr.net/npm/instantsearch.js@4.8.3/dist/instantsearch.production.min.js"
  integrity="sha256-LAGhRRdtVoD6RLo2qDQsU2mp+XVSciKRC8XPOBWmofM="
  crossorigin="anonymous"
></script>

続いて、algoliasearch.jsinstantsearch.jsを読み込みます。最新版は、本項の冒頭にあるAlgoliaのドキュメントページを参照してください。

let params = new URLSearchParams(document.location.search.substring(1));

URLのクエリ文字列を受け取ります。Hugoで、URLのクエリ文字列を動的に処理することはできないため、JavaScript(ブラウザ)側で実装する必要があります。例えば、URLがhttps://ottan.jp/search/?q=hogeであれば、params.get("q")hogeを取得できます。

  const searchClient = algoliasearch(
    "WHA9JKWNW0", // 事前に取得したApplication ID
    "5b2c83156b29a7d67868e8440fc800b7" // 事前に取得したSearch-Only API Key。誤ってAdmin API Keyを公開しないよう注意
  );

  const search = instantsearch({
    indexName: "index", // 事前に作成したインデックス名
    searchClient,
  });

  search.addWidgets([
    instantsearch.widgets.searchBox({
      container: "#searchbox", // 検索フォームを表示するためのHTMLエレメントを指定
    }),

    instantsearch.widgets.hits({
      container: "#hits", // 検索結果を表示するためのHTMLエレメントを指定
      templates: {
        empty: "No results", // 検索結果に何もヒットしない場合の表示用文字列
        item: ``,
      },
    }),
  ]);

ここまでは、Algoliaのスタートガイドにあるテンプレートを、ほぼ流用しています。Admin API Keyを誤って公開するJavaScript内に記述しないように注意しましょう。無償プランでは、検索用のAPI Keyを隠蔽するための手段がないように見えます。もし、公開したくない場合は、Netlify FunctionsやVercelのServerless Functionsを利用するなど、サーバサイド側で処理するよう変更してみてください。

  const renderHits = (renderOptions, isFirstRender) => {
    const { hits, widgetParams } = renderOptions;

    widgetParams.container.innerHTML = `
    ${hits
      .map(
        (item) =>
          `<div class="column is-12-mobile is-6-tablet is-4-desktop">
                    <div class="title is-4"><a class="has-text-black-bis has-text-weight-bold" href="${item.permalink}">${item.title}</a></div>
        </div>`
      )
      .join("")}
  `;
  };

  const customHits = instantsearch.connectors.connectHits(renderHits);

  search.addWidgets([
    customHits({
      container: document.querySelector("#hits"), // 検索結果を表示するためのHTMLエレメントを指定
    }),
  ]);

検索結果をカスタマイズするためのスクリプトです。renderHits関数を作成して、Instantsearch.jsへインスタンスを渡します。widgetParams.container.innerHTMLで、検索結果に表示されるHTMLを書き換えています。ここでは、一部のみを抜粋しています。map関数により、${item.title}のようにして検索結果(JSON形式)を参照することができます。${…}の形式で参照する必要がありますので注意してください。また、titleは、インデックスへ登録するJSONファイルを作成したときのキーを指定します。パーマリンクや更新日時等を別途取得すると良いでしょう。

ここではテンプレートをカスタマイズしましたが、デフォルトのテンプレートをそのまま使用することもできます。renderHits関数以降の定義が不要になるため、シンプルです。詳細はAlgoliaの公式ドキュメント(冒頭)を参照してください。

  search.start();
  search.setUiState({
    // Algoliaに登録したインデックス名を指定
    index: {
      query: params.get("q"), // URLのクエリ文字列を指定
    },
  });

setUiState関数で、キーワードをAlgoliaへ渡します。

検索フォームの設置

以下の検索フォームをメニューバー等、検索しやすい場所に設置してください。/searchへ移動する、name=“q”にクエリ文字列のキー(q)を指定しています。デザインは自由です。

<form action="/search“>
    ….
    <input class="input" type="search" name="q" placeholder="Search..." />
    ….
</form>

まとめ

まだまだ荒削りな部分はありますが、静的サイトでも全文検索を導入することができました。以前、サーバサイドで処理する必要はありましたが、Fuse.jsを利用した全文検索も紹介していますので、そちらもぜひご覧ください。

comments powered by Disqus