为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
2
3
4
5
{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
    {{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "categories" .Params.categories "contents" .Plain "permalink" .Permalink "date" .Date "section" .Section) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

修改配置文件config.toml

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

添加js文件

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

fastsearch.js内容如下

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
var fuse; // holds our search engine
var fuseIndex;
var searchVisible = false; 
var firstRun = true; // allow us to delay loading json data unless search activated
var list = document.getElementById('searchResults'); // targets the <ul>
var first = list.firstChild; // first child of search list
var last = list.lastChild; // last child of search list
var maininput = document.getElementById('searchInput'); // input box for search
var resultsAvailable = false; // Did we get any search results?

// ==========================================
// The main keyboard event listener running the show
//
document.addEventListener('keydown', function(event) {

  // CMD-/ to show / hide Search
  if (event.altKey && event.which === 191) {
      // Load json search index if first time invoking search
      // Means we don't load json unless searches are going to happen; keep user payload small unless needed
      doSearch(event)
  }

  // Allow ESC (27) to close search box
  if (event.keyCode == 27) {
    if (searchVisible) {
      document.getElementById("fastSearch").style.visibility = "hidden";
      document.activeElement.blur();
      searchVisible = false;
    }
  }

  // DOWN (40) arrow
  if (event.keyCode == 40) {
    if (searchVisible && resultsAvailable) {
      console.log("down");
      event.preventDefault(); // stop window from scrolling
      if ( document.activeElement == maininput) { first.focus(); } // if the currently focused element is the main input --> focus the first <li>
      else if ( document.activeElement == last ) { last.focus(); } // if we're at the bottom, stay there
      else { document.activeElement.parentElement.nextSibling.firstElementChild.focus(); } // otherwise select the next search result
    }
  }

  // UP (38) arrow
  if (event.keyCode == 38) {
    if (searchVisible && resultsAvailable) {
      event.preventDefault(); // stop window from scrolling
      if ( document.activeElement == maininput) { maininput.focus(); } // If we're in the input box, do nothing
      else if ( document.activeElement == first) { maininput.focus(); } // If we're at the first item, go to input box
      else { document.activeElement.parentElement.previousSibling.firstElementChild.focus(); } // Otherwise, select the search result above the current active one
    }
  }
});


// ==========================================
// execute search as each character is typed
//
document.getElementById("searchInput").onkeyup = function(e) { 
  executeSearch(this.value);
}

document.querySelector("body").onclick = function(e) { 
    if (e.target.tagName === 'BODY' || e.target.tagName === 'DIV') {
        hideSearch()
    }
}

document.querySelector("#search-btn").onclick = function(e) { 
    doSearch(e)
}
  
function doSearch(e) {
    e.stopPropagation();
    if (firstRun) {
        loadSearch() // loads our json data and builds fuse.js search index
        firstRun = false // let's never do this again
    }
    // Toggle visibility of search box
    if (!searchVisible) {
        showSearch() // search visible
    }
    else {
        hideSearch()
    }
}

function hideSearch() {
    document.getElementById("fastSearch").style.visibility = "hidden" // hide search box
    document.activeElement.blur() // remove focus from search box 
    searchVisible = false
}

function showSearch() {
    document.getElementById("fastSearch").style.visibility = "visible" // show search box
    document.getElementById("searchInput").focus() // put focus in input box so you can just start typing
    searchVisible = true
}

// ==========================================
// fetch some json without jquery
//
function fetchJSONFile(path, callback) {
  var httpRequest = new XMLHttpRequest();
  httpRequest.onreadystatechange = function() {
    if (httpRequest.readyState === 4) {
      if (httpRequest.status === 200) {
        var data = JSON.parse(httpRequest.responseText);
          if (callback) callback(data);
      }
    }
  };
  httpRequest.open('GET', path);
  httpRequest.send(); 
}


// ==========================================
// load our search index, only executed once
// on first call of search box (CMD-/)
//
function loadSearch() { 
  console.log('loadSearch()')
  fetchJSONFile('/index.json', function(data){

    var options = { // fuse.js options; check fuse.js website for details
      shouldSort: true,
      location: 0,
      distance: 100,
      threshold: 0.4,
      minMatchCharLength: 2,
      keys: [
        'permalink',
        'title',
        'tags',
        'contents'
        ]
    };
    // Create the Fuse index
    fuseIndex = Fuse.createIndex(options.keys, data)
    fuse = new Fuse(data, options, fuseIndex); // build the index from the json file
  });
}


// ==========================================
// using the index we loaded on CMD-/, run 
// a search query (for "term") every time a letter is typed
// in the search box
//
function executeSearch(term) {
  let results = fuse.search(term); // the actual query being run using fuse.js
  let searchitems = ''; // our results bucket

  if (results.length === 0) { // no results based on what was typed into the input box
    resultsAvailable = false;
    searchitems = '';
  } else { // build our html
    // console.log(results)
    permalinks = [];
    numLimit = 5;
    for (let item in results) { // only show first 5 results
        if (item > numLimit) {
            break;
        }
        if (permalinks.includes(results[item].item.permalink)) {
            continue;
        }
    //   console.log('item: %d, title: %s', item, results[item].item.title)
      searchitems = searchitems + '<li><a href="' + results[item].item.permalink + '" tabindex="0">' + '<span class="title">' + results[item].item.title + '</span></a></li>';
      permalinks.push(results[item].item.permalink);
    }
    resultsAvailable = true;
  }

  document.getElementById("searchResults").innerHTML = searchitems;
  if (results.length > 0) {
    first = list.firstChild.firstElementChild; // first result container — used for checking against keyboard up/down location
    last = list.lastChild.firstElementChild; // last result container — used for checking against keyboard up/down location
  }
}

添加HTML代码到主题里

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

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

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

在主题模板上引用js

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

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

添加CSS样式

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#fastSearch {
  visibility: hidden;
  position: absolute;
  right: 0px;
  top: 30px;
  display: inline-block;
  width: 320px;
  margin: 0 10px 0 0;
  padding: 0;
}

#fastSearch input {
  padding: 4px;
  width: 100%;
  height: 31px;
  font-size: 1em;
  color: #465373;
  font-weight: bold;
  background-color: #95B0F4;
  border-radius: 3px 3px 0px 0px;
  border: none;
  outline: none;
  text-align: left;
  display: inline-block;
}

#fastSearch ul {
  list-style: none;
  margin: 0px;
  padding: 0px;
}

#searchResults li {
  list-style: none;
  margin-left: 0em;
  background-color: #E1E7F7;
  border-bottom: 1px dotted #465373;
}

#searchResults li .title {
  font-size: .9em;
  margin: 0;
  display: inline-block;
}

#searchResults {
  visibility: inherit;
  display: inline-block;
  width: 328px;
  margin: 0;
  max-height: calc(100vh - 120px);
  overflow: hidden;
}

#searchResults a {
  text-decoration: none !important;
  padding: 10px;
  display: inline-block;
  width: 100%;
}

#searchResults a:hover, #searchResults a:focus {
  outline: 0;
  background-color: #95B0F4;
  color: #fff;
}

#search-btn {
  position: sticky;
  font-size: 20px;
}

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

总结

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