Hugo Eureka 主题增加搜索功能

2024-05-25
2024-05-25
5 min read
Hits

  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 了~φ(゜▽゜*)♪

Avatar

Hui.Ke

❤ Cyber Security | Safety is a priority.