5分钟给Hugo博客增加搜索功能

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 agohugo-lunr-zh本身是基于hugo-lunr添加了一个nodejieba(结巴分词lib)分词的功能以支持中文,同样是年久失修了 Last publish 2 years ago, 不过我使用这个生成索引失败了,没有任何错误输出,只能做罢。

亮点

  1. 最小/零外部依赖(无需jQuery)
  2. 添加到每个页面尺寸尽可能小
  3. JSON索引文件按需加载(进一步减少对页的速度/用户体验的整体影响)
  4. 键盘友好,瞬时导航(有点像Alfred / macOS Spotlight)

另外,此方案就像Eddie Webb指出的那样, 还有如下额外的好处:

  1. 无需NPM, grunt等外部工具
  2. 无需额外的编译步骤,你只需要像往常一样执行hugo
  3. 可以方便地切换到任意可使用json索引的客户端搜索工具

集成步骤

  1. 添加 index.json 文件到 layouts/_default
  2. 修改config.toml以使Hugo对首页生成额外的JSON输出格式
  3. 添加fastsearch.jsfuse.min.js (可从 https://fusejs.io 下载) 到 static/js
  4. 添加搜索框HTML代码到模板页面footer
  5. 添加CSS样式到模板页面header或模板主CSS文件
  6. 访问 http://localhost:1313/ , 键入 Alt-/ 执行搜索

相关文件

注意:跟原文章相比,老灯做了一些微调

  1. 允许通过点击页面空白处隐藏搜索框,而不是只能按Esc

  2. 在右上角添加了一个搜索按钮,方便不想按快捷键的人

  3. 默认的快捷键由于Firefox Linux默认 Super-/Quick Find 功能,因此我改成了 Alt-/

  4. 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, 如果文章数量特别多,可能会导致生成的索引过大

  1. config.toml 增加配置
[outputs]
  home = ["HTML", "RSS", "JSON"]
  1. 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
  }
}
  1. 添加搜索框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>
  1. 添加CSS样式到模板页面header或模板主CSS文件

这个可以通过添加到 header 模板 或模板的主CSS文件。

比如我当前在使用的terminal主题, 它就内置了额外的header支持, 可以通过添加 layouts/partials/extended_header.html 方便地对header增加内容。

如果主题没有额外的支持,你可以修改模板的主CSS文件,通常是style.cssmain.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;
    }
  }

如果样式跟你当前的主题不是很合,你可以自行稍作调整。