给HugoDiary主题添加搜索页面
通过自定义 JS 给 Hugo Diary 主题添加一个搜索页面

前言

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

过程

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

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

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

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

步骤

配置文件

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

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

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

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

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

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

创建数据索引文件

~/layouts/_default/ 文件夹下新建 index.json 文件,写入内容,字典内的索引变量可以自定义。

1{{- $.Scratch.Add "index" slice -}}
2{{- range .Site.RegularPages -}}
3    {{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "categories" .Params.categories "content" .Plain "permalink" .Permalink "date" .Date "section" .Section ) -}}
4{{- end -}}
5{{- $.Scratch.Get "index" | jsonify -}}

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

创建 JS 文件

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

  1/**
  2 * Escape HTML tags as HTML entities
  3 * Edited from:
  4 * @link https://stackoverflow.com/a/5499821
  5 */
  6const tagsToReplace = {
  7  '&': '&',
  8  '<': '&lt;',
  9  '>': '&gt;',
 10  '"': '&quot;',
 11  '…': '&hellip;'
 12};
 13function replaceTag(tag) {
 14  return tagsToReplace[tag] || tag;
 15}
 16function replaceHTMLEnt(str) {
 17  return str.replace(/[&<>"]/g, replaceTag);
 18}
 19function escapeRegExp(string) {
 20  return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&');
 21}
 22
 23function Search({ form, input, list, resultTitle, resultTitleTemplate }) {
 24  this.form = form;
 25  this.input = input;
 26  this.list = list;
 27  this.resultTitle = resultTitle;
 28  this.resultTitleTemplate = resultTitleTemplate;
 29  this.data = null; // 用于缓存获取的数据
 30  this.handleQueryString();
 31  this.bindQueryStringChange();
 32  this.bindSearchForm();
 33}
 34
 35/**
 36* Processes search matches
 37* @param str original text
 38* @param matches array of matches
 39* @param ellipsis whether to add ellipsis to the end of each match
 40* @param charLimit max length of preview string
 41* @param offset how many characters before and after the match to include in preview
 42* @returns preview string
 43*/
 44Search.processMatches = function (str, matches, ellipsis = true, charLimit = 140, offset = 20) {
 45  matches.sort((a, b) => {
 46      return a.start - b.start;
 47  });
 48  let i = 0, lastIndex = 0, charCount = 0;
 49  const resultArray = [];
 50  while (i < matches.length) {
 51      const item = matches[i];
 52      if (ellipsis && item.start - offset > lastIndex) {
 53          resultArray.push(`${replaceHTMLEnt(str.substring(lastIndex, lastIndex + offset))} [...] `);
 54          resultArray.push(`${replaceHTMLEnt(str.substring(item.start - offset, item.start))}`);
 55          charCount += offset * 2;
 56      } else {
 57          resultArray.push(replaceHTMLEnt(str.substring(lastIndex, item.start)));
 58          charCount += item.start - lastIndex;
 59      }
 60      let j = i + 1, end = item.end;
 61      while (j < matches.length && matches[j].start <= end) {
 62          end = Math.max(matches[j].end, end);
 63          ++j;
 64      }
 65      resultArray.push(`<mark>${replaceHTMLEnt(str.substring(item.start, end))}</mark>`);
 66      charCount += end - item.start;
 67      i = j;
 68      lastIndex = end;
 69      if (ellipsis && charCount > charLimit) break;
 70  }
 71  if (lastIndex < str.length) {
 72      let end = str.length;
 73      if (ellipsis) end = Math.min(end, lastIndex + offset);
 74      resultArray.push(`${replaceHTMLEnt(str.substring(lastIndex, end))}`);
 75      if (ellipsis && end !== str.length) {
 76          resultArray.push(` [...]`);
 77      }
 78  }
 79  return resultArray.join('');
 80};
 81
 82Search.prototype.searchKeywords = async function (keywords) {
 83  const rawData = await this.getData();
 84  const results = [];
 85  const regex = new RegExp(keywords.filter((v, index, arr) => {
 86      arr[index] = escapeRegExp(v);
 87      return v.trim() !== '';
 88  }).join('|'), 'gi');
 89  for (const item of rawData) {
 90      const titleMatches = [], contentMatches = [];
 91      let result = Object.assign({}, item, { preview: '', matchCount: 0 });
 92      const contentMatchAll = item.content.matchAll(regex);
 93      for (const match of Array.from(contentMatchAll)) {
 94          contentMatches.push({
 95              start: match.index,
 96              end: match.index + match[0].length
 97          });
 98      }
 99      const titleMatchAll = item.title.matchAll(regex);
100      for (const match of Array.from(titleMatchAll)) {
101          titleMatches.push({
102              start: match.index,
103              end: match.index + match[0].length
104          });
105      }
106      if (titleMatches.length > 0) {
107          result.title = Search.processMatches(result.title, titleMatches, false);
108      }
109      if (contentMatches.length > 0) {
110          result.preview = Search.processMatches(result.content, contentMatches);
111      } else {
112          result.preview = replaceHTMLEnt(result.content.substring(0, 140));
113      }
114      result.matchCount = titleMatches.length + contentMatches.length;
115      if (result.matchCount > 0) results.push(result);
116  }
117  return results.sort((a, b) => b.matchCount - a.matchCount);
118};
119
120Search.prototype.doSearch = async function (keywords) {
121  const startTime = performance.now();
122  const results = await this.searchKeywords(keywords);
123  this.clear();
124  for (const item of results) {
125      this.list.appendChild(Search.render(item));
126  }
127  const endTime = performance.now();
128  this.resultTitle.innerText = this.generateResultTitle(results.length, ((endTime - startTime) / 1000).toPrecision(1));
129};
130
131Search.prototype.generateResultTitle = function (resultLen, time) {
132  return this.resultTitleTemplate.replace("#PAGES_COUNT", resultLen).replace("#TIME_SECONDS", time);
133};
134
135Search.prototype.getData = async function () {
136  if (!this.data) {
137      const jsonURL = this.form.dataset.json;
138      this.data = await fetch(jsonURL).then(res => res.json());
139      const parser = new DOMParser();
140      for (const item of this.data) {
141          item.content = parser.parseFromString(item.content, 'text/html').body.innerText;
142      }
143  }
144  return this.data;
145};
146
147Search.prototype.bindSearchForm = function () {
148  let lastSearch = '';
149  const eventHandler = (e) => {
150      e.preventDefault();
151      const keywords = this.input.value.trim();
152      Search.updateQueryString(keywords, true);
153      if (keywords === '') {
154          lastSearch = '';
155          return this.clear();
156      }
157      if (lastSearch === keywords) return;
158      lastSearch = keywords;
159      this.doSearch(keywords.split(' '));
160  };
161  this.input.addEventListener('input', eventHandler);
162  this.input.addEventListener('compositionend', eventHandler);
163};
164
165Search.prototype.clear = function () {
166  this.list.innerHTML = '';
167  this.resultTitle.innerText = '';
168};
169
170Search.prototype.bindQueryStringChange = function () {
171  window.addEventListener('popstate', () => {
172      this.handleQueryString();
173  });
174};
175
176Search.prototype.handleQueryString = function () {
177  const pageURL = new URL(window.location.toString());
178  const keywords = pageURL.searchParams.get('keyword');
179  this.input.value = keywords;
180  if (keywords) {
181      this.doSearch(keywords.split(' '));
182  } else {
183      this.clear();
184  }
185};
186
187Search.updateQueryString = function (keywords, replaceState = false) {
188  const pageURL = new URL(window.location.toString());
189  if (keywords === '') {
190      pageURL.searchParams.delete('keyword');
191  } else {
192      pageURL.searchParams.set('keyword', keywords);
193  }
194  if (replaceState) {
195      window.history.replaceState('', '', pageURL.toString());
196  } else {
197      window.history.pushState('', '', pageURL.toString());
198  }
199};
200
201Search.render = function (item) {
202  const article = document.createElement("article");
203
204  const link = document.createElement("a");
205  link.href = item.permalink;
206
207  const detailsDiv = document.createElement("div");
208  detailsDiv.className = "article-details";
209
210  const title = document.createElement("h2");
211  title.className = "article-title";
212  title.innerHTML = item.title;
213  detailsDiv.appendChild(title);
214
215  const preview = document.createElement("section");
216  preview.className = "article-preview";
217  preview.innerHTML = item.preview;
218  detailsDiv.appendChild(preview);
219
220  link.appendChild(detailsDiv);
221
222  article.appendChild(link);
223
224  return article;
225};
226
227
228window.addEventListener('load', () => {
229  setTimeout(() => {
230      const searchForm = document.querySelector('.search-form');
231      const searchInput = searchForm.querySelector('input');
232      const searchResultList = document.querySelector('.search-result--list');
233      const searchResultTitle = document.querySelector('.search-result--title');
234      
235      new Search({
236          form: searchForm,
237          input: searchInput,
238          list: searchResultList,
239          resultTitle: searchResultTitle,
240          resultTitleTemplate: window.searchResultTitleTemplate
241      });
242  }, 0);
243});

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

创建模板文件

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

 1{{ define "main" }}
 2
 3<div class="post-list-container post-list-container-shadow">
 4  <a class="a-block">
 5    <div class="post-item-wrapper post-item-wrapper-no-hover">
 6      <div class="post-item post-item-no-gaps">
 7        <div class="post-item-info-wrapper">
 8          <div class="post-item-title post-item-title-small">
 9            {{.Title}}
10          </div>
11        </div>
12      </div>
13    </div>
14  </a>
15<!-- 搜索表单组件 -->
16<form class="search-form" data-json="/index.json">
17    <input type="text" placeholder="Search..." aria-label="Search" />
18</form>
19<div class="search-result--title">Results</div>
20<ul class="search-result--list">
21    <article>
22        <a href="your-link">
23            <div class="article-details">
24                <h2 class="article-title"><a href="your-link">Article Title</a></h2>
25                <section class="article-preview">This is a preview of the content with <mark>highlighted</mark> text.</section>
26            </div>
27        </a>
28    </article>
29</ul>
30
31
32<script src="{{ "js/search.js" | relURL }}"></script>
33<script>
34    window.searchResultTitleTemplate = 'Found #PAGES_COUNT results in #TIME_SECONDS seconds';
35</script>
36
37{{ end }}

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

样式修改

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

 1// 搜索页面样式
 2input{
 3  width: 100%;
 4  height: 40px;
 5  border-radius: 6px;
 6  border: 2px solid lighten($color-accent, 10%);
 7  background: #f5f5f5;
 8  body.night & {
 9    background: #333;
10    border-color: 2px solid darken($color-accent, 10%);
11    color: #e6e6e6;
12  }
13}
14.search-result--title {
15  font-size: 1.25rem;
16  margin: 16px;
17  color: #555;
18  body.night & {
19    color: #e6e6e6;
20  }
21}
22// 样式用于搜索结果的整体容器
23.search-result--list {
24  margin: 0;
25  padding: 0;
26  list-style-type: none;
27}
28
29// 样式用于每个搜索结果的文章
30.search-result--list article {
31  display: flex;
32  flex-direction: row;
33  align-items: flex-start;
34  padding: 16px;
35  border-bottom: 1px solid #ddd;
36  transition: background-color 0.3s ease;
37  mark{
38    background-color: lighten($color-accent, 20%);
39    color: #fff;
40  }
41  &:hover {
42    background-color: #f9f9f9;
43  }
44}
45
46// 样式用于文章的标题
47.article-title {
48  font-size: 1.5rem;
49  margin: 0;
50  color: #333;
51  font-weight: bold;
52  transition: color 0.3s ease;
53
54  a {
55    text-decoration: none;
56    color: inherit;
57
58    &:hover {
59      color: #007bff;
60    }
61  }
62}
63
64// 样式用于文章预览部分
65.article-preview {
66  font-size: 1rem;
67  color: #666;
68  margin-top: 8px;
69  line-height: 1.5;
70  body.night & {
71    color: #e6e6e6;
72  }
73
74  mark {
75    background-color: lighten($color-accent, 20%);
76    color: #333;
77    font-weight: bold;
78  }
79}
80
81
82// 样式用于文章的详细信息部分
83.article-details {
84  flex: 1;
85}
86
87// 样式用于搜索结果标题
88.search-result--title {
89  font-size: 1.25rem;
90  margin-bottom: 16px;
91  color: #555;
92}

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

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

参考


最后修改于 2024-08-17