「搜尋」功能上線啦!

2025/12/21

Categories: 技術/程式 Tags:

全文約 1098 字,預計閱讀 3 分鐘

因爲文章越來越多,只靠分類其實也不好找到文章,在「歷史上的今天」之後,弄了搜尋功能!以下分享怎麼在 Hugo 中做到。

  1. 做出搜尋頁面的基本頁

content/search/_index

---
title: "搜尋"
type: "search"
draft: false
---
  1. header 在導覽列做搜尋框框 themes/classic/layouts/partials/header.html
    <header>
      <nav>
        <ul>
          {{ $title := lower .Title }}
          {{ $section := lower .Section }}
          <li class="pull-left {{ if .IsHome }}current{{ end }}">
            <a href="{{ .Site.BaseURL }}">~/{{ lower .Site.Title}}</a>
          </li>
          {{ range .Site.Menus.main }}
          {{ $name := lower .Name }}
          <li class="pull-left {{ if eq $name $title }}current{{ else if eq $section $name }}current{{ else if eq $title (pluralize $name) }}current{{ end }}">
            <a href="{{ .URL }}">~/{{ lower .Name }}</a>
          </li>
          {{end}}

          {{ range .Site.Menus.feed }}
          {{ $name := lower .Name}}
          <li class="pull-right">
            <a href="{{ .URL }}">~/{{ lower .Name}}</a>
          </li>
          {{end}}
          <!-- 🔍 Search (final, no more edits needed) -->
          <li class="pull-right search-item">
            <form action="/search/" method="get" role="search">
              <label for="nav-search" class="visually-hidden">Search</label>
              <input
                id="nav-search"
                type="search"
                name="q"
                placeholder="搜尋…"
                autocomplete="off"
              >
            </form>
          </li>
        </ul>
        
      </nav>
    </header>
  1. json 抓出所有文章標題、內容等,來讓 js 可以搜尋

themes/classic/layouts/_default/index.json

{{- $pages := .Site.RegularPages -}}
[
{{- range $i, $p := $pages }}
  {{- if $i }},{{ end }}
  {
    "title": {{ $p.Title | jsonify }},
    "url": {{ $p.RelPermalink | jsonify }},
    "date": {{ $p.Date.Format "2006-01-02" | jsonify }},
    "content": {{ $p.Plain | jsonify }}
  }
{{- end }}
]
  1. html 格式 themes/classic/layouts/search/list.html
{{/* 引入網站的通用頁首 */}}
{{ partial "header.html" . }}

{{/* 為了讓 <mark> 標籤有螢光筆效果,可以加上一點點 CSS */}}
<style>
  mark {
    background-color: #fcf8e3; /* 一個柔和的黃色 */
    padding: 0.2em 0.1em;
    border-radius: 3px;
  }
  .search-excerpt {
    font-size: 0.9em;
    color: #666;
    margin-top: 0.3em;
  }
</style>

<main>
  <h1>{{ .Title }}</h1>
  
  <ul id="search-results">
    <li>請在上方搜尋框輸入關鍵字</li>
  </ul>
</main>

<script>
document.addEventListener('DOMContentLoaded', function() {
  const resultsEl = document.getElementById('search-results');
  const params = new URLSearchParams(window.location.search);
  const q = params.get('q')?.toLowerCase();

  // 如果 URL 中沒有搜尋關鍵字,就不執行任何動作
  if (!q) {
    return;
  }
  
  resultsEl.innerHTML = '<li>正在搜尋中...</li>';

  // --- 新增的輔助函式 ---

  /**
   * 為了安全地使用正規表示式,需要轉義特殊字元
   * @param {string} str - 使用者輸入的字串
   * @returns {string} - 轉義後的字串
   */
  function escapeRegExp(str) {
    return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  }

  /**
   * 在文字中高亮顯示關鍵字
   * @param {string} text - 要處理的文字 (標題或摘要)
   * @param {string} query - 要高亮的關鍵字
   * @returns {string} - 包含 <mark> 標籤的 HTML 字串
   */
  function highlight(text, query) {
    if (!text || !query) return text;
    const safeQuery = escapeRegExp(query);
    const regex = new RegExp(safeQuery, 'gi'); // 'g' for global, 'i' for case-insensitive
    return text.replace(regex, match => `<mark>${match}</mark>`);
  }

  /**
   * 從完整內容中建立摘要
   * @param {string} content - 文章的純文字內容
   * @param {string} query - 搜尋的關鍵字
   * @param {number} length - 摘要的目標長度
   * @returns {string} - 包含關鍵字的摘要字串
   */
  function createExcerpt(content, query, length = 150) {
    if (!content) return '';
    const index = content.toLowerCase().indexOf(query);
    if (index === -1) {
      // 如果內容中找不到關鍵字 (可能是在標題中找到的),就直接從頭截取
      return content.substring(0, length) + (content.length > length ? '...' : '');
    }

    const start = Math.max(0, index - Math.floor(length / 2));
    let excerpt = content.substring(start, start + length);
    
    // 加上省略號
    if (start > 0) excerpt = '... ' + excerpt;
    if (start + length < content.length) excerpt = excerpt + ' ...';

    return excerpt;
  }


  fetch('{{ "/index.json" | relURL }}')
    .then(res => {
      if (!res.ok) throw new Error('Network response was not ok');
      return res.json();
    })
    .then(pages => {
      const results = pages.filter(p =>
        (p.title && p.title.toLowerCase().includes(q)) ||
        (p.content && p.content.toLowerCase().includes(q))
      );

      if (results.length === 0) {
        resultsEl.innerHTML = '<li>找不到結果</li>';
      } else {
        // --- 這是修改的核心部分 ---
        resultsEl.innerHTML = results.map(r => {
          // 1. 建立摘要
          const excerpt = createExcerpt(r.content, q);

          // 2. 高亮標題和摘要
          const highlightedTitle = highlight(r.title, q);
          const highlightedExcerpt = highlight(excerpt, q);

          // 3. 組合最終的 HTML
          return `
            <li style="margin-bottom:1.5em;">
              <a href="${r.url}" style="font-weight:bold; font-size: 1.2em;">${highlightedTitle}</a><br>
              <small style="color:gray;">${r.date}</small>
              <p class="search-excerpt">${highlightedExcerpt}</p>
            </li>
          `;
        }).join('');
      }
    })
    .catch(err => {
      console.error('搜尋時發生錯誤:', err);
      resultsEl.innerHTML = '<li>搜尋發生錯誤,請稍後再試</li>';
    });
});
</script>

{{/* 引入網站的通用頁尾 */}}
{{ partial "footer.html" . }}
>> Home