Gatsbyブログに検索機能を導入した(Algolia)

2022/4/24

(最終更新: 2022/4/24

はじめに

Gatsby ブログに検索機能を付けたく、いくつかやりかたを調べてみた。

  • Google カスタム検索を使う
  • Algolia という有名な検索サービス(SaaS)を使う
  • サーバを立てて、Elasticsearch などの有名な検索ソフトウェアを入れる(製品は色々と出ている…奥が深い)

このうち、今回は

Algolia という有名な検索サービス(SaaS)

を使って実装する。 本記事では実装した内容をメモする。

できあがりイメージ

出来上がりイメージはこのような形となる。

  • 調べたい語句を入れる。
  • Enter キーを押す  or 虫眼鏡マークをクリックすると、検索結果が表示される。
  • 結果をクリックすると、該当ページに飛ぶ。

できあがりイメージ

できること

記事のタイトル、descrpition、タグの値を検索に用いて全記事からキーワード検索する。

できないこと

記事の本文を検索対象に入れることはできなかった。(やり方はあるのかもしれない)

モチベーション

このブログは個人的に勉強したことのメモの側面があるため、自分の過去の記憶を頼りに記事を探したい。検索機能が必要となる。

はてなブログの時には良い感じに全文検索機能を提供してくれていた。

はてなブログのときの検索機能

Algolia とは

以下のような特徴がある検索サービスとのこと。

  • 全文検索サービスを SaaS として提供
  • GUI から検索ロジックを柔軟に設定可能
  • レコード数/API アクセス数による従量課金
  • 世界各地にデータセンターがあり、どのロケーションでも高速な検索が可能
  • 様々なプログラミング言語向けに API を提供
  • 様々なフロントエンドフレームワーク向けに便利なライブラリを提供

イケてる全文検索サービス「Algolia」を触ってみよう - Qiita

料金体系は以下のようになっていた。

Algolia Pricing | Pay As You Go | Site Search Pricing | Algolia

料金体系

ひとまず月に 10,000 検索までは無料と思われる。

機能分担のイメージ

このブログは Gatsby Cloud 上でビルド、ホスティングしている。 検索を行うには、検索対象のキーワードや文章を取り込んで、検索用のインデックスデータを作成し、検索時にはそこを参照する。

インデックスデータはAlgolia側のサーバにあるため、ブログの内容に変更が入るたびに、Algolia側のサーバにデータを送信する仕組みが必要となる。

Gatsby.jsには便利なgatsby-plugin-algoliaというプラグインがあり、これを使えばビルドする際に Algolia 側にデータをプッシュしてくれる。

機能分担イメージ(間違っていたらすみません)

機能分担イメージ

実施内容

  • 基本的には、Gatsby 公式サイトの手順通りに実施した。
  • ただし公式サイトのようにキーを打つごとに検索する仕様にするとクエリ発行量が必要以上に増えそうなので、エンターキー or 検索ボタンを押したときにのみ検索する仕様に変更した。
  • 検索結果へのリンクがおかしかったので、修正した。

プラグインインストール

データを Algolia 側に渡すためのプラグインをインストールする。

npm install gatsby-plugin-algolia

gatsby-config.jsplugin配列の中に、以下の通り追加する。

gatsby-config.js
    {
      resolve: `gatsby-plugin-algolia`,
      options: {
        appId: process.env.GATSBY_ALGOLIA_APP_ID,
        apiKey: process.env.ALGOLIA_ADMIN_KEY,
        queries: require("./src/utils/algolia-queries"),
      },
    },

ここで出てくる./src/utils/algolia-queriesは後ほど作成する。

また、gatsby-config.jsの先頭に以下の記載を追加しておく。環境変数を設定した.envファイルを読み込む設定。

gatsby-config.js
require("dotenv").config()

Algolia サービスに登録・API キー取得

Algoliaのサイトでアカウントを作成する。GitHub のアカウントでログインできた。

「Settings」⇒「API Keys」と遷移し、API Key を取得する。

APIKeyの取得

これを.envファイルに設定する。

GATSBY_ALGOLIA_APP_ID=<確認した値>
GATSBY_ALGOLIA_SEARCH_KEY=<確認した値>
ALGOLIA_ADMIN_KEY=<確認した値>

.envファイルが.gitignoreに入っていることを確認する。 .gitignoreに入っていないと、流出してはいけないAdmin API Keyを GitHub に上げてしまうので注意。

.gitignore
# dotenv environment variable files
.env*

データをAlgoliaに送信するためのソースコードの作成

algolia-queries.js

src/utils/algolia-queries.jsをほぼGatsby.jsの公式ページのとおり記載する。

※勉強しきれていないので、変にいじらない。

ただし、GraphQL のクエリ部分は各々変える必要がある。私の場合、マークダウンの変換にはgatsby-plugin-mdxを使っているため、以下のようなクエリとなる。

また、検索に加えたいフィールドはここで取得しておく必要がある。idも取得しておく必要がある。 記事作成時にマークダウン記事の情報を取得するクエリなどを参考にしながら作ればよい。

src/utils/algolia-queries.js
const escapeStringRegexp = require("escape-string-regexp")
const pagePath = `content`
const indexName = `Pages`
const pageQuery = `{  pages: allMdx(    filter: {      fileAbsolutePath: { regex: "/myblog/" } ,    }  ) {    edges {      node {        frontmatter {          slug          title          description          tags        }      id      }    }  }}`function pageToAlgoliaRecord({ node: { id, frontmatter, fields, ...rest } }) {
  return {
    objectID: id,
    ...frontmatter,
    ...fields,
    ...rest,
  }
}
const queries = [
  {
    query: pageQuery,
    transformer: ({ data }) => data.pages.edges.map(pageToAlgoliaRecord),
    indexName,
    settings: { attributesToSnippet: [`excerpt:20`] },
  },
]
module.exports = queries

ここまで作ったら試しにビルドしてみて、Algolia の「Index」ページで値が取得できていることが確認できればOK。

Indexが取得できた

検索用コンポーネントの作成

検索用コンポーネントを作成していく。

プラグインインストール

ここは公式ページ通り。 プラグインのインストールをする。

npm install react-instantsearch-dom algoliasearch styled-components gatsby-plugin-styled-components @styled-icons/fa-solid

gatsby-config.jsplugin配列の中に、以下の通り追加する。

gatsby-config.js
`gatsby-plugin-styled-components`,

SearchBox の作成

src/components/search-algolia/search-box.jsを作成する。

公式ページどおり作成してももちろん問題ないが、キーを打つごとに検索するとクエリ発行量が必要以上に増えそうなので、エンターキー or 検索ボタンを押したときにのみ検索する仕様に変更した。

src/components/search-algolia/search-box.js
import React from "react"
import { connectSearchBox } from "react-instantsearch-dom"
import { Search as SearchIcon } from "@styled-icons/fa-solid"
import { IconButton } from "@chakra-ui/react"

const SearchBox = ({ refine, currentRefinement, className, onFocus }) => {
  const [value, setValue] = React.useState("")

  return (
    <form className={className}>
      <IconButton
        icon={<SearchIcon className="SearchIcon" />}
        onClick={(e) => {          e.preventDefault()          refine(value)        }}      ></IconButton>
      <input
        className="SearchInput"
        type="text"
        placeholder="記事検索..."
        aria-label="Search"
        onFocus={onFocus}
        name="search-input"
        value={value}        onChange={(e) => setValue(e.target.value)}        onKeyDown={(e) => {          if (e.code == "Enter") {            e.preventDefault()            refine(value)          }        }}      />
    </form>
  )
}

export default connectSearchBox(SearchBox)

connectSearchBoxの解説

connectSearchBoxというのが、自分で UI を決めた検索ボックスを作りたいときに利用できる関数で、引数にはSearchBox関数を取る。

引数に設定する関数SearchBoxは、refineという props を持つ。

refineは、「Algolia に投げるクエリを更新する」という役割を持っている。

ここでは、

  • <IconButton>(Chakra ui で提供される、<button>の拡張版のようなもの)に対してonClickイベントとしてrefine(value)を渡す。
  • <input>エリアのonKeyDownイベントにも、refine(value)を渡す。

とすることで、検索の意志があるときのみクエリを発行するようにした。

ちなみに、他の要素も忘れないようメモしておく。

  • 検索と同時に読み込みなおされるのを防ぐためにe.preventDefault()を設定しておく必要がある。
  • const [value, setValue] = React.useState("")onChange={(e) => setValue(e.target.value)}は、一般的に React でフォームを作るときにやるやり方。
  • e.code == "Enter"とすることで Enter キーが押されたときの処理を記載できる。
  • e.codeにどんな値が入るかは、https://developer.mozilla.org/ja/docs/Web/API/KeyboardEvent/code から調べられる。

※この部分は結構苦労した。。

SearchResults(検索結果)の作成

公式ページほぼそのままコピペだが、一点だけ、このブログに合わず不具合となった部分があるため修正をしている。

検索結果をクリックすると該当の記事に飛ぶようにするため、<Link>の先にslugを指定している部分があるが、ここは

<Link to={hit.slug}>

とすると誤りで、

<Link to={`/${hit.slug}`}>

とする必要がある。

このブログではすべてのページの上部に検索ボックスを表示するため、誤りのパターンで記載するとslugが相対パスとなってしまう。 (例えば、https://bunsugi.com/aboutページで検索を行なって、sluggatsby-blog-algoliaの記事に飛ぼうとした場合に、https://bunsugi.com/about/gatsby-blog-algoliaに向かってしまう。)

src/components/search-algolia/search-result.js
import { Link } from "gatsby"
import { default as React } from "react"
import {
  connectStateResults,
  Highlight,
  Hits,
  Index,
  Snippet,
  PoweredBy,
} from "react-instantsearch-dom"
const HitCount = connectStateResults(({ searchResults }) => {
  const hitCount = searchResults && searchResults.nbHits
  return hitCount > 0 ? (
    <div className="HitCount">
      {hitCount} result{hitCount !== 1 ? `s` : ``}
    </div>
  ) : null
})
const PageHit = ({ hit }) => (
  <div>
    <Link to={`/${hit.slug}`}>      <h4>
        <Highlight attribute="title" hit={hit} tagName="mark" />
      </h4>
    </Link>
    <Snippet attribute="excerpt" hit={hit} tagName="mark" />
  </div>
)
const HitsInIndex = ({ index }) => (
  <Index indexName={index.name}>
    <HitCount />
    <Hits className="Hits" hitComponent={PageHit} />
  </Index>
)
const SearchResult = ({ indices, className }) => (
  <div className={className}>
    {indices.map(index => (
      <HitsInIndex index={index} key={index.name} />
    ))}
    <PoweredBy />
  </div>
)
export default SearchResult

大元の部品を作成(index.js)

ページコンポーネントから実際に呼び出すコンポーネントを作成する。

ここは公式ページそのままなので記載を省略する。

ファイル名: src/components/search-algolia/index.js

Adding Search with Algolia | Gatsby

stlye 調整コンポーネントの作成

公式ページを参考に以下 4 つのファイルを作成する。コピペした部分は記載を省略する。

参考資料

Adding Search with Algolia | Gatsby

  • ファイル名:src/components/search/use-click-outside.js

    • 役割:検索ボックスの外をクリックしたらウインドウを閉じる
  • ファイル名:src/components/search/styled-search-root.js

    • 役割:検索ボックスの全体的なスタイリング。
  • ファイル名:src/components/search/styled-search-result.js

    • 役割:検索ボックス(結果部分)のスタイリング。
  • ファイル名:src/components/search/styled-search-box.js

    • 役割:検索ボックス(検索部分)のスタイリング。

styled-search-box.jsについては1行だけ自分の好みで変えている。

元々は虫眼鏡マークを押すとインプットエリアが開く仕様だったが、わかりにくいので常に開いておくようにした。

src/components/search/styled-search-box.js
import styled, { css } from "styled-components"
import SearchBox from "./search-box"

const open = css`
  width: 10em;
  background: ${({ theme }) => theme.background};
  cursor: text;
  margin-left: -1.6em;
  padding-left: 1.6em;
`

const closed = css`
  width: 0;
  background: transparent;
  cursor: pointer;
  margin-left: -1em;
  padding-left: 1em;
`

export default styled(SearchBox)`
  display: flex;
  flex-direction: row-reverse;
  align-items: center;
  margin-bottom: 0;

  .SearchInput {
    outline: none;
    border: ${({ hasFocus }) => (hasFocus ? "auto" : "none")};
    font-size: 1em;
    transition: 100ms;
    border-radius: 2px;

    color: ${({ theme }) => theme.foreground};
    ::placeholder {
      color: ${({ theme }) => theme.faded};
    }
    ${({ hasFocus }) => (hasFocus ? open : open)}  }

  .SearchIcon {
    width: 1em;
    margin: 0.3em;
    color: ${({ theme }) => theme.foreground};
    pointer-events: none;
  }
`

// ${({ hasFocus }) => (hasFocus ? open : closed)}

コンポーネントから呼び出し

<Search>コンポーネントを作成したので、そこから呼び出す。

src/components/organisms/search.js
import * as React from "react"
import SearchAlgolia from "../search-algolia"
import { Box } from "@chakra-ui/react"

const searchIndices = [{ name: `Pages`, title: `Pages` }]
const Search = () => {
  return (
    <Box mx={5}>
      <SearchAlgolia indices={searchIndices} />    </Box>
  )
}

export default Search

本番環境への環境変数設定

.envファイルはGitHubにプッシュしないので、GatsbyCloud側に環境変数を設定する。 「Site Settings」から設定できる。

環境変数の設定

まとめ

  • Gatsby ブログに Algolia による検索を導入した。

残課題

  • 本文まで含めた検索ができていないので、運用上困るようであれば考える。
  • frontmatterkeywordsを作っても良いかもしれない。


個別連絡はこちらへ→Twitterお問い合わせ

プロフィール

プロフィールイメージ

はち子

事業会社のシステム部門で働きはじめて5年目の会社員。システム企画/要件定義/システムアーキテクチャ等。

Twitter→@bun_sugi

過去の記事について

はてなブログに掲載の記事(主にプログラミングメモ)についてはこちらに掲載しております。(本ブログに移行中)

タグ一覧

関連記事

Copyright© 2023, エンジニアを目指す日常ブログ

お問い合わせ|プライバシーポリシー