前言

Hugo Diary 主题没有自带的搜索页面,文章多了后找笔记不方便,因此想给主题添加一个搜索页面,最终耗时一天成功添加。

过程

最开始发现一篇文章 5分钟给Hugo博客增加搜索功能 引入 fuse.js 文件实现搜索,最终效果是页面出现一个搜索按钮,点击会出现一个搜索框,然后可以搜索内容,呈现符合的文章标题列表,貌似由于主题的模板写法,使用这种方法在桌面端和移动端的 html 文件中添加会覆盖上一个引入的模板,并且按钮的位置调整也比较麻烦,因此作罢。

随后在另一篇文章中看到可以通过创建搜索模板文件来生成搜索页面,这样也不需要进行大的样式调整,所以使用这种方法,成功后发现这种搜索方法对中文并不生效,不会搜索中文内容,尝试引入结巴分词的 JS 库,但是没找到现成的,还老报错,因此再次寻找其他方法。

想到之前使用的 Stack 主题有搜索页面,并且中文的搜索也不错,所以尝试将其中的 TypeScript 代码转换成 js 后引入,最终成功,接下来记录一下详细的步骤。

文中所使用的站点结构以及部分 SCSS 样式变量仅适用于 Diary 主题,如果要引入其他主题记得更改

步骤

配置文件

首先在配置文件 config.yaml 中添加输出数据代码:

1
2
[outputs]
  home = ["HTML", "RSS", "JSON"]

顺带添加一个菜单子页面:

1
2
3
4
[[menu.main]]
url = "/search"
name = "🔍 搜索"
weight = 6

然后在 ~/content/ 文件夹新建 search.md 文件,Front Matter 填写如下:

1
2
3
4
---
title: "搜索"
layout: "search"
---

创建数据索引文件

~/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 "content" .Plain "permalink" .Permalink "date" .Date "section" .Section ) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

完成后可以通过 http://localhost:1313/index.json 查看是否成功生成数据,以及是否有想要的字段。

创建 JS 文件

~/static/js 文件夹下新建 search.js 文件,写入搜索代码:

  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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
/**
 * Escape HTML tags as HTML entities
 * Edited from:
 * @link https://stackoverflow.com/a/5499821
 */
const tagsToReplace = {
  '&': '&',
  '<': '&lt;',
  '>': '&gt;',
  '"': '&quot;',
  '…': '&hellip;'
};
function replaceTag(tag) {
  return tagsToReplace[tag] || tag;
}
function replaceHTMLEnt(str) {
  return str.replace(/[&<>"]/g, replaceTag);
}
function escapeRegExp(string) {
  return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
}

function Search({ form, input, list, resultTitle, resultTitleTemplate }) {
  this.form = form;
  this.input = input;
  this.list = list;
  this.resultTitle = resultTitle;
  this.resultTitleTemplate = resultTitleTemplate;
  this.data = null; // 用于缓存获取的数据
  this.handleQueryString();
  this.bindQueryStringChange();
  this.bindSearchForm();
}

/**
* Processes search matches
* @param str original text
* @param matches array of matches
* @param ellipsis whether to add ellipsis to the end of each match
* @param charLimit max length of preview string
* @param offset how many characters before and after the match to include in preview
* @returns preview string
*/
Search.processMatches = function (str, matches, ellipsis = true, charLimit = 140, offset = 20) {
  matches.sort((a, b) => {
      return a.start - b.start;
  });
  let i = 0, lastIndex = 0, charCount = 0;
  const resultArray = [];
  while (i < matches.length) {
      const item = matches[i];
      if (ellipsis && item.start - offset > lastIndex) {
          resultArray.push(`${replaceHTMLEnt(str.substring(lastIndex, lastIndex + offset))} [...] `);
          resultArray.push(`${replaceHTMLEnt(str.substring(item.start - offset, item.start))}`);
          charCount += offset * 2;
      } else {
          resultArray.push(replaceHTMLEnt(str.substring(lastIndex, item.start)));
          charCount += item.start - lastIndex;
      }
      let j = i + 1, end = item.end;
      while (j < matches.length && matches[j].start <= end) {
          end = Math.max(matches[j].end, end);
          ++j;
      }
      resultArray.push(`<mark>${replaceHTMLEnt(str.substring(item.start, end))}</mark>`);
      charCount += end - item.start;
      i = j;
      lastIndex = end;
      if (ellipsis && charCount > charLimit) break;
  }
  if (lastIndex < str.length) {
      let end = str.length;
      if (ellipsis) end = Math.min(end, lastIndex + offset);
      resultArray.push(`${replaceHTMLEnt(str.substring(lastIndex, end))}`);
      if (ellipsis && end !== str.length) {
          resultArray.push(` [...]`);
      }
  }
  return resultArray.join('');
};

Search.prototype.searchKeywords = async function (keywords) {
  const rawData = await this.getData();
  const results = [];
  const regex = new RegExp(keywords.filter((v, index, arr) => {
      arr[index] = escapeRegExp(v);
      return v.trim() !== '';
  }).join('|'), 'gi');
  for (const item of rawData) {
      const titleMatches = [], contentMatches = [];
      let result = Object.assign({}, item, { preview: '', matchCount: 0 });
      const contentMatchAll = item.content.matchAll(regex);
      for (const match of Array.from(contentMatchAll)) {
          contentMatches.push({
              start: match.index,
              end: match.index + match[0].length
          });
      }
      const titleMatchAll = item.title.matchAll(regex);
      for (const match of Array.from(titleMatchAll)) {
          titleMatches.push({
              start: match.index,
              end: match.index + match[0].length
          });
      }
      if (titleMatches.length > 0) {
          result.title = Search.processMatches(result.title, titleMatches, false);
      }
      if (contentMatches.length > 0) {
          result.preview = Search.processMatches(result.content, contentMatches);
      } else {
          result.preview = replaceHTMLEnt(result.content.substring(0, 140));
      }
      result.matchCount = titleMatches.length + contentMatches.length;
      if (result.matchCount > 0) results.push(result);
  }
  return results.sort((a, b) => b.matchCount - a.matchCount);
};

Search.prototype.doSearch = async function (keywords) {
  const startTime = performance.now();
  const results = await this.searchKeywords(keywords);
  this.clear();
  for (const item of results) {
      this.list.appendChild(Search.render(item));
  }
  const endTime = performance.now();
  this.resultTitle.innerText = this.generateResultTitle(results.length, ((endTime - startTime) / 1000).toPrecision(1));
};

Search.prototype.generateResultTitle = function (resultLen, time) {
  return this.resultTitleTemplate.replace("#PAGES_COUNT", resultLen).replace("#TIME_SECONDS", time);
};

Search.prototype.getData = async function () {
  if (!this.data) {
      const jsonURL = this.form.dataset.json;
      this.data = await fetch(jsonURL).then(res => res.json());
      const parser = new DOMParser();
      for (const item of this.data) {
          item.content = parser.parseFromString(item.content, 'text/html').body.innerText;
      }
  }
  return this.data;
};

Search.prototype.bindSearchForm = function () {
  let lastSearch = '';
  const eventHandler = (e) => {
      e.preventDefault();
      const keywords = this.input.value.trim();
      Search.updateQueryString(keywords, true);
      if (keywords === '') {
          lastSearch = '';
          return this.clear();
      }
      if (lastSearch === keywords) return;
      lastSearch = keywords;
      this.doSearch(keywords.split(' '));
  };
  this.input.addEventListener('input', eventHandler);
  this.input.addEventListener('compositionend', eventHandler);
};

Search.prototype.clear = function () {
  this.list.innerHTML = '';
  this.resultTitle.innerText = '';
};

Search.prototype.bindQueryStringChange = function () {
  window.addEventListener('popstate', () => {
      this.handleQueryString();
  });
};

Search.prototype.handleQueryString = function () {
  const pageURL = new URL(window.location.toString());
  const keywords = pageURL.searchParams.get('keyword');
  this.input.value = keywords;
  if (keywords) {
      this.doSearch(keywords.split(' '));
  } else {
      this.clear();
  }
};

Search.updateQueryString = function (keywords, replaceState = false) {
  const pageURL = new URL(window.location.toString());
  if (keywords === '') {
      pageURL.searchParams.delete('keyword');
  } else {
      pageURL.searchParams.set('keyword', keywords);
  }
  if (replaceState) {
      window.history.replaceState('', '', pageURL.toString());
  } else {
      window.history.pushState('', '', pageURL.toString());
  }
};

Search.render = function (item) {
  const article = document.createElement("article");

  const link = document.createElement("a");
  link.href = item.permalink;

  const detailsDiv = document.createElement("div");
  detailsDiv.className = "article-details";

  const title = document.createElement("h2");
  title.className = "article-title";
  title.innerHTML = item.title;
  detailsDiv.appendChild(title);

  const preview = document.createElement("section");
  preview.className = "article-preview";
  preview.innerHTML = item.preview;
  detailsDiv.appendChild(preview);

  link.appendChild(detailsDiv);

  article.appendChild(link);

  return article;
};


window.addEventListener('load', () => {
  setTimeout(() => {
      const searchForm = document.querySelector('.search-form');
      const searchInput = searchForm.querySelector('input');
      const searchResultList = document.querySelector('.search-result--list');
      const searchResultTitle = document.querySelector('.search-result--title');
      
      new Search({
          form: searchForm,
          input: searchInput,
          list: searchResultList,
          resultTitle: searchResultTitle,
          resultTitleTemplate: window.searchResultTitleTemplate
      });
  }, 0);
});

此搜索代码使用 ChatGPTHugo Stack 主题的 search.tsx 文件转换成 js 文件,并将其中的 React 及模块化相关代码使用原生实现,去除了封面图片相关代码。

创建模板文件

新建 ~/layouts/_default/search.html 模板文件,填入内容:

 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
{{ define "main" }}

<div class="post-list-container post-list-container-shadow">
  <a class="a-block">
    <div class="post-item-wrapper post-item-wrapper-no-hover">
      <div class="post-item post-item-no-gaps">
        <div class="post-item-info-wrapper">
          <div class="post-item-title post-item-title-small">
            {{.Title}}
          </div>
        </div>
      </div>
    </div>
  </a>
<!-- 搜索表单组件 -->
<form class="search-form" data-json="/index.json">
    <input type="text" placeholder="Search..." aria-label="Search" />
</form>
<div class="search-result--title">Results</div>
<ul class="search-result--list">
    <article>
        <a href="your-link">
            <div class="article-details">
                <h2 class="article-title"><a href="your-link">Article Title</a></h2>
                <section class="article-preview">This is a preview of the content with <mark>highlighted</mark> text.</section>
            </div>
        </a>
    </article>
</ul>


<script src="{{ "js/search.js" | relURL }}"></script>
<script>
    window.searchResultTitleTemplate = 'Found #PAGES_COUNT results in #TIME_SECONDS seconds';
</script>

{{ end }}

注意:总体的布局是 Diary 主题,如果是其他主题请使用相应的模板。

样式修改

~/assets/scss/custom.scss 中写入搜索框及搜索内容的样式:

 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
// 搜索页面样式
input{
  width: 100%;
  height: 40px;
  border-radius: 6px;
  border: 2px solid lighten($color-accent, 10%);
  background: #f5f5f5;
  body.night & {
    background: #333;
    border-color: 2px solid darken($color-accent, 10%);
    color: #e6e6e6;
  }
}
.search-result--title {
  font-size: 1.25rem;
  margin: 16px;
  color: #555;
  body.night & {
    color: #e6e6e6;
  }
}
// 样式用于搜索结果的整体容器
.search-result--list {
  margin: 0;
  padding: 0;
  list-style-type: none;
}

// 样式用于每个搜索结果的文章
.search-result--list article {
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  padding: 16px;
  border-bottom: 1px solid #ddd;
  transition: background-color 0.3s ease;
  mark{
    background-color: lighten($color-accent, 20%);
    color: #fff;
  }
  &:hover {
    background-color: #f9f9f9;
  }
}

// 样式用于文章的标题
.article-title {
  font-size: 1.5rem;
  margin: 0;
  color: #333;
  font-weight: bold;
  transition: color 0.3s ease;

  a {
    text-decoration: none;
    color: inherit;

    &:hover {
      color: #007bff;
    }
  }
}

// 样式用于文章预览部分
.article-preview {
  font-size: 1rem;
  color: #666;
  margin-top: 8px;
  line-height: 1.5;
  body.night & {
    color: #e6e6e6;
  }

  mark {
    background-color: lighten($color-accent, 20%);
    color: #333;
    font-weight: bold;
  }
}


// 样式用于文章的详细信息部分
.article-details {
  flex: 1;
}

// 样式用于搜索结果标题
.search-result--title {
  font-size: 1.25rem;
  margin-bottom: 16px;
  color: #555;
}

同样,代码中 $color-accent 以及 body.night 相关代码是 Diary 主题独有,其他主题需修改。

至此搜索页面的添加就结束了。

参考