Hugoで作られたブログに、ブラウザで完結する検索ページを作ってみました。

Table of Contents

はじめに

経緯

このブログはHugoで作られています。
Hugoを選んだ経緯は以下の記事をご覧下さい。

当初から検索ページは作る予定でした。
しかし、調べていると『何かしら検索indexを作成/管理する仕組み』がサーバーサイドに必要という情報が数多くヒットしました。

SaaSや自前サーバを用意してまで作りたくなかったので、以前に以下の記事で一旦ごまかしました。

とはいえ、この方法は格好良くないし、何より面倒です。
記事を書いた私でさえ、検索する方法を覚えていません。

今回作成したページは上記不満を解決するものになっています。

前提

私の環境はWindows10です。
Windowsでなくても大丈夫だと思います。

また、HugoのテーマとしてTranquilpeakを使っています。

方針

クライアント側(ブラウザ)で全て完結する構成を目指します。

利用技術のバージョン

主な技術/ライブラリとして以下を使用します。

名称 バージョン 備考
Hugo v0.58.3 Go製静的サイトジェネレーター
Tranquilpeak 0.4.7-beta? Hugoで使えるテーマの1つ
Vue.js v2.6.10 フロントエンドのViewライブラリ
Fuse.js v3.4.5 軽量な曖昧検索ライブラリ

本記事ではこれらの技術説明はしません。

その他にもAxiosやLodashを使っています。

完成ページ

完成したページは https://blog.mamansoft.net/search/ です。
サイドバーの『Search』からでもどうぞ👾

キーワードを入力すると1秒弱してから結果が表示されます。

UIにはVue.jsを、検索にはFuse.jsを使っています。

以降は作り方の説明になります。

ビルドでjsonを作成できるよう設定ファイルをいじる

config.tomloutputs.homeを追加しましょう。
HTMLとRSSの他に、JSONを追加します。

[outputs]
  home = ["HTML", "RSS", "JSON"]

これで記事をビルドすると、jsonファイルも作成されるようになります。

検索用jsonのテンプレート作成

layouts/default配下に作成します。

layouts/_default/index.json
{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
    {{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "categories" .Params.categories "contents" .Plain "permalink" .Permalink "date" .Params.date "image" .Params.thumbnailImage) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

ここで定義された変数を記事から取得して、Fuse.jsの検索indexを作成します。
以下はアイテムの一例です。

  {
    "categories": ["engineering"],
    "contents": "TypeScriptでサクっと動作確認したいときのTipsをまとめてみました。\n はじめに 最小限のコードを書いて、『それがどのように動くのか』確認したいケースがあると思います。\nしかし、TypeScriptはJavaScriptのようにChrome開発者ツールから気軽に確認できません。\nそんなときに使えるTipsを3つ紹介します。\n公式のPlayground TypeScriptの文法や挙動を確認したいなら最適です。\n メリット 事前準備が不要 ブラウザがあれば動きます。\n設定の切り替えが楽 GUIから利用バージョンやConfig設定を切り替えられます。\nトランスパイル結果がデフォルトで隣に表示される ァイルを実行するため   prettier 自動でフォーマットをかけるため    個人的な好みでeslintはインストールしていません。\nテストを書きたい場合はJestを追加します。\nnpm i -D jest ts-jest @types/jest npx ts-jest config:init  総括 TypeScriptでサクっと動作確認したいときのTipsをまとめてみました。\n   Tips 個人的なオススメ用途     公式のPlayground TypeScriptの動作確認   StackBlitz フロントエンド開発の動作確認 (特にUIフレームワーク)   Localに自分で作る バックエンド開発の動作確認 or いつものエディタ/IDE使いたい    状況と用途に応じて使い分けていきたいですね😄\n",
    "permalink": "https://blog.mamansoft.net/2019/11/02/run-typescript-quickly/",
    "tags": ["typescript"],
    "title": "TypeScriptでサクっと動作確認したいとき"
  },

検索ページマークダウンの作成

/searchでアクセスできるようにするため、content配下のsearch.mdを作ります。

content/search.md
---
title: "Search"
sitemap:
  priority : 0.1
showSocial: false
showPagination: false
showDate: false
---

{{<search>}}

----

タイトル、本文、タグなどから記事を検索できます。  
空白区切りはOR検索になります。(AND検索はできません)

showSocialshowPaginationshowDateTranquilpeakで使えるオプションです。
ソーシャルボタン、ページネーションボタン、日付を非表示にしています。

上記ページには検索用のshortcodesを埋め込んでいます。
このあと作成します。

検索shortcodesの作成

{{<search>}}で展開されるshortcodesを作ります。

layouts/shortcodes/search.html
<script src="https://cdn.jsdelivr.net/npm/vue" crossorigin></script>
<script
  src="https://cdnjs.cloudflare.com/ajax/libs/fuse.js/3.4.5/fuse.min.js"
  crossorigin
></script>
<script src="https://unpkg.com/axios/dist/axios.min.js" crossorigin></script>
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.15/lodash.min.js" crossorigin></script>
<link rel="stylesheet" href="{{ "css/search.css" | absURL }}" />

<div id="app">
  <div style="display: flex; align-items: center;">
    <i class="fas fa-search fa-lg"></i>
    <input
        v-model="word"
        name="word"
        type="text"
        class="form-control input--xlarge"
        placeholder="Search by word"
        autofocus="autofocus"
        style="margin-left: 10px;"
    />
  </div>
  <search-result-item
      v-for="res in results"
      :title="res.item.title"
      :contents="res.item.contents"
      :url="res.item.permalink"
      :date="res.item.date"
      :image="res.item.image"
      :tags="res.item.tags"
      style="padding: 20px;"
  />
</div>

<script src="{{ "js/search.js" | absURL }}"></script>

scriptタグで今回利用する技術を読み込んでいます。
あとで作成するjs/search.jsは最後に読みこみ必要があるため、記載も最後です。

なぜshortcodesを使うのか?
それ以外で上手くいくやり方が思いつかなかったからです..😅
もしbetterな方法ご存知の方いらっしゃれば教えて下さい🙇

JavaScriptファイルの作成

上記のhtmlで読み込まれるJavaScriptファイルを作成します。

static/js/search.js
const fuseOptions = {
  shouldSort: true,
  includeMatches: true,
  tokenize: true,
  threshold: 0.0,
  location: 0,
  distance: 100,
  maxPatternLength: 32,
  minMatchCharLength: 1,
  keys: [
    { name: "title", weight: 0.8 },
    { name: "contents", weight: 0.5 },
    { name: "tags", weight: 0.3 },
    { name: "categories", weight: 0.3 }
  ]
};

Vue.component("search-result-item", {
  props: ["title", "url", "date", "image", "contents", "tags"],
  template: `
  <div style="display: flex;">
    <div>
      <a :href="url">
        <img alt="" itemprop="image" :src="image" class="image">
      </a>
    </div>
    <div class="description">
      <a :href="url" v-text="title" style="font-weight: bold;"></a>
      <div v-text="contents" class="contents"></div>
      <div class="date" v-text="date"></div>
      <div v-for="tag in tags" class="search-tag" v-text="tag"></div>
    </div>
  </div>`
});

const app = new Vue({
  el: "#app",
  mounted: async function() {
    this.fuse = new Fuse((await axios.get("/index.json")).data, fuseOptions);
  },
  data: {
    fuse: {},
    word: "",
    results: []
  },
  watch: {
    word: _.debounce(function(word) {
      this.results = word.length > 0 ? this.fuse.search(word) : [];
    }, 500)
  }
});

2つポイントがあります。

VueはMustache構文を避けている

以下のようなMustache構文は使わず、v-textを使うようにしています。

<span>Message: {{ msg }}</span>

これはMustache構文が、Hugoのshortcodes表記と衝突するからです。

debounceで検索処理を遅延させている

Lodashの_.debounce関数で、0.5秒以上入力が途切れるまで検索しないようにしています。
Fuse.jsのパフォーマンスやVue.jsの描画コストを考慮しています。

CSSファイルの追加

最後はCSSファイルを追加します。
スマホでも全体が表示されるように一部@mediaを使っています。

static/css/search.css
.search-tag {
  font-size: 1.3rem;
  padding: 2px 10px;
  color: #349ef3 !important;
  border: 1px solid #349ef3;
  display: inline-block;
  background: #fff;
  width: auto;
  height: auto;
  border-radius: 3px;
  letter-spacing: 0.01em;
  margin: 0;
  margin-right: 4px;
  margin-bottom: 7px;
}

.contents {
  position: relative;
  font-size: 85%;
  width: 100%;
  height: 50px;
  overflow: hidden;
  text-align: justify;
}

.date {
  text-align: right;
  padding-top: 10px;
  color: darkgrey;
  font-size: 75%;
}

.post .post-content .image {
  min-width: 200px;
  max-width: 200px;
  border-radius: 10%;
}
.description {
  width: 480px;
  padding: 10px 0 10px 30px;
}

@media screen and (max-width: 480px) {
  .post .post-content .image {
    display: none;
  }
  .description {
    width: 300px;
    padding: 5px 0 5px 15px;
  }
}

サイドバーに検索ページへのリンクを追加する

/searchと入力してもらうわけにいかないので、config.tomlにリンクを追加します。

[[menu.main]]
  weight = 2
  identifier = "search"
  name = "Search"
  pre = "<i class=\"sidebar-button-icon fas fa-lg fa-search\"></i>"
  url = "/search"

せっかくなので、スマホで閲覧したときのヘッダ右側にも追加しました。

[params]

  [params.header.rightLink]
    url = "/search"
    icon = "search"

これでビルドすると検索できるようになっていると思います👍

総括

Hugoで作られた本ブログに、ブラウザで完結する検索ページを作ってみました。
以下のような課題も残っていますが、フロントエンドだけで完結できたため満足しています😄

  • 検索実行されないときがある
  • ローディング中 or 検索結果なし が区別できない
  • AND検索ができない

時間があるときに改善していければと🌻

2019-11-24: AND検索に対応しました

search.jsappを以下のようにします。

const search = (words, fuse) =>
  _.intersectionBy(...words.map(x => fuse.search(x)), "item.permalink");

const app = new Vue({
  el: "#app",
  mounted: async function() {
    this.fuse = new Fuse((await axios.get("/index.json")).data, fuseOptions);
  },
  data: {
    fuse: {},
    word: "",
    results: []
  },
  watch: {
    word: _.debounce(function(word) {
      this.results = word.length > 0 ? search(word.split(" "), this.fuse) : [];
    }, 500)
  }
});

item.permalinkで記事の一意性が保証できるため、LodashのintersectionByで共通部分だけを抽出しています。