🔍

给hugo增加站内搜索

搜索方案概述

搜索方案大体上分为前端搜索和后端搜索。

后端搜索通常使用 elastic search,这是一个很成熟的解决方案。另外,不同的语言通常会有一些小众化的解决方案,例如 php 中的 tnt search,以及迅搜

后端搜索还可以使用第三方的服务,algolia就是一个很不错的选择,有免费额度,初创公司或者个人用户可以考虑。但是离我们最近的数据中心在香港,延迟是一个大问题。再者就是使用腾讯云或者阿里云提供的 elastic search 服务,但是很贵,用不起🙂,溜了溜了。

前端搜索也可以叫做 browser search 或者 offline search,总的来说就是搜索的时候不需要发请求到服务器。关于前端搜索,我所了解的,有这么几个可供选择:

这个网站还有列举了另外几个不是很流行的库,可以瞄一眼。

总体上说,前端方案是轻量级的,而后端方案成熟,大而全。

fusejs

一个轻量级的模糊搜索库。

安装方式

官网都写了。

npm or yarn

和其他库也没什么两样的,npm 或者 yarn 下载即可。

1
2
3
npm install --save fuse.js
// or
yarn add fuse.js

手动

如果不想用 npm 之类的工具,可以去仓库里手动复制到本地来用。

cdn引入

传统 cdn 引入方式。

1
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.4.6"></script>

module导入

可以使用浏览器原生支持的模块功能从外部导入。

1
2
3
<script type="module">
import Fuse from 'https://cdn.jsdelivr.net/npm/fuse.js@6.4.6/dist/fuse.esm.js'
</script>

deno

值得一提的是,如果你使用 deno,也可以用 deno 的方式引入。

1
import Fuse from 'https://deno.land/x/fuse@v6.4.6/dist/fuse.esm.min.js'

基本用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<script>
    // 数据
    const data = [
        {
            "title": "hello world",
        },
        {
            "title": "你好,世界",
        }
    ];

    // 弄一个实例
    const fuse = new Fuse(data, {
        // 自定义需要搜索的字段
        keys: ["title"]
    });

    // 执行搜索
    const result = fuse.search('world');
</script>

结合 hugo 使用

总体的思想

  1. hugo 生成 json 数据。
  2. 搜索的时候,用 js 发请求,拿到数据。
  3. 实例化 fuse,搜索。
  4. 得到搜索结果后,把结果展示在页面上。

下面以官方给的代码为例,阐述一下实现步骤。

hugo 生成 json 数据

hugo 支持自定义输出类型,参考

配置

Config.toml 中增加 json 输出:

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

定义 json 格式

layout/_default/index.json

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

做好上面两步之后,可以通过 http(s)://youdomain.com/index.json 拿到数据了。执行 hugo 构建站点,在 public 文件夹中也可以看到 index.json 文件。

引入 fuse.js

通常来说,主题文件里有一个 themes/xxx/layouts/_default/baseof.html 的文件,把 <script src="https://cdn.jsdelivr.net/npm/fuse.js@6.4.6"></script> 放到这个 html 的 header 中。

搜索框

同样找到 themes/xxx/layouts/_default/baseof.html 文件,在 body 中放入:

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

搜索框样式

新建文件 static/css/search.css ,并在 themes/xxx/layouts/_default/baseof.html 中引入:<link rel=“stylesheet” href='{{ “css/search.css” | relURL }}'>。

 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
#fastSearch { 
  visibility: hidden;
  position: absolute;
  right: 0px;
  top: 0px;
  display: inline-block;
  width: 300px;
}      

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

#searchResults li { 
  list-style: none; 
  margin-left: 0em;
  background-color: #333; 
  border-bottom: 1px dotted #000;
}
  #searchResults li .title { font-size: 1.1em; margin-bottom: 10px; display: inline-block;}

#searchResults { visibility: inherit; display: inline-block; width: 320px; }
#searchResults a { text-decoration: none !important; padding: 10px; display: inline-block; }
#searchResults a:hover, a:focus { outline: 0; background-color: #666; color: #fff; }

search.js

新建文件 static/js/search.js ,并在 themes/xxx/layouts/_default/baseof.html 中引入:<script src='{{ "js/search.js" | relURL }}'></script>

  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
var fuse; // holds our search engine
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.metaKey && 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
      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) {
        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; // search visible
      }
      else {
        document.getElementById("fastSearch").style.visibility = "hidden"; // hide search box
        document.activeElement.blur(); // remove focus from search box 
        searchVisible = false; // search not visible
      }
  }

  // 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);
}


// ==========================================
// 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() { 
  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: [
        'title',
        'permalink',
        'summary'
        ]
    };
    fuse = new Fuse(data, options); // 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 
    for (let item in results.slice(0,5)) { // only show first 5 results
      searchitems = searchitems + '<li><a href="' + results[item].item.permalink + '" tabindex="0">' + '<span class="title">' + results[item].item.title + '</span><br /> <span class="sc">'+ results[item].item.section +'</span> — ' + results[item].item.date + ' — <em>' + results[item].item.desc + '</em></a></li>';
    }
    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
  }
}

CMD + / 或者 win + / 即可开启搜索。