此方法来自 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,不过我使用这个生成索引失败了,没有任何错误输出,只能做罢。

亮点

  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.js 和 fuse.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

1
2
3
4
5
{{- $.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 增加配置
1
2
[outputs]
  home = ["HTML", "RSS", "JSON"]
  1. static/js/fastsearch.js

fuse.min.js 可从 https://github.com/krisk/Fuse/releases 下载。

  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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
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,然后在最后附加内容。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<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. Css 或 main. Css,这个因情况而异。

 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
  #fastSearch {
    Visibility: hidden;
    Position: absolute;
    Right: 10 px;
    Top: 10 px;
    Display: inline-block;
    Width: 320 px;
    Margin: 0 10 px 0 0;
    Padding: 0;
  }

  #fastSearch input {
    Padding: 4 px;
    Width: 100%;
    Height: 31 px;
    Font-size: 1.6 em;
    color: #222129 ;
    Font-weight: bold;
    background-color: #ffa86a ;
    Border-radius: 3 px 3 px 0 px 0 px;
    Border: none;
    Outline: none;
    Text-align: left;
    Display: inline-block;
  }

  #searchResults li {
    List-style: none;
    Margin-left: 0 em;
    background-color: #333 ;
    border-bottom: 1 px dotted #000 ;
  }

  #searchResults li .title {
    Font-size: 1.1 em;
    Margin: 0;
    Display: inline-block;
  }

  #searchResults {
    Visibility: inherit;
    Display: inline-block;
    Width: 320 px;
    Margin: 0;
    Max-height: calc (100 vh - 120 px);
    Overflow: hidden;
  }

  #searchResults a {
    Text-decoration: none !Important;
    Padding: 10 px;
    Display: inline-block;
    Width: 100%;
  }

  #searchResults a: hover, #searchResults a: focus {
    Outline: 0;
    background-color: #666 ;
    color: #fff ;
  }

  #search -btn {
    Position: absolute;
    Top: 10 px;
    Right: 20 px;
    Font-size: 24 px;
  }

  @media (max-width: 683 px) {
    #fastSearch , #search -btn {
      Top: 64 px;
    }
  }

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