前言
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 '<': '<',
9 '>': '>',
10 '"': '"',
11 '…': '…'
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});
此搜索代码使用 ChatGPT
将 Hugo 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