为Hugo博客添加搜索功能

起因

一直以来我对博客的功能要求都不高,比如说wordpress上一些强大的主题,虽然有些功能的确是很吸引人,但我觉得很多都是可有可无的,博客发展到现在,基本都已经成了个人自娱自乐的自留地了,所以大家都各有各的选择,没有好与不好之分,只要适合就好。

昨天有个博友圈的朋友和我说,为什么你的博客连个搜索功能都没有的啊?太Low了。

其实嘛,我现在用的主题是带搜索功能的,不过是使用google的ces,所以国内都使用不了,我就把功能给关了。刚开始我也试着去修改,曾经看中了Algolia,但当时可能是太长时间没折腾了,CSS上一些基础知识都忘得七七八八了,所以一直没能把Algolia很好地融入到现在这个主题上。

也许是因为最近折腾多了,慢慢的也找回了点记忆,外加朋友也正好说起了这个事情,就尝试着去为博客添加一个搜索功能吧,毕竟搜索也属于博客的一个基本功能,就如评论一样,没有的话总感觉少了丝灵魂。

说明

Hugo官网文档找了下,这个怎么说呢,可选择的还真不是很多,有些还是三年没有更新过了的。最后我选择了hugofastsearch,官网文档是这样定义的:

A usability and speed update to “GitHub Gist for Fuse.js integration” — global, keyboard-optimized search.

没错,这个搜索功能其实就是官方文档中另外一个方案“GitHub Gist for Fuse.js integration”的升级改良版。至于有什么优缺点,老实说我也是刚用,具体好与坏现在也不好说,我当时选择这个方案是因为不用其他的依赖,也不用再输入任何的编译命令,更不用其他的工具。所以正适合追求简洁的我。

安装

添加index.json

在themes/主题文件夹/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 "date" .Date "section" .Section) -}}
4{{- end -}}
5{{- $.Scratch.Get "index" | jsonify -}}

修改配置文件config.toml

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

添加js文件

在Hugo默认的静态文件目录/static/js/添加fastsearch.js和fuse.js。fuse.js这里建议使用官方提供fuse.min.js,下载地址:https://github.com/krisk/fuse

fastsearch.js内容如下

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

添加HTML代码到主题里

这里因为每个主题可能存在差异,所以请根据自己实际的情况做出相应的更改。我选择将代码添加到页头的菜单栏后面,在/layouts/partials/header.html添加

 1      <li class="menu-item">
 2        <a id="search-btn" style="display: inline-block;" href="javascript:void(0);">
 3          <i class="iconfont">
 4            {{ partial "svg/search.svg" }}
 5          </i>
 6        </a>
 7        <div id="fastSearch">
 8          <input id="searchInput" tabindex="0">
 9          <ul id="searchResults">
10          </ul>
11        </div>
12      </li>

li标签是继承主题,i标签是因为调用了图标。

在主题模板上引用js

我使用的主题有一个专门引用js的模板,所以我选择在此添加引用。选择/layouts/partials/scripts.html添加

1<!-- Fastsearch -->
2<script src="/js/fuse.min.js"></script>
3<script src="/js/fastsearch.js"></script>

添加CSS样式

添加样式我们尽量选择对应的模板来添加,比如说我是在header里修改的,那么我就直接选择在/assets/sass/_partial/_header.scss添加CSS样式了。如果你使用的主题没有模板CSS的话,直接在主题的主CSS上添加。

 1#fastSearch {
 2  visibility: hidden;
 3  position: absolute;
 4  right: 0px;
 5  top: 30px;
 6  display: inline-block;
 7  width: 320px;
 8  margin: 0 10px 0 0;
 9  padding: 0;
10}
11
12#fastSearch input {
13  padding: 4px;
14  width: 100%;
15  height: 31px;
16  font-size: 1em;
17  color: #465373;
18  font-weight: bold;
19  background-color: #95B0F4;
20  border-radius: 3px 3px 0px 0px;
21  border: none;
22  outline: none;
23  text-align: left;
24  display: inline-block;
25}
26
27#fastSearch ul {
28  list-style: none;
29  margin: 0px;
30  padding: 0px;
31}
32
33#searchResults li {
34  list-style: none;
35  margin-left: 0em;
36  background-color: #E1E7F7;
37  border-bottom: 1px dotted #465373;
38}
39
40#searchResults li .title {
41  font-size: .9em;
42  margin: 0;
43  display: inline-block;
44}
45
46#searchResults {
47  visibility: inherit;
48  display: inline-block;
49  width: 328px;
50  margin: 0;
51  max-height: calc(100vh - 120px);
52  overflow: hidden;
53}
54
55#searchResults a {
56  text-decoration: none !important;
57  padding: 10px;
58  display: inline-block;
59  width: 100%;
60}
61
62#searchResults a:hover, #searchResults a:focus {
63  outline: 0;
64  background-color: #95B0F4;
65  color: #fff;
66}
67
68#search-btn {
69  position: sticky;
70  font-size: 20px;
71}

这里需要根据自己主题来进行稍微的修改。

总结

其实跟着官方的教程一步步来集成还是很简单的,十分、八分钟吧。在使用后觉得反应是真的快,比之前使用的Algolia快太多了,而且还方便,Algolia在hugo之后还得输入npm run algolia,fastsearch不需任何命令,正常的hugo就行。