5分钟给Hugo博客增加搜索功能
此方法来自Hugo官方文档 中的 hugofastsearch
A usability and speed update to “Github Gist for Fuse.js integration” — global, keyboard-optimized search.
没错,这个方案,是Github Gist for Fuse.js integration 的改进版。
其实在使用这个方案之前,老灯也尝试了hugo-lunr-zh
方案。hugo-lunr Last publish 4 years ago 而hugo-lunr-zh本身是基于hugo-lunr
添加了一个nodejieba(结巴分词lib)分词的功能以支持中文,同样是年久失修了 Last publish 2 years ago, 不过我使用这个生成索引失败了,没有任何错误输出,只能做罢。
亮点
- 最小/零外部依赖(无需jQuery)
- 添加到每个页面尺寸尽可能小
- JSON索引文件按需加载(进一步减少对页的速度/用户体验的整体影响)
- 键盘友好,瞬时导航(有点像Alfred / macOS Spotlight)
另外,此方案就像Eddie Webb指出的那样, 还有如下额外的好处:
- 无需NPM, grunt等外部工具
- 无需额外的编译步骤,你只需要像往常一样执行
hugo
- 可以方便地切换到任意可使用json索引的客户端搜索工具
集成步骤
- 添加
index.json
文件到layouts/_default
- 修改
config.toml
以使Hugo对首页生成额外的JSON
输出格式 - 添加
fastsearch.js
和fuse.min.js
(可从 https://fusejs.io 下载) 到static/js
- 添加搜索框HTML代码到模板页面footer
- 添加CSS样式到模板页面header或模板主CSS文件
- 访问 http://localhost:1313/ , 键入
Alt-/
执行搜索
相关文件
注意:跟原文章相比,老灯做了一些微调
允许通过点击页面空白处隐藏搜索框,而不是只能按Esc
在右上角添加了一个搜索按钮,方便不想按快捷键的人
默认的快捷键由于Firefox Linux默认
Super-/
是 Quick Find 功能,因此我改成了Alt-/
layouts/_default/index.json
{{- $.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 -}}
这里默认取的
contents
, 如果文章数量特别多,可能会导致生成的索引过大
config.toml
增加配置
[outputs]
home = ["HTML", "RSS", "JSON"]
static/js/fastsearch.js
fuse.min.js
可从 https://github.com/krisk/Fuse/releases 下载。
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代码到模板页面footer
这个可以通过添加到 baseof 或者 footer 模板。
比如我当前在使用的terminal主题, 它就内置了额外的footer支持, 可以通过添加 layouts/partials/extended_footer.html
方便地对footer增加内容。
如果主题没有额外的支持,你可以copy你当前主题目录下的baseof.html模板到layouts/_default/baseof.html
,然后在最后附加内容。
<a id="search-btn" style="display: inline-block;" href="javascript:void(0);">
<span class="icon-search">捜</span>
</a>
<div id="fastSearch">
<input id="searchInput" tabindex="0">
<ul id="searchResults">
</ul>
</div>
<script src="/js/fuse.min.js"></script> <!-- download and copy over fuse.min.js file from fusejs.io -->
<script src="/js/fastsearch.js"></script>
- 添加CSS样式到模板页面header或模板主CSS文件
这个可以通过添加到 header 模板 或模板的主CSS文件。
比如我当前在使用的terminal主题, 它就内置了额外的header支持, 可以通过添加 layouts/partials/extended_header.html
方便地对header增加内容。
如果主题没有额外的支持,你可以修改模板的主CSS文件,通常是style.css
或 main.css
, 这个因情况而异。
#fastSearch {
visibility: hidden;
position: absolute;
right: 10px;
top: 10px;
display: inline-block;
width: 320px;
margin: 0 10px 0 0;
padding: 0;
}
#fastSearch input {
padding: 4px;
width: 100%;
height: 31px;
font-size: 1.6em;
color: #222129;
font-weight: bold;
background-color: #ffa86a;
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: 0;
display: inline-block;
}
#searchResults {
visibility: inherit;
display: inline-block;
width: 320px;
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: #666;
color: #fff;
}
#search-btn {
position: absolute;
top: 10px;
right: 20px;
font-size: 24px;
}
@media (max-width:683px) {
#fastSearch, #search-btn {
top: 64px;
}
}
如果样式跟你当前的主题不是很合,你可以自行稍作调整。