给HUGO添加文章搜索功能

之前用 Hugo-theme-stack 主题,自带了搜索功能,我曾以为这种功能是 Hugo 默认就有,后来才知道,是外挂。
在更换 Bear cub 主题后,之前也没觉得这功能有多重要,但实际使用中发现,有时候记不起来一些事情,还得借助搜索才行。于是研究了下怎么外挂搜索。其实也很简单,只需四步。
在 content 目录下新建 search.md 页面
如果不需要放置在导航栏,内容填入默认信息即可,需要进导航栏的,效仿其他 md 文件设置。
1---
2title: "搜索"
3date: 2025-11-20T00:00:00+08:00
4type: "search"
5layout: "search"
6---
7在此搜索本博客文章。在 hugo 配置文件中添加 json 输出
比如我的hugo配置文件是 toml 格式,内容如下。
1[outputs]
2 home = ["HTML", "RSS", "JSON"] # 添加 JSON 输出
3
4[outputFormats.JSON]
5 baseName = "index"
6 mediaType = "application/json"在 layouts 目录下新建 /search/single.html 模板
模板内容可以参照其他模板修改,搜索功能可定制精确搜索或模糊搜索,大致内容如下。
1{{ define "main" }}
2<content>
3 <h1>{{ .Title }}</h1>
4 <div class="search-box">
5 <input
6 id="search-input"
7 class="search-input"
8 type="text"
9 placeholder="输入关键词搜索…"
10 autocomplete="off"
11 />
12 </div>
13
14 <ul id="results" class="search-results">
15 <li style="color:#666">请输入关键词搜索</li>
16 </ul>
17
18</content>
19
20<script>
21// ========== 工具函数 ==========
22function escapeHtml(str) {
23 if (!str) return "";
24 return str
25 .replace(/&/g, "&")
26 .replace(/</g, "<")
27 .replace(/>/g, ">");
28}
29
30// ========== 精确搜索 ==========
31function exactMatch(haystack, needle) {
32 if (!haystack || !needle) return false;
33 return haystack.toLowerCase().includes(needle.toLowerCase());
34}
35
36// ========== 渲染结果 ==========
37function renderResults(list) {
38 const resultsEl = document.getElementById("results");
39
40 if (!list || list.length === 0) {
41 resultsEl.innerHTML = '<li style="color:#666">未找到结果。</li>';
42 return;
43 }
44
45 const itemsHtml = list.map(item => {
46 const title = escapeHtml(item.title || "(无标题)");
47
48 // 外链或本地链接
49 const url = escapeHtml(item.link || item.url || "#");
50 const isExternal = !!item.link;
51
52 const linkAttrs = isExternal
53 ? ' target="_blank" rel="noopener noreferrer"'
54 : "";
55
56 // 摘要:取 summary 或 content 开头一段
57 const summaryRaw =
58 item.summary ||
59 (item.content ? item.content.slice(0, 200) + "…" : "");
60
61 const summary = escapeHtml(summaryRaw);
62
63 return `
64 <li>
65 <div class="sr-title-col">
66 <a class="sr-title" href="${url}"${linkAttrs}>${title}</a>
67 </div>
68
69 <div class="sr-snippet-col">
70 <div class="sr-snippet">${summary}</div>
71 </div>
72 </li>
73 `;
74 }).join("");
75
76 resultsEl.innerHTML = itemsHtml;
77}
78
79// ========== 加载 index.json 数据 ==========
80async function loadIndex() {
81 try {
82 const res = await fetch("/index.json");
83 return await res.json();
84 } catch (err) {
85 console.error("加载 index.json 失败:", err);
86 return [];
87 }
88}
89
90// ========== 主逻辑 ==========
91(async function () {
92 const data = await loadIndex();
93 const input = document.getElementById("search-input");
94
95 input.addEventListener("input", () => {
96 const q = input.value.trim();
97
98 if (!q) {
99 renderResults([]);
100 return;
101 }
102
103 // 精确搜索:title / content / tags 均可匹配
104 const result = data.filter(item =>
105 exactMatch(item.title, q) ||
106 exactMatch(item.content, q) ||
107 (item.tags || []).some(t => exactMatch(t, q))
108 );
109
110 renderResults(result);
111 });
112})();
113</script>
114{{ end }}在 layouts 中新建 index.json 模板
主要就是将整个博客文档内容输出到 index.json 文件,搜索时直接在该文件内搜索,可依照自己需求进行修改,比如只搜索标题、摘要、标签等信息。
1[
2{{- $pages := where .Site.RegularPages "Type" "not in" (slice "page" "something-you-want-to-exclude") -}}
3{{- $first := true -}}
4{{- range $i, $p := $pages -}}
5 {{- if not $first }},{{ end -}}
6 {
7 "title": {{ $p.Title | jsonify }},
8 "url": {{ $p.RelPermalink | absURL | jsonify }},
9 "date": {{ $p.Date.Format "2006-01-02" | jsonify }},
10 "summary": {{ with $p.Params.description }}{{ . | jsonify }}{{ else }}{{ $p.Summary | plainify | jsonify }}{{ end }},
11 "content": {{ $p.Plain | chomp | jsonify }},
12 "tags": {{ $p.Params.tags | jsonify }},
13 "categories": {{ $p.Params.categories | jsonify }}
14 }
15 {{- $first = false -}}
16{{- end -}}
17]其他 CSS 配置
如果需要自定义搜索页面的 CSS,可以直接在主题 CSS 或自定义 custom.css 文件中添加,或者写在前边 /search/single.html 模板中也可以。
1/* ====== 搜索框区域布局 ====== */
2.search-box {
3 max-width: 720px;
4 margin: 24px 0 32px;
5}
6
7.search-input {
8 width: 100%;
9 padding: 10px 14px;
10 font-size: 16px;
11
12 border: 1px solid var(--border);
13 border-radius: 8px;
14
15 background: var(--entry);
16 color: var(--primary);
17
18 outline: none;
19 transition: border-color .15s ease, box-shadow .15s ease;
20}
21
22.search-box {
23 max-width: 720px;
24 margin: 24px 0 32px;
25}
26
27.search-input {
28 width: 100%;
29 padding: 10px 14px;
30 font-size: 16px;
31
32 border: 1px solid rgba(150, 150, 150, 0.35);
33 border-radius: 8px;
34
35 background: var(--entry);
36 color: var(--primary);
37 outline: none;
38
39 transition: border-color .15s ease, box-shadow .15s ease;
40}
41
42.search-input:focus {
43 border-color: var(--text-highlight);
44 box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.20);
45}
46
47.search-results {
48 list-style: none;
49 padding: 0;
50 margin: 0;
51}
52
53.search-results li {
54 display: flex;
55 align-items: flex-start;
56 gap: 16px;
57 padding: 12px 0;
58 border-bottom: 1px solid rgba(0,0,0,0.04);
59}
60
61.search-results .sr-title-col {
62 flex: 0 0 40%;
63 min-width: 180px;
64 max-width: 420px;
65}
66
67.search-results .sr-title {
68 font-size: 1.02rem;
69 line-height: 1.3;
70 text-decoration: none;
71 color: var(--primary);
72}
73
74.search-results .sr-title[target="_blank"]::after {
75 content: " ↪";
76 font-weight: 400;
77}
78
79.search-results .sr-snippet-col {
80 flex: 1 1 60%;
81}
82
83.search-results .sr-snippet {
84 color: var(--secondary);
85 font-size: 0.95rem;
86 line-height: 1.5;
87
88 overflow: hidden;
89 display: -webkit-box;
90 -webkit-line-clamp: 3;
91 -webkit-box-orient: vertical;
92}
93
94@media (max-width: 500px) {
95 .search-results li {
96 flex-direction: column;
97 gap: 8px;
98 }
99
100 .search-results .sr-title-col {
101 max-width: none;
102 }
103}