Hugo Eureka 主题增加搜索功能
Hugo Eureka 这个主题的原作已经停更好几年了,曾在 GitHub Issue 里回复的将会增加搜索功能也是遥遥无期了😔但是随着博主和博客年龄的增加,内容是越来越多,博客需要一个搜索功能已经是刻不容缓了……
于是就有了本文,详细记录了博主针对 Hugo Eureka 主题增加搜索功能所修改的主题源代码,供同样使用该主题或其他 Hugo 主题的博主们参考。
博主在参考了网上的各类教程及参阅了 Hugo 官方文档 后,决定选用 hugofastsearch 作为博客的搜索引擎。
此引擎是开源的,可能因为博客内容太多,实际测试下来搜索不是很流畅,甚至可以说是略微有点卡,每次至少都需要一到两秒才能展现出搜索结果,体验效果可能不如下面商业化的搜索引擎。如果对搜索效率有比较高的要求的,可以考虑采用商业化方案。
/config/_default/config.yaml
首先需要修改的是 Hugo 配置文件,也可以理解为主题配置文件。因为我们的搜索是针对 Hugo 生成的 json 文件,所以需要在配置文件中声明我们需要生成 json 格式的文件。需要在该配置文件最后加入以下代码
outputs:
home:
- html
- rss
- json # 告诉 Hugo 需要生成 json
/layouts/partials/header.html(没有该文件则新建)
然后我们在博客合适的位置添加搜索框,博主是新增在了 header 的右上方,切换深色模式的左边。所以需要在/layouts/partials/header.html
的合适位置添加如下代码
<div id="fastSearch">
<div class="search-wrapper"><input id="searchInput" autocomplete="off" placeholder="搜索较慢,耐心等待~o(*^▽^*)┛……【黑客朋友们别试了没注入点ヾ( ̄▽ ̄)Bye~Bye~】" tabindex="0">
<i class="fa-solid fa-magnifying-glass"></i>
</div>
<ul id="searchResults"></ul>
</div>
<script defer src="https://lib.baomitu.com/fuse.js/7.0.0/fuse.min.js"></script> # 引入 75CDN 的 fuse.min.js
<script defer src="/js/fastsearch.js"></script> # 引入马上要新增的 fastsearch.js
当然我们对搜索框和搜索结果的样式也需要确定,所以在该文件的最上方再加入 CSS 样式如下
<style type="text/css" media="screen">
#fastSearch {
width: 100%;
margin: 0 15px;
max-width: 235px;
position: relative;
transition: max-width .2s ease-in-out;
}
#fastSearch.active {
max-width: 550px;
}
#fastSearch .search-wrapper {
position: relative;
}
#fastSearch .search-wrapper .svg-inline--fa {
top: 50%;
left: 5px;
color: #555;
font-size: 14px;
position: absolute;
transform: translateY(-50%);
}
#fastSearch .search-wrapper input {
width: 100%;
outline: none;
font-size: 13px;
border-radius: 3px;
padding: 3px 10px 3px 25px;
border: 1px solid #e5e7eb;
background-color: transparent;
}
#searchResults {
left: 0;
top: 100%;
opacity: 0;
width: 100%;
z-index: 999;
overflow-y: auto;
position: absolute;
visibility: hidden;
border-style: solid;
border-color: #e5e7eb;
background-color: #FFF;
border-width: 0 1px 1px 1px;
max-height: calc(100vh - 65px);
}
#searchResults>li {
list-style: none;
padding: 10px 15px;
border-bottom: 1px dotted #000;
}
#searchResults li>a {
display: block;
}
#searchResults .title {
font-size: 14px;
font-weight: bold;
}
#searchResults .meta {
color: #777;
font-size: 13px;
}
#searchResults .description {
font-size: 12px;
margin: 10px 0 0;
font-style: italic;
}
#searchResults>li:last-child {
border: 0;
}
#searchResults .not-found {
font-size: 13px;
padding: 10px 15px;
}
#fastSearch.active #searchResults {
opacity: 1;
visibility: visible;
}
#fastSearch.active .search-wrapper .svg-inline--fa {
color: var(--color-eureka);
}
#fastSearch.active input {
border-radius: 3px 3px 0 0;
}
.dark #fastSearch input,
.dark #fastSearch #searchResults {
border-color: var(--color-tertiary-bg);
}
.dark #fastSearch #searchResults {
background-color: var(--color-secondary-bg);
}
.dark #searchResults>li {
border-color: var(--color-tertiary-bg);
}
@media screen and (max-width: 768px) {
#fastSearch {
width: 100%;
max-width: 100%;
margin: 10px 0 0 0;
}
#fastSearch.active {
max-width: 100%;
}
#fastSearch.active #searchResults {
max-height: 320px;
}
}
</style>
/static/js/fastsearch.js(新增该文件)
新增我们的核心fashsearch.js
文件如下
let fuse; // 搜索引擎实例
let searchVisible = false; // 搜索框是否可见
let firstRun = true; // 用于标记是否第一次运行,以便延迟加载json数据
const list = document.getElementById('searchResults'); // 目标<ul>元素
let first, last; // 搜索结果的第一个和最后一个子元素
const maininput = document.getElementById('searchInput'); // 搜索输入框
let resultsAvailable = false; // 是否有搜索结果
// 主键盘事件监听器
document.addEventListener('keydown', (event) => {
// CMD-/ 显示/隐藏搜索框
if (event.metaKey && event.key === '/') {
// 如果是第一次调用搜索,加载json搜索索引
if (firstRun) {
loadSearch(); // 加载json数据并构建fuse.js搜索索引
firstRun = false; // 确保不再重复加载
}
// 切换搜索框的可见性
toggleSearchVisibility();
}
// 允许通过ESC键关闭搜索框
if (event.key === 'Escape') {
if (searchVisible) {
toggleSearchVisibility();
}
}
// 向下箭头键
if (event.key === 'ArrowDown') {
if (searchVisible && resultsAvailable) {
event.preventDefault(); // 阻止窗口滚动
if (document.activeElement === maininput) {
first.focus(); // 如果当前聚焦在输入框,聚焦到第一个搜索结果
} else if (document.activeElement === last) {
last.focus(); // 如果在最后一个结果,保持不变
} else {
document.activeElement.parentElement.nextElementSibling.querySelector('a').focus(); // 否则聚焦到下一个搜索结果的<a>元素
}
}
}
// 向上箭头键
if (event.key === 'ArrowUp') {
if (searchVisible && resultsAvailable) {
event.preventDefault(); // 阻止窗口滚动
if (document.activeElement === maininput) {
maininput.focus(); // 如果当前在输入框,不做任何操作
} else if (document.activeElement === first) {
maininput.focus(); // 如果在第一个结果,返回输入框
} else {
document.activeElement.parentElement.previousElementSibling.querySelector('a').focus(); // 否则聚焦到上一个搜索结果的<a>元素
}
}
}
});
// 每次输入一个字符时执行搜索
maininput.addEventListener('keyup', (e) => {
executeSearch(e.target.value);
});
// 切换搜索框的可见性
const toggleSearchVisibility = () => {
const fastSearch = document.getElementById("fastSearch");
if (!searchVisible) {
fastSearch.classList.add("active"); // 添加active类名,显示搜索框
maininput.focus(); // 聚焦到输入框,便于直接输入
searchVisible = true; // 标记搜索框可见
} else {
fastSearch.classList.remove("active"); // 移除active类名,隐藏搜索框
document.activeElement.blur(); // 移除搜索框的聚焦
searchVisible = false; // 标记搜索框不可见
}
};
// 当搜索输入框获得焦点时切换搜索框的可见性,并加载搜索索引
maininput.addEventListener('focus', () => {
if (firstRun) {
loadSearch(); // 加载json数据并构建fuse.js搜索索引
firstRun = false; // 确保不再重复加载
}
if (!searchVisible) {
toggleSearchVisibility();
}
});
// 监听点击事件,判断是否点击了#fastSearch之外的区域
document.addEventListener('click', (event) => {
const fastSearch = document.getElementById("fastSearch");
if (!fastSearch.contains(event.target) && searchVisible) {
toggleSearchVisibility();
}
});
// 使用Fetch API获取json数据
const fetchJSONFile = (path, callback) => {
fetch(path)
.then(response => response.json())
.then(data => {
if (callback) callback(data);
})
.catch(error => console.error('Error fetching JSON:', error));
};
// 加载搜索索引,只在第一次调用搜索框时执行
const loadSearch = () => {
fetchJSONFile('/index.json', (data) => {
const options = { // fuse.js配置选项
shouldSort: true,
location: 0,
distance: 100,
threshold: 0.4,
ignoreLocation: true,
minMatchCharLength: 2,
keys: ['title', 'permalink', 'content']
};
fuse = new Fuse(data, options); // 从json数据构建索引
});
};
// 执行搜索,每次在搜索框输入字符时调用
const executeSearch = (term) => {
const results = fuse.search(term); // 使用fuse.js运行查询
let searchitems = ''; // 存放结果的HTML
if (results.length === 0 && term.trim() !== '') { // 如果没有结果且输入框不为空
searchitems = '<p class="not-found">(⊙o⊙)?等半天你跟我说没搜到?(╯‵□′)╯︵┻━┻!换个关键词试试呢 (ಥ _ ಥ)</p>';
resultsAvailable = false;
} else { // 构建HTML
results.slice(0, 50).forEach(result => {
searchitems += `
<li>
<a href="${result.item.permalink}" tabindex="0">
<div class="title">${result.item.title}</div>
<div class="meta">
<span class="section">${result.item.section}</span> -
<span class="date">${result.item.date}</span>
</div>
<div class="description">${result.item.description}</div>
</a>
</li>`;
});
resultsAvailable = results.length > 0;
}
list.innerHTML = searchitems;
if (results.length > 0) {
first = list.querySelector('a'); // 第一个结果的<a>元素
last = list.lastElementChild.querySelector('a'); // 最后一个结果的<a>元素
}
};
/layouts/_default/index.json(新增该文件)
最后新增/layouts/_default/index.json
用于确定搜索的范围、格式等,同时修复了搜索结果重复的问题(非 Eureka 主题可能需要自己适配)
{{- $.Scratch.Add "index" slice -}}
{{- $section := $.Site.GetPage "section" .Section -}}
{{- range where .Site.RegularPages "Type" "not in" (slice "page" "json") -}}
{{- if or (and (.IsDescendant $section) (and (not .Draft) (not .Params.private))) $section.IsHome -}}
{{- $.Scratch.Add "index" (dict
"date" (time.Format "Monday, Jan 2, 2006" .Date)
"description" .Summary
"permalink" .Permalink
"title" .Title
"section" .Section
"tags" .Params.Tags
"categories" .Params.Categories
"author" .Params.authors
"content" .Content
)
-}}
{{- end -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}
大功告成!可以享用拥有搜索框的 Hugo Eureka 了~φ(゜▽゜*)♪