为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,内容为

{{- $.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

[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 内容如下

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 添加

<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 添加

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

添加 CSS 样式

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

#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: 0.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就行。