Hugo 实现搜索功能
前言
这里记录了两种实现方式,一种通过 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" . }}