HUGOに全文検索機能を実装する方法

今回はHUGOで作ったサイトに、全文検索機能を実装する方法を紹介します。

本サイトは自分用のメモも兼ねているので、どこでも素早く検索できるようにしたかったのです。

HUGOは静的サイトなのでデータベースを使った検索は無理なのですが、優秀な諸先輩たちが検索機能を実装する方法を紹介していたので、自分も導入しました。

基本的にはコピペで実装していますが、自分的にはハマったところや変えたところもメモしているので、導入する際の参考にしていただけると幸いです。

HUGOに全文検索を導入する方法

HUGOに全文検索を導入する方法は、何パターンかあります。

今回採用したのは、JavaScriptで検索用インデックスを作って、そのインデックを参照するという方式です。

4つのファイルを作れば完成します。

  1. 検索用インデックス作成するjs
  2. 検索用インデックスを保管用のフロントマター
  3. 検索結果ページのhtml
  4. 検索結果ページのフロントマター

4つのファイルのうち2つはフロントマターを書いただけのMarkdownファイルなので、実質2つのファイル追加するだけでほぼ実装できます。

実際にHUGOに全文検索を導入していく

では、実際にHUGOに全文検索を導入していきます。

  • コピペでの実装
  • 自分の環境用に機能の調整
  • レイアウトの修正

この順番で進めていきます。

コピペでの実装

まず、コピペで機能を実装していきましょう。

  1. 検索用インデックス作成するjs
  2. 検索用インデックスを保管用のフロントマター
  3. 検索結果ページのhtml
  4. 検索結果ページのフロントマター

上記の順番で進めていきます。

検索用インデックス作成するhtml

検索用インデックスを作成しましょう。
今回作成する全文検索は、ここのインデックスを参照するので最初に作成します。

layoutsフォルダ直下に「js」フォルダを作成し、その中にsingle.htmlというファイルを設置します。

layouts/js/single.html
{{ define "escape" }}
  {{- trim (replace . "\n" " ") " " | replaceRE " +" " " | jsonify -}}
{{ end }}

var data = [
{{- range $index, $page := where .Site.Pages "Params.type" "post" }}
  {
    url: {{ $page.Permalink | jsonify }},
    title: {{ $page.Title | jsonify }},
    date: {{ $page.Date | jsonify }},
    body: {{ template "escape" (printf "%s %s" $page.Title $page.Plain) }}
  },
{{- end }}
];

htmlファイルとは名ばかりで、hugoの関数書くページです。
ここでは、サイト内の指定したページから、url,タイトル,日付,記事データを取得しています。

検索用インデックス保管用のフロントマター

contentフォルダ内の任意の場所に、ダミーのファイルを作成します。
ファイル名は任意のファイル名.mdで大丈夫です。
自分はsearch-index.mdとしました。

search-index.md
+++
type = "js"
url = "index.js"
+++

URL指定で.jsとすることで、擬似的に.jsファイルが作成されます。

ここまで正しくできていれば、インデックスが表示されるので確認しましょう。

自分のドメイン/index.jsにアクセスしてみましょう。
ビルド不要でローカル環境でも動作します。

ここで表示が上手くいかなければ、layouts/js/single.htmlの修正をしましょう。

データの取得範囲を選択する{{- range $index, $page := where .Site.Pages "Params.type" "post" }}の記述を自分のサイト設計に沿った形で調整すれば動くはずです。

検索結果ページのhtml

ここからサイトのUI部分です。

検索ページ用のテンプレート(single.html)を作成して、layouts/search/に配置しましょう。

layouts/search/single.html
{{ define "main" }}

<div class="entry-content">
    <p><h1 id="ブログ インクリメンタルサーチ">ブログ インクリメンタルサーチ</h1>
      <div class="ulist">
        <ul>
          <li>複数キーワードで検索したい場合は右のGoogle検索をお使い下さい</li>
          <li>キーワードにメタ文字を含む場合エスケープが必要です</li>
        </ul>
      </div>
  
      <script src="/index.js"></script>
  
      <div id="searchbox">
        <input onkeyup="search(this.value)" size="15" autocomplete="off" autofocus placeholder="検索ワードを入力" />
        <span id="inputWord"></span> <span id="resultCount"></span>
        <div id="result"></div>
      </div>
  
      <script>
        function search(query) {
          var result = searchData(query);
          var html = createHtml(result);
          showResult(html);
          showResultCount(result.length, data.length);
        }
  
        function searchData(query) {
          var result = [];
  
          query = query.trim();
          if (query.length < 1) {
            return result;
          }
          var re = new RegExp(query, 'i');
          for (var i = 0; i < data.length; ++i) {
            var pos = data[i].body.search(re);
            if (pos != -1) {
              result.push([i, pos, pos + query.length]);
            }
          }
          return result;
        }
  
        function createHtml(result) {
          var htmls = [];
          for (var i = 0; i < result.length; ++i) {
            var dataIndex = result[i][0];
            var startPos = result[i][1];
            var endPos = result[i][2];
            var url = data[dataIndex].url;
            var title = data[dataIndex].title;
            var body = data[dataIndex].body;
            htmls.push(createEntry(url, title, body, startPos, endPos));
          }
          return htmls.join('');
        }
  
        function createEntry(url, title, body, startPos, endPos) {
          return '<div class="item">' +
            '<a class="item_title" href="' + url + '">' + title + '</a>' +
            '<div class="item_excerpt">' + excerpt(body, startPos, endPos) + '</div>' +
            '</div>';
        }
  
        function excerpt(body, startPos, endPos) {
          return [
            body.substring(startPos - 30, startPos),
            '<b>', body.substring(startPos, endPos), '</b>',
            body.substring(endPos, endPos + 200)
          ].join('');
        }
  
        function showResult(html) {
          var el = document.getElementById('result');
          el.innerHTML = html;
        }
  
        function showResultCount(count, total) {
          var el = document.getElementById('resultCount');
          el.innerHTML = '<b>' + count + '</b> 件見つかりました(' + total + '件中)';
        }
        </script>
  
      <noscript><p class="notice">注意: この検索機能は JavaScript を使用しています</p></noscript>
    </p>
  </div>

  {{ end }}

検索結果のレイアウトを調整するならここを調整します。

検索結果ページのフロントマター

さいごに、layouts/search/single.htmlを表示するためのフロントマターを用意したら完了です。

contentsフォルダ直下にsearch.mdというファイルを設置しました。

search.md
type = "search"
url = "search"
title = "サイト内検索"

自分のドメイン/searchにアクセスしたら検索ページが表示されてれば実装完了です。

あとは、ここから機能を調整していきましょう。

自分の環境用に機能の調整

取得項目の調整

layouts/js/single.htmlを編集して、取得するデータの項目と取得データを変えます。

以下が、自分用に加えた調整です。

layouts/js/single.html
<!-- 取得データの部分 -->
{{- range $index, $page := where .Site.Pages "Params.type" "post" }}
  {
    url: {{ $page.Permalink | jsonify }},
    title: {{ $page.Title | jsonify }},
    image: {{ $page.Params.image | jsonify }},
    lastmod: {{ $page.Lastmod.Format "2006-01-02" | jsonify }},
    date: {{ $page.Date.Format "2006-01-02" | jsonify }},
    body: {{ template "escape" (printf "%s %s" $page.Title $page.Plain) }}
  },
{{- end }}

検索結果ページに公開日と更新日を載せたいので、lastmodを追加しました。
あと、標準の日付フォーマットだと、秒単位まで表示されて煩わしいので書式を調整しています。

サムネイルも出たら面白そうなんで、imageも取得することにしました。

表示結果の調整

layouts/search/single.htmlを編集して、表示項目を変えます。

layouts/search/single.html
<!-- 表示項目の部分 -->
function createEntry(url, title, image, date, lastmod,body, startPos, endPos) {
return '<div class="search-item-wrap">' +'<a href="' + url + '">' + '<div class="search-item-head">' + '<div class="search-item-image">' + '<img src="' + image + '">' + '</div>' + '<div class="search-item-info">' + '<span class="search-item-title">'+ title + '</span>' + '<span class="search-item-date">' + '<br>' + '更新日:' + lastmod + ' 公開日:' + date + '</a>' + '</div>' + '</div>' + '<div class="search-item-excerpt">' + '<p class="search-item-text">' + excerpt(body, startPos, endPos) + '</p>' +'</div>'+'</div>';
}

画像を持ってきたり、更新日を持ってきているので諸々弄ってます。

元のソースコードがマトモなので、この2ヶ所をほんの少し調整するだけで自分ごのみの全文検索システムになります。

レイアウトの修正

最後にレイアウトを整えましょう。
指定したクラスにCSSで調整したら終わりです。

完成形がこちらです。
HUGOに全文検索導入した結果
画像も読み込まれるようになり、デザイン自体もサイトに馴染みました。

CSSは以下です。

style.css
#searchbox {
  margin: 1rem;
}

#searchbox>input {
  color: #444;
  font-size: 1.2rem;
  font-weight: bolder;
  border: solid;
  border-color: #ccc;
  border-width: 0.1rem;
  padding: 0.5rem;
}

input::-webkit-input-placeholder {
  color: #777;
}

#result {
  margin: 1rem;
}

.search-item-wrap{
  border-radius: 1rem;
  box-shadow: 3px 3px 7px #a7a7a7, -3px -3px 7px #f7f7f7;
  margin-bottom: 1rem;
}

.search-item-wrap a{
  text-decoration: none;
  color: #444;
}

.search-item-head{
  display: flex;
  flex: wrap;
}

.search-item-image{
  max-width: 50%;
}

.search-item-info{
  min-width: 40%;
  padding: 0.5rem;
}

.search-item-title{
  font-size: 1.4rem;
  font-weight: bold;
  line-height: 1.2;
}

.search-item-date{
  font-size: 1.2rem;
}

.search-item-text{
  font-size: 1.4rem;
  padding: 0 0.5rem;
  margin: 0;
  line-height: 1.4;
}

.search-item-text b{
  font-weight: bold;
  background: linear-gradient(transparent 50%, #ff6 50%);
}

@media screen and (min-width:900px) {
  .search-item-title{
    font-size: 1.8rem;
    font-weight: bold;
  }

  .search-item-date{
    font-size: 1.4rem;
  }
}

まとめ:HUGOで全文検索の実装はデータ取得さえ正しくできれば実装はカンタン

HUGO全文検索の実装は一見ハードルが高そうですが、諸先輩方が共有してくれているコードを仕様すれば比較的カンタンに実装できます。

layouts/js/single.htmlで記事データを読み込むのを自サイトにあった形式に書き換えることができれば、スムーズに行くでしょう。

参考にしたサイト

以下、今回の全文検索機能の作成にあたり参考にしたサイトです。

カテゴリー:HUGO