Hugo 实现搜索功能

11 minute

前言

这里记录了两种实现方式,一种通过 Fuse 进行,一种通过 Algolia 进行。

通过 fuse 实现是纯静态的方式,更为简单便捷,且很稳定,而通过 algolia 实现需要依赖于 algolia 服务,搜索功能更强大但可能不够稳定(特别是墙内用户)。

Fuse

生成搜索数据

layouts/_default/ 下新建 index.json 文件,编辑如下:

1{{- $.Scratch.Add "index" slice -}}
2{{- range .Site.RegularPages -}}
3    {{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "categories" .Params.categories "contents" .Plain "permalink" .Permalink) -}}
4{{- end -}}
5{{- $.Scratch.Get "index" | jsonify -}}

在配置文件(这里以 config.yaml 为例)中进行输出文件的配置:

1outputs:
2  home: [HTML, JSON, RSS]

注意,HTML, JSON, RSS 分别生成 index.html, index.json, index.xml。

这样,运行 hugo 生成命令即可生成搜索数据文件 index.json。

下载引用 fuse

About fuse: Lightweight fuzzy-search, in JavaScript.

可以从 github 下载到 fuse。

将 fuse 脚本(fuse.min.js)放置到根路径下(比如 static/js)。

调用 fuse 实现搜索功能

该脚本参考了 github 上的代码,然后在一些地方进行了小改动。

  1var fuse; // holds our search engine
  2var fuseIndex;
  3var searchVisible = false;
  4var firstRun = true; // allow us to delay loading json data unless search activated
  5var list = document.getElementById("searchResults"); // targets the <ul>
  6var first = list.firstChild; // first child of search list
  7var last = list.lastChild; // last child of search list
  8var maininput = document.getElementById("searchInput"); // input box for search
  9var resultsAvailable = false; // Did we get any search results?
 10
 11// ==========================================
 12// The main keyboard event listener running the show
 13//
 14document.addEventListener("keydown", function (event) {
 15  // CMD-/ to show / hide Search
 16  if (event.altKey && event.which === 191) {
 17    // Load json search index if first time invoking search
 18    // Means we don't load json unless searches are going to happen; keep user payload small unless needed
 19    doSearch(event);
 20  }
 21
 22  // Allow ESC (27) to close search box
 23  if (event.keyCode == 27) {
 24    if (searchVisible) {
 25      document.getElementById("fastSearch").style.visibility = "hidden";
 26      document.activeElement.blur();
 27      searchVisible = false;
 28    }
 29  }
 30
 31  // DOWN (40) arrow
 32  if (event.keyCode == 40) {
 33    if (searchVisible && resultsAvailable) {
 34      console.log("down");
 35      event.preventDefault(); // stop window from scrolling
 36      if (document.activeElement == maininput) {
 37        first.focus();
 38      } // if the currently focused element is the main input --> focus the first <li>
 39      else if (document.activeElement == last) {
 40        last.focus();
 41      } // if we're at the bottom, stay there
 42      else {
 43        document.activeElement.parentElement.nextSibling.firstElementChild.focus();
 44      } // otherwise select the next search result
 45    }
 46  }
 47
 48  // UP (38) arrow
 49  if (event.keyCode == 38) {
 50    if (searchVisible && resultsAvailable) {
 51      event.preventDefault(); // stop window from scrolling
 52      if (document.activeElement == maininput) {
 53        maininput.focus();
 54      } // If we're in the input box, do nothing
 55      else if (document.activeElement == first) {
 56        maininput.focus();
 57      } // If we're at the first item, go to input box
 58      else {
 59        document.activeElement.parentElement.previousSibling.firstElementChild.focus();
 60      } // Otherwise, select the search result above the current active one
 61    }
 62  }
 63});
 64
 65// ==========================================
 66// execute search as each character is typed
 67//
 68document.getElementById("searchInput").onkeyup = function (e) {
 69  executeSearch(this.value);
 70};
 71
 72document.querySelector("html").onclick = function (e) {
 73  if (e.target.id !== "searchInput") {
 74    hideSearch();
 75    $("#menu").css("visibility", "visible");
 76  }
 77};
 78
 79searchBtn = document.getElementById("searchBtn");
 80if (searchBtn) {
 81  searchBtn.onclick = function (e) {
 82    $("#menu").css("visibility", "hidden");
 83    doSearch(e);
 84  };
 85}
 86
 87function doSearch(e) {
 88  e.stopPropagation();
 89  if (firstRun) {
 90    loadSearch(); // loads our json data and builds fuse.js search index
 91    firstRun = false; // let's never do this again
 92  }
 93  // Toggle visibility of search box
 94  if (!searchVisible) {
 95    showSearch(); // search visible
 96  } else {
 97    hideSearch();
 98  }
 99}
100
101function hideSearch() {
102  document.getElementById("searchInput").value = "";
103  document.getElementById("fastSearch").style.visibility = "hidden"; // hide search box
104  document.getElementById("searchResults").innerHTML = ""; // remmove results
105  document.activeElement.blur(); // remove focus from search box
106  searchVisible = false;
107}
108
109function showSearch() {
110  document.getElementById("fastSearch").style.visibility = "visible"; // show search box
111  document.getElementById("searchInput").focus(); // put focus in input box so you can just start typing
112  searchVisible = true;
113}
114
115// ==========================================
116// fetch some json without jquery
117//
118function fetchJSONFile(path, callback) {
119  var httpRequest = new XMLHttpRequest();
120  httpRequest.onreadystatechange = function () {
121    if (httpRequest.readyState === 4) {
122      if (httpRequest.status === 200) {
123        var data = JSON.parse(httpRequest.responseText);
124        if (callback) callback(data);
125      }
126    }
127  };
128  httpRequest.open("GET", path);
129  httpRequest.send();
130}
131
132// ==========================================
133// load our search index, only executed once
134// on first call of search box (CMD-/)
135//
136function loadSearch() {
137  console.log("loadSearch()");
138  fetchJSONFile("/index.json", function (data) {
139    var options = {
140      // fuse.js options; check fuse.js website for details
141      shouldSort: true,
142      location: 0,
143      distance: 100,
144      threshold: 0.4,
145      minMatchCharLength: 2,
146      keys: ["permalink", "title", "tags", "contents"],
147    };
148    // Create the Fuse index
149    fuseIndex = Fuse.createIndex(options.keys, data);
150    fuse = new Fuse(data, options, fuseIndex); // build the index from the json file
151  });
152}
153
154// ==========================================
155// using the index we loaded on CMD-/, run
156// a search query (for "term") every time a letter is typed
157// in the search box
158//
159function executeSearch(term) {
160  let results = fuse.search(term); // the actual query being run using fuse.js
161  let searchitems = ""; // our results bucket
162
163  if (results.length === 0) {
164    // no results based on what was typed into the input box
165    resultsAvailable = false;
166    searchitems = "";
167  } else {
168    // build our html
169    // console.log(results)
170    permalinks = [];
171    numLimit = 15;
172    for (let item in results) {
173      if (item > numLimit) {
174        break;
175      }
176      if (permalinks.includes(results[item].item.permalink)) {
177        continue;
178      }
179      //   console.log('item: %d, title: %s', item, results[item].item.title)
180      searchitems =
181        searchitems +
182        '<li><a href="' +
183        results[item].item.permalink +
184        '" tabindex="0">' +
185        "<span>" +
186        results[item].item.title +
187        "</span></a></li>";
188      permalinks.push(results[item].item.permalink);
189    }
190    resultsAvailable = true;
191  }
192
193  document.getElementById("searchResults").innerHTML = searchitems;
194  if (results.length > 0) {
195    first = list.firstChild.firstElementChild; // first result container — used for checking against keyboard up/down location
196    last = list.lastChild.firstElementChild; // last result container — used for checking against keyboard up/down location
197  }
198}

将该脚本放置到根路径下(比如 static/js)。

接着,在需要用到搜索功能的界面中同时引用 fuse 脚本和该脚本。

创建搜索块

在 partials 下创建 search.html,编写内容如下:

1<div id="fastSearch">
2  <input id="searchInput" tabindex="0" />
3  <ul id="searchResults"></ul>
4</div>

在需要调用搜索功能的地方放置 {{ partial "search.html" . }} 即可(同一界面需要引用了以上两个脚本)。

参考 css:

 1#fastSearch {
 2  visibility: hidden;
 3  font-size: 15px;
 4  z-index: 99999;
 5}
 6
 7#searchInput {
 8  color: $color-search-text;
 9  background-color: $color-search-bg;
10  position: fixed;
11  display: inline;
12  top: 30px;
13  right: 8px;
14  height: 30px;
15  width: 300px;
16  padding: 4px;
17  outline: none;
18}
19
20#searchResults {
21  visibility: inherit;
22  display: inline;
23  position: fixed;
24  top: 64px;
25  right: 8px;
26  width: 340px;
27  li {
28    padding: 4px;
29    list-style: none;
30    background-color: $color-search-bg;
31    border-bottom: 1px dotted $color-search-border;
32  }
33}
34
35@media (max-width: 683px) {
36  #searchInput {
37    top: 20px;
38    left: 15px;
39    right: auto;
40  }
41
42  #searchResults {
43    top: 50px;
44    left: -25px;
45    right: auto;
46  }
47}

创建搜索图标

上面的搜索块通过参考 css 作用,在未被触发的情况下是隐藏的。

通过点击搜索图标可以弹出搜索块,可以通过 font-awesome 快速实现:

1<a class="icon" href="#" id="searchBtn"><i class="fas fa-search"></i></a>

在需要用到的地方放置该代码块即可(同个界面还需要包含搜索块和之前的两个脚本)。

Algolia

注册账号并创建 Index

官网链接:Algolia,注册完成后保存好 ApiID 和 ApiKey。

接着,创建一个 Index,保存好 Index 的名称。

搜索数据生成及上传

这里可以通过 github action 方便地实现,另外也通过 hugo-algolia 插件的方式进行,但是个人认为不够方便,这里不介绍了。

配置 algolia 输出文件

 1outputs:
 2home:
 3    - HTML
 4    - RSS
 5    - Algolia
 6outputFormats:
 7    Algolia:
 8        mediaType: application/json
 9        baseName: algolia
10        isPlainText: true

生成 algolia.json

编辑 {site}/themes/layouts/_default/list.algolia.json 如下:

 1[
 2    {{- range $index, $entry := .Site.RegularPages }}
 3    {{- if $index }}, {{ end }}
 4        {
 5        "objectID": "{{ .File.TranslationBaseName }}",
 6        "url": {{ .Permalink | jsonify }},
 7        "title": {{ .Title | jsonify }},
 8        "date": {{ .PublishDate | jsonify }},
 9        "tags": {{ .Params.tags | jsonify }},
10        "categories": {{.Params.categories | jsonify}},
11        "summary": {{ .Summary | jsonify }},
12        "content": {{ .Plain | jsonify }}
13        }
14    {{- end }}
15]

这里可以自行查阅文档定制化查询数据。

接着,通过 hugo 相关命令即可在 public 下生成 algolia.json 文件,这样就得到了搜索数据。

在 GithubAction 新建工作流

 1name: Algolia Upload Records
 2on:
 3[push] # 推送时执行
 4jobs:
 5algolia:
 6    runs-on: ubuntu-latest
 7    steps:
 8    - name: Checkout
 9        # 获取代码 Checkout
10        uses: actions/checkout@v2
11    - name: Upload Records
12        # 使用 Action
13        uses: iChochy/Algolia-Upload-Records@main
14        # 设置环境变量
15        env:
16        APPLICATION_ID: ${{secrets.ALGOLIA_APPID}} # appID
17        ADMIN_API_KEY: ${{secrets.ALGOLIA_KEY}} # key
18        INDEX_NAME: ${{secrets.ALGOLIA_INDEX}} # index
19        FILE_PATH: algolia.json

注意添加好对应环境变量。

之后,每次 push 都会自动将 algolia.json 推送到 algolia 数据库啦。

关于搜索类型及优先级的设置

前往 algolia 的 indices 进行搜索类型的设置,可以选择按 tag,category,content 等内容进行搜索,并指定优先级。

这里也就是 algolia 更加强大的一个点了。

定制搜索框

{site}/themes/{theme}/layouts/partials 下创建 search.html:

 1<div id="modalSearch" class="modal fade" role="dialog">
 2    <div class="modal-dialog">
 3        <div class="modal-content">
 4            <div class="modal-body">
 5                <div class="aa-input-container" id="aa-input-container">
 6                    <input type="search" id="aa-search-input" class="aa-input-search" placeholder="write here..." name="search" autocomplete="off" />
 7                </div>
 8            </div>
 9        </div>
10    </div>
11</div>
12
13<script src="{{ "https://res.cloudinary.com/jimmysong/raw/upload/rootsongjc-hugo/algoliasearch.min.js" | absURL }}"></script>
14<script src="{{ "https://res.cloudinary.com/jimmysong/raw/upload/rootsongjc-hugo/autocomplete.min.js" | absURL }}"></script>
15<script>
16var client = algoliasearch("{appID}", "{key}");
17var index = client.initIndex('{indexName}');
18autocomplete('#aa-search-input',
19{ hint: false}, {
20    source: autocomplete.sources.hits(index, {hitsPerPage: 8}),
21    displayKey: 'name',
22    templates: {
23        suggestion: function(suggestion) {
24            var des_url = suggestion.uri;
25            var reg = /[《》()]/g; // 转化一些中文字符,可以自己指定需要的
26            des_url = des_url.toLowerCase().replace(reg, ""); // 转为小写
27            return '<span>' + '<a href="' + des_url + '"">' +
28            suggestion._highlightResult.title.value + '</a></span>';
29        }
30    }
31});
32</script>

其中注意代码片段:

1var client = algoliasearch("{appID}", "{key}");
2var index = client.initIndex('{indexName}');

自己填入自己的 appID,key,index 名称即可。

最后在自己需要的地方放置搜索框即可:

1<!--搜索文章-->
2{{ partial "search.html" . }}

参考:hugofastsearch