<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>靜態網站 on Tarragon</title><link>https://tarrragon.github.io/blog/tags/%E9%9D%9C%E6%85%8B%E7%B6%B2%E7%AB%99/</link><description>Recent content in 靜態網站 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 24 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/%E9%9D%9C%E6%85%8B%E7%B6%B2%E7%AB%99/index.xml" rel="self" type="application/rss+xml"/><item><title>Fuse.js / MiniSearch：客戶端載入索引的搜尋方案</title><link>https://tarrragon.github.io/blog/posts/fuse.js-/-minisearch%E5%AE%A2%E6%88%B6%E7%AB%AF%E8%BC%89%E5%85%A5%E7%B4%A2%E5%BC%95%E7%9A%84%E6%90%9C%E5%B0%8B%E6%96%B9%E6%A1%88/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/posts/fuse.js-/-minisearch%E5%AE%A2%E6%88%B6%E7%AB%AF%E8%BC%89%E5%85%A5%E7%B4%A2%E5%BC%95%E7%9A%84%E6%90%9C%E5%B0%8B%E6%96%B9%E6%A1%88/</guid><description>&lt;h2 id="客戶端搜尋的問題空間">客戶端搜尋的問題空間&lt;/h2>
&lt;p>靜態站搜尋必須在 build 時或 client runtime 完成。選擇&lt;strong>整包序列化 + client 載入&lt;/strong>這條路時，核心設計軸是：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>設計軸&lt;/th>
 &lt;th>意義&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>索引內容&lt;/td>
 &lt;td>由作者在 build time 明確決定要搜哪些欄位、哪些 section&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>索引結構&lt;/td>
 &lt;td>扁平 JSON 陣列，每筆一個頁面，欄位直寫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>runtime 處理&lt;/td>
 &lt;td>在瀏覽器內建索引、記憶體內匹配&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Fuse.js 與 MiniSearch 是這條路上的兩個主要實作。差異在匹配策略（fuzzy vs 全文），共享的是「一包索引載入瀏覽器、之後所有查詢不再出站」這個骨幹。&lt;/p>
&lt;hr>
&lt;h2 id="核心設計build-時序列化--runtime-in-memory">核心設計：build 時序列化 + runtime in-memory&lt;/h2>
&lt;p>&lt;strong>商業邏輯&lt;/strong>：把搜尋放在 client runtime 的關鍵是&lt;strong>搜尋不再跨網路來回&lt;/strong>。第一次載入索引之後，每次打字的匹配都在使用者的 RAM 內完成，不受網路延遲影響、不受後端服務狀態影響、甚至不需要網路連線。&lt;/p>
&lt;p>此設計把「索引存放」從伺服端或 CDN 移到了訪客自己的瀏覽器，換得 runtime 的完全獨立。&lt;/p>
&lt;p>&lt;strong>CASE&lt;/strong>：整個流程兩個時點：&lt;/p>
&lt;p>&lt;strong>Build time（Hugo 階段）&lt;/strong>：Hugo 用 custom output format 產出一份 JSON，每筆一個頁面。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;WAF&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;url&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;/backend/knowledge-cards/waf/&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;說明 WAF 如何在入口層過濾攻擊&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;完整內文…&amp;#34;&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Runtime（瀏覽器階段）&lt;/strong>：使用者打開搜尋頁，browser &lt;code>fetch&lt;/code> JSON → library 在 memory 中建索引 → 使用者打字 → library 匹配 → 結果渲染。&lt;/p>
&lt;p>第一次 fetch + build index 通常 100-500ms；之後的每次查詢在 memory 內匹配，一般 &amp;lt;10ms。&lt;/p>
&lt;hr>
&lt;h2 id="架構選擇作者定義索引內容">架構選擇：作者定義索引內容&lt;/h2>
&lt;p>&lt;strong>商業邏輯&lt;/strong>：索引的範圍與欄位由誰決定，這件事決定了搜尋結果的邊界。Fuse.js / MiniSearch 採「作者顯式宣告」的路線 — Hugo template 明確列出哪些 section 進索引、每筆要哪些欄位。&lt;/p>
&lt;p>這個選擇讓搜尋結果成為&lt;strong>作者設計決策的產物&lt;/strong>：想排除 work-log 類別就不列入 range；想讓 tag 也可搜就加一個 &lt;code>tags&lt;/code> 欄位到 JSON；想降低索引大小就只存 &lt;code>title + description&lt;/code> 而不存 &lt;code>content&lt;/code>。&lt;/p>
&lt;p>&lt;strong>CASE&lt;/strong>：&lt;code>layouts/index.json&lt;/code> 決定 JSON 內容：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go-html-template" data-lang="go-html-template">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="cp">{{-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">$pages&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">:=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">where&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">.Site.RegularPages&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;Section&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;in&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="k">slice&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;posts&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;backend&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;go&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">&amp;#34;python&amp;#34;&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">-}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">[
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="cp">{{-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">range&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">$i&lt;/span>&lt;span class="o">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">$p&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">:=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">$pages&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">-}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="cp">{{-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">if&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">$i&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span>,&lt;span class="cp">{{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">end&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> { &amp;#34;title&amp;#34;: &lt;span class="cp">{{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">.Title&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">|&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">jsonify&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span>,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &amp;#34;url&amp;#34;: &lt;span class="cp">{{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">.RelPermalink&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">|&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">jsonify&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span>,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &amp;#34;description&amp;#34;: &lt;span class="cp">{{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">.Description&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">|&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">jsonify&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span>,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &amp;#34;content&amp;#34;: &lt;span class="cp">{{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="na">.Plain&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">|&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nx">jsonify&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">}}&lt;/span> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="cp">{{-&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">end&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="cp">-}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>配套在 &lt;code>hugo.toml&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-toml" data-lang="toml">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">[&lt;/span>&lt;span class="nx">outputs&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">home&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;HTML&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;RSS&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;JSON&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">[&lt;/span>&lt;span class="nx">outputFormats&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JSON&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">mediaType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s2">&amp;#34;application/json&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">baseName&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s2">&amp;#34;index&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nx">isPlainText&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">true&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Build 後 &lt;code>public/index.json&lt;/code> 就是整站可搜內容的權威來源。&lt;/p>
&lt;hr>
&lt;h2 id="整合步驟以-fusejs-為例">整合步驟（以 Fuse.js 為例）&lt;/h2>
&lt;h3 id="1-hugo-產生-indexjson">1. Hugo 產生 index.json&lt;/h3>
&lt;p>&lt;strong>核心動作&lt;/strong>：設定 custom output format，寫 template 輸出 JSON。&lt;/p></description><content:encoded><![CDATA[<h2 id="客戶端搜尋的問題空間">客戶端搜尋的問題空間</h2>
<p>靜態站搜尋必須在 build 時或 client runtime 完成。選擇<strong>整包序列化 + client 載入</strong>這條路時，核心設計軸是：</p>
<table>
  <thead>
      <tr>
          <th>設計軸</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>索引內容</td>
          <td>由作者在 build time 明確決定要搜哪些欄位、哪些 section</td>
      </tr>
      <tr>
          <td>索引結構</td>
          <td>扁平 JSON 陣列，每筆一個頁面，欄位直寫</td>
      </tr>
      <tr>
          <td>runtime 處理</td>
          <td>在瀏覽器內建索引、記憶體內匹配</td>
      </tr>
  </tbody>
</table>
<p>Fuse.js 與 MiniSearch 是這條路上的兩個主要實作。差異在匹配策略（fuzzy vs 全文），共享的是「一包索引載入瀏覽器、之後所有查詢不再出站」這個骨幹。</p>
<hr>
<h2 id="核心設計build-時序列化--runtime-in-memory">核心設計：build 時序列化 + runtime in-memory</h2>
<p><strong>商業邏輯</strong>：把搜尋放在 client runtime 的關鍵是<strong>搜尋不再跨網路來回</strong>。第一次載入索引之後，每次打字的匹配都在使用者的 RAM 內完成，不受網路延遲影響、不受後端服務狀態影響、甚至不需要網路連線。</p>
<p>此設計把「索引存放」從伺服端或 CDN 移到了訪客自己的瀏覽器，換得 runtime 的完全獨立。</p>
<p><strong>CASE</strong>：整個流程兩個時點：</p>
<p><strong>Build time（Hugo 階段）</strong>：Hugo 用 custom output format 產出一份 JSON，每筆一個頁面。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span> <span class="nt">&#34;title&#34;</span><span class="p">:</span> <span class="s2">&#34;WAF&#34;</span><span class="p">,</span> <span class="nt">&#34;url&#34;</span><span class="p">:</span> <span class="s2">&#34;/backend/knowledge-cards/waf/&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;description&#34;</span><span class="p">:</span> <span class="s2">&#34;說明 WAF 如何在入口層過濾攻擊&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;content&#34;</span><span class="p">:</span> <span class="s2">&#34;完整內文…&#34;</span> <span class="p">}</span></span></span></code></pre></div><p><strong>Runtime（瀏覽器階段）</strong>：使用者打開搜尋頁，browser <code>fetch</code> JSON → library 在 memory 中建索引 → 使用者打字 → library 匹配 → 結果渲染。</p>
<p>第一次 fetch + build index 通常 100-500ms；之後的每次查詢在 memory 內匹配，一般 &lt;10ms。</p>
<hr>
<h2 id="架構選擇作者定義索引內容">架構選擇：作者定義索引內容</h2>
<p><strong>商業邏輯</strong>：索引的範圍與欄位由誰決定，這件事決定了搜尋結果的邊界。Fuse.js / MiniSearch 採「作者顯式宣告」的路線 — Hugo template 明確列出哪些 section 進索引、每筆要哪些欄位。</p>
<p>這個選擇讓搜尋結果成為<strong>作者設計決策的產物</strong>：想排除 work-log 類別就不列入 range；想讓 tag 也可搜就加一個 <code>tags</code> 欄位到 JSON；想降低索引大小就只存 <code>title + description</code> 而不存 <code>content</code>。</p>
<p><strong>CASE</strong>：<code>layouts/index.json</code> 決定 JSON 內容：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go-html-template" data-lang="go-html-template"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="cp">{{-</span><span class="w"> </span><span class="nx">$pages</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">where</span><span class="w"> </span><span class="na">.Site.RegularPages</span><span class="w"> </span><span class="s">&#34;Section&#34;</span><span class="w"> </span><span class="s">&#34;in&#34;</span><span class="w"> </span><span class="o">(</span><span class="k">slice</span><span class="w"> </span><span class="s">&#34;posts&#34;</span><span class="w"> </span><span class="s">&#34;backend&#34;</span><span class="w"> </span><span class="s">&#34;go&#34;</span><span class="w"> </span><span class="s">&#34;python&#34;</span><span class="o">)</span><span class="w"> </span><span class="cp">-}}</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">[
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="cp">{{-</span><span class="w"> </span><span class="k">range</span><span class="w"> </span><span class="nx">$i</span><span class="o">,</span><span class="w"> </span><span class="nx">$p</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">$pages</span><span class="w"> </span><span class="cp">-}}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="cp">{{-</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="nx">$i</span><span class="w"> </span><span class="cp">}}</span>,<span class="cp">{{</span><span class="w"> </span><span class="k">end</span><span class="w"> </span><span class="cp">}}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  { &#34;title&#34;: <span class="cp">{{</span><span class="w"> </span><span class="na">.Title</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="nx">jsonify</span><span class="w"> </span><span class="cp">}}</span>,
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    &#34;url&#34;: <span class="cp">{{</span><span class="w"> </span><span class="na">.RelPermalink</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="nx">jsonify</span><span class="w"> </span><span class="cp">}}</span>,
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    &#34;description&#34;: <span class="cp">{{</span><span class="w"> </span><span class="na">.Description</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="nx">jsonify</span><span class="w"> </span><span class="cp">}}</span>,
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    &#34;content&#34;: <span class="cp">{{</span><span class="w"> </span><span class="na">.Plain</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="nx">jsonify</span><span class="w"> </span><span class="cp">}}</span> }
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="cp">{{-</span><span class="w"> </span><span class="k">end</span><span class="w"> </span><span class="cp">-}}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">]</span></span></code></pre></div><p>配套在 <code>hugo.toml</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">[</span><span class="nx">outputs</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">home</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;HTML&#34;</span><span class="p">,</span> <span class="s2">&#34;RSS&#34;</span><span class="p">,</span> <span class="s2">&#34;JSON&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">[</span><span class="nx">outputFormats</span><span class="p">.</span><span class="nx">JSON</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">mediaType</span> <span class="p">=</span> <span class="s2">&#34;application/json&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">baseName</span> <span class="p">=</span> <span class="s2">&#34;index&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="nx">isPlainText</span> <span class="p">=</span> <span class="kc">true</span></span></span></code></pre></div><p>Build 後 <code>public/index.json</code> 就是整站可搜內容的權威來源。</p>
<hr>
<h2 id="整合步驟以-fusejs-為例">整合步驟（以 Fuse.js 為例）</h2>
<h3 id="1-hugo-產生-indexjson">1. Hugo 產生 index.json</h3>
<p><strong>核心動作</strong>：設定 custom output format，寫 template 輸出 JSON。</p>
<p>見上方「架構選擇」段落的 <code>hugo.toml</code> 與 <code>layouts/index.json</code>。</p>
<h3 id="2-搜尋頁載入-library--index">2. 搜尋頁載入 library + index</h3>
<p><strong>核心動作</strong>：前端一個 <code>&lt;input&gt;</code>、一段 script，完成 fetch + 建索引 + 匹配 + 渲染。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln"> 1</span><span class="cl">{{ define &#34;main&#34; }}
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="p">&lt;</span><span class="nt">input</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;q&#34;</span> <span class="na">placeholder</span><span class="o">=</span><span class="s">&#34;搜尋…&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">&lt;</span><span class="nt">ul</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;results&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">ul</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;https://cdn.jsdelivr.net/npm/fuse.js@7/dist/fuse.min.js&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nx">fetch</span><span class="p">(</span><span class="s1">&#39;{{ &#34;index.json&#34; | relURL }}&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">r</span><span class="p">.</span><span class="nx">json</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">.</span><span class="nx">then</span><span class="p">(</span><span class="nx">data</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="kr">const</span> <span class="nx">fuse</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Fuse</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">keys</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;title&#39;</span><span class="p">,</span> <span class="s1">&#39;description&#39;</span><span class="p">,</span> <span class="s1">&#39;content&#39;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">threshold</span><span class="o">:</span> <span class="mf">0.3</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">includeMatches</span><span class="o">:</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">      <span class="p">});</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">      <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="s1">&#39;q&#39;</span><span class="p">).</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="nx">e</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="nx">fuse</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">value</span><span class="p">).</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">20</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="s1">&#39;results&#39;</span><span class="p">).</span><span class="nx">innerHTML</span> <span class="o">=</span> <span class="nx">results</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">          <span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="sb">`&lt;li&gt;&lt;a href=&#34;</span><span class="si">${</span><span class="nx">r</span><span class="p">.</span><span class="nx">item</span><span class="p">.</span><span class="nx">url</span><span class="si">}</span><span class="sb">&#34;&gt;</span><span class="si">${</span><span class="nx">r</span><span class="p">.</span><span class="nx">item</span><span class="p">.</span><span class="nx">title</span><span class="si">}</span><span class="sb">&lt;/a&gt;
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="sb">                     &lt;p&gt;</span><span class="si">${</span><span class="nx">r</span><span class="p">.</span><span class="nx">item</span><span class="p">.</span><span class="nx">description</span><span class="si">}</span><span class="sb">&lt;/p&gt;&lt;/li&gt;`</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">          <span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="s1">&#39;&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">      <span class="p">});</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">{{ end }}</span></span></code></pre></div><p>30 行內可以跑起來。</p>
<h3 id="3-minisearch-的-api-差異">3. MiniSearch 的 API 差異</h3>
<p><strong>核心動作</strong>：選 MiniSearch 時，API 形狀相近、配置項不同。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">const</span> <span class="nx">mini</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MiniSearch</span><span class="p">({</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nx">fields</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;title&#39;</span><span class="p">,</span> <span class="s1">&#39;description&#39;</span><span class="p">,</span> <span class="s1">&#39;content&#39;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">storeFields</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;title&#39;</span><span class="p">,</span> <span class="s1">&#39;url&#39;</span><span class="p">,</span> <span class="s1">&#39;description&#39;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nx">searchOptions</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">boost</span><span class="o">:</span> <span class="p">{</span> <span class="nx">title</span><span class="o">:</span> <span class="mi">3</span><span class="p">,</span> <span class="nx">description</span><span class="o">:</span> <span class="mi">2</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">prefix</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">fuzzy</span><span class="o">:</span> <span class="mf">0.2</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nx">mini</span><span class="p">.</span><span class="nx">addAll</span><span class="p">(</span><span class="nx">data</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kr">const</span> <span class="nx">results</span> <span class="o">=</span> <span class="nx">mini</span><span class="p">.</span><span class="nx">search</span><span class="p">(</span><span class="nx">query</span><span class="p">);</span></span></span></code></pre></div><ul>
<li><code>boost</code> 決定各欄位命中的權重：title 命中比 content 命中重 3 倍</li>
<li><code>prefix: true</code> 讓 &ldquo;WA&rdquo; 命中 &ldquo;WAF&rdquo;</li>
<li><code>fuzzy: 0.2</code> 開啟 approximate match，容錯程度可調</li>
</ul>
<hr>
<h2 id="方案的內在屬性">方案的內在屬性</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Fuse.js / MiniSearch 的特徵</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>覆蓋完整性</td>
          <td>由作者顯式宣告索引範圍 — 要搜什麼完全可控</td>
      </tr>
      <tr>
          <td>可逆性</td>
          <td>移除只需刪除 <code>index.json</code> output、搜尋頁、script reference</td>
      </tr>
      <tr>
          <td>維護成本</td>
          <td>無額外 build step；索引 schema 改動要同步改 template 與 client code</td>
      </tr>
      <tr>
          <td>可理解性</td>
          <td>library 原始碼規模可讀（Fuse.js ~10KB、MiniSearch ~6KB gzipped），API 面積小</td>
      </tr>
      <tr>
          <td>依賴前提</td>
          <td>要求 Hugo 支援 custom output format（所有版本皆支援）；要求 client 能跑 JS</td>
      </tr>
      <tr>
          <td>擴展性</td>
          <td>單次查詢發生在 memory 內 — 查詢效能不受網路或站規模影響；索引載入是首次一次性</td>
      </tr>
  </tbody>
</table>
<p><strong>與 runtime 獨立相關的延伸特徵</strong>：</p>
<ul>
<li><strong>離線可用</strong>：索引載入後所有查詢不需要網路；PWA 加 Cache API 讓索引也能離線快取</li>
<li><strong>自託管</strong>：索引資料不離開你的網域；敏感內容或私有文件特別適合</li>
<li><strong>隱私</strong>：訪客查詢字串不會送到任何第三方服務</li>
</ul>
<p><strong>與 UI 獨立相關的延伸特徵</strong>：</p>
<ul>
<li><strong>樣式與互動 100% 可控</strong>：搜尋框位置、結果卡排版、modal 與否、鍵盤操作 — 每一項都由作者決定</li>
<li><strong>與 theme 緊密整合</strong>：UI 可以直接套用站上其他元件的 CSS variable 與設計 token</li>
</ul>
<hr>
<h2 id="兩家-library-的定位差異">兩家 library 的定位差異</h2>
<p>Fuse.js 與 MiniSearch 共享核心架構，<strong>設計重心不同</strong>：</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>Fuse.js</th>
          <th>MiniSearch</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>匹配策略</td>
          <td>以 fuzzy / approximate match 為主軸</td>
          <td>傳統全文檢索（詞項匹配 + 評分）</td>
      </tr>
      <tr>
          <td>擅長情境</td>
          <td>錯字容錯、近似詞匹配 — 搜 &ldquo;kubernates&rdquo; 命中 &ldquo;kubernetes&rdquo;</td>
          <td>精確詞匹配、field boosting、prefix 搜尋</td>
      </tr>
      <tr>
          <td>Gzipped 大小</td>
          <td>~10KB</td>
          <td>~6KB</td>
      </tr>
  </tbody>
</table>
<p>兩者的 API 形狀相近，切換成本低。決定用哪一個，主要看<strong>希望怎麼對待 query</strong>：可能有錯字的模糊輸入偏向 Fuse.js，結構化的技術關鍵字偏向 MiniSearch。</p>
<hr>
<h2 id="運作特徵">運作特徵</h2>
<h3 id="index-在首次載入">Index 在首次載入</h3>
<p><strong>核心定義</strong>：索引是一份 JSON，使用者打開搜尋頁時由瀏覽器一次性 fetch。</p>
<p><strong>含義</strong>：首次延遲 = 下載 JSON + library 建索引。常見做法是在 <code>DOMContentLoaded</code> 就 preload JSON，讓使用者看到搜尋框時索引已建好、第一次打字即可查詢。</p>
<p><strong>規模適合度</strong>：幾百到一兩千頁、索引 JSON 幾百 KB 到 1-2MB 的站，體驗最穩定。索引大小由作者在 Hugo template 內決定 — 只索引 title + description 可以把 size 壓到很小。</p>
<h3 id="索引範圍由作者決定">索引範圍由作者決定</h3>
<p><strong>核心定義</strong>：Hugo template 明確列出要進索引的 section 與欄位。</p>
<p><strong>含義</strong>：搜尋結果的邊界是作者設計決策。增減 section、增減 field、調整儲存策略，都在 template 這一層直接生效。</p>
<h3 id="tokenization-依-library-而異">Tokenization 依 library 而異</h3>
<p><strong>核心定義</strong>：Fuse.js 採 character-level 匹配；MiniSearch 預設用空白分詞。</p>
<p><strong>含義</strong>：</p>
<ul>
<li>Fuse.js 對中文天然能搜，不需要斷詞設定</li>
<li>MiniSearch 對中文需要傳自訂 <code>tokenize</code> function，可以一個字一 token，或接 Intl.Segmenter 做詞界切分</li>
</ul>
<h3 id="ui-由作者自己寫">UI 由作者自己寫</h3>
<p><strong>核心定義</strong>：library 只提供搜尋 API，不提供視覺組件。</p>
<p><strong>含義</strong>：排版、鍵盤操作、focus management、ARIA 這些 UI 層責任由作者顯式實作。收穫是與 theme 完全融合的客製體驗。</p>
<hr>
<h2 id="適合的場景">適合的場景</h2>
<ul>
<li>站的規模穩定在幾百到一兩千頁</li>
<li>UI 需要深度客製、與 theme 風格緊密整合</li>
<li>想要最單純的 build pipeline（無 post-build step、無額外工具）</li>
<li>內容敏感、希望索引不離開自家網域</li>
<li>希望搜尋在離線狀態仍可用</li>
<li>需要 fuzzy match（Fuse.js）或精細 field boost + prefix（MiniSearch）</li>
</ul>
]]></content:encoded></item><item><title>Pagefind：靜態站搜尋的 build-time 索引方案</title><link>https://tarrragon.github.io/blog/posts/pagefind%E9%9D%9C%E6%85%8B%E7%AB%99%E6%90%9C%E5%B0%8B%E7%9A%84-build-time-%E7%B4%A2%E5%BC%95%E6%96%B9%E6%A1%88/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/posts/pagefind%E9%9D%9C%E6%85%8B%E7%AB%99%E6%90%9C%E5%B0%8B%E7%9A%84-build-time-%E7%B4%A2%E5%BC%95%E6%96%B9%E6%A1%88/</guid><description>&lt;h2 id="靜態站搜尋的問題空間">靜態站搜尋的問題空間&lt;/h2>
&lt;p>靜態站沒有後端可以接查詢，所有搜尋工作必須在兩個時點之一完成：&lt;strong>build 時&lt;/strong>產生索引、&lt;strong>client runtime&lt;/strong> 執行匹配。這個前提決定了所有靜態站搜尋方案共同面對的兩個設計軸：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>設計軸&lt;/th>
 &lt;th>意義&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>索引產生時機&lt;/td>
 &lt;td>build 時靜態產生，或 client 載入後動態建立&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>索引交付方式&lt;/td>
 &lt;td>一次全量下載，或按查詢 lazy-load&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>方案差異來自這兩軸的組合。Pagefind 選的是「build 時產生、按需載入」，它的所有設計決策都是這個選擇的延伸。&lt;/p>
&lt;hr>
&lt;h2 id="核心設計索引切片與按需載入">核心設計：索引切片與按需載入&lt;/h2>
&lt;p>&lt;strong>商業邏輯&lt;/strong>：搜尋索引的 scaling 關鍵是&lt;strong>單次查詢需要下載多少資料&lt;/strong>，而非壓縮率或演算法效率。若索引是一整包、每次查詢都要先整包載入，訪客體驗與站的大小線性綁定 — 站大 10 倍，首次搜尋延遲 10 倍。&lt;/p>
&lt;p>要脫離這條綁定，索引必須能以「與查詢相關」的粒度切片、按需傳輸。這把「索引多大」的問題從訪客手上移回 build pipeline。&lt;/p>
&lt;p>&lt;strong>CASE&lt;/strong>：Pagefind 的索引是三層結構：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層次&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;th>大小&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>pagefind-entry.json&lt;/code>&lt;/td>
 &lt;td>索引目錄，記載有哪些 chunk 與 fragment&lt;/td>
 &lt;td>&amp;lt;10KB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>index/*.pf_index&lt;/code>&lt;/td>
 &lt;td>倒排索引切片，依 term 前綴分片&lt;/td>
 &lt;td>10-50KB / chunk&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>fragment/*.pf_fragment&lt;/code>&lt;/td>
 &lt;td>每篇文章的 metadata、URL、摘要&lt;/td>
 &lt;td>2-5KB / fragment&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>查「WAF」時，client 下載路徑是：entry（10KB）→ 涵蓋 &amp;ldquo;W&amp;rdquo; 的 index chunk（~30KB）→ 命中文章的 fragment（每筆 3KB）。總傳輸量與全站大小幾乎脫鉤 — 站擴大 10 倍，單次搜尋仍然只下載「W」那個 chunk 與少數 fragment。&lt;/p>
&lt;hr>
&lt;h2 id="架構選擇爬-rendered-html">架構選擇：爬 rendered HTML&lt;/h2>
&lt;p>&lt;strong>商業邏輯&lt;/strong>：索引內容的來源有兩種可能：&lt;strong>source 層&lt;/strong>（markdown、frontmatter、結構化資料）或 &lt;strong>output 層&lt;/strong>（render 後的 HTML）。選哪一層決定工具與 framework 的耦合程度 — source 層要求工具懂特定 framework 的內容模型；output 層只要求結果是 HTML。&lt;/p>
&lt;p>Pagefind 選 output 層。含義是：它跟 Hugo、Jekyll、Zola、Next.js static export 完全解耦，只要該 framework 產出的是 HTML，Pagefind 都能索引。&lt;/p>
&lt;p>&lt;strong>CASE&lt;/strong>：此選擇在 blog 端的具體要求：希望被搜到的內容必須出現在 rendered HTML 上。frontmatter 的 &lt;code>description&lt;/code> 欄位若只存在於 markdown source、沒被 theme 輸出成 &lt;code>&amp;lt;meta&amp;gt;&lt;/code> 或可見文字，就不會進索引。&lt;/p>
&lt;p>這個 blog 天然滿足 — theme 把 description 寫進 &lt;code>&amp;lt;meta name=&amp;quot;description&amp;quot;&amp;gt;&lt;/code>，render hook 也用它做 tooltip。移植到任何其他 static site generator，只要目標的 output HTML 有這些欄位，搜尋整合不用重寫。&lt;/p>
&lt;hr>
&lt;h2 id="整合步驟">整合步驟&lt;/h2>
&lt;h3 id="1-build-pipeline">1. Build pipeline&lt;/h3>
&lt;p>&lt;strong>核心動作&lt;/strong>：Hugo build 後加一步 Pagefind。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">hugo --minify
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">npx -y pagefind --site public&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩步，沒有中間檔。Pagefind 自行讀取 &lt;code>public/&lt;/code> 的 HTML，將索引寫回 &lt;code>public/pagefind/&lt;/code>。&lt;/p>
&lt;h3 id="2-搜尋頁路由">2. 搜尋頁路由&lt;/h3>
&lt;p>&lt;strong>核心動作&lt;/strong>：建立 Hugo 單頁，指向專屬 layout。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">title&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;搜尋&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">layout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">search&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">sitemap&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">disable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>sitemap.disable&lt;/code> 避免搜尋頁自己被 Hugo sitemap 收錄。&lt;/p>
&lt;h3 id="3-ui-掛載">3. UI 掛載&lt;/h3>
&lt;p>&lt;strong>核心動作&lt;/strong>：在 layout 中載入 Pagefind UI 資源，指定 mount point。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">{{ define &amp;#34;main&amp;#34; }}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">data-pagefind-ignore&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">link&lt;/span> &lt;span class="na">href&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;{{ &amp;#34;&lt;/span>&lt;span class="na">pagefind&lt;/span>&lt;span class="err">/&lt;/span>&lt;span class="na">pagefind-ui&lt;/span>&lt;span class="err">.&lt;/span>&lt;span class="na">css&lt;/span>&lt;span class="err">&amp;#34;&lt;/span> &lt;span class="err">|&lt;/span> &lt;span class="na">relURL&lt;/span> &lt;span class="err">}}&amp;#34;&lt;/span> &lt;span class="na">rel&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;stylesheet&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">script&lt;/span> &lt;span class="na">src&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;{{ &amp;#34;&lt;/span>&lt;span class="na">pagefind&lt;/span>&lt;span class="err">/&lt;/span>&lt;span class="na">pagefind-ui&lt;/span>&lt;span class="err">.&lt;/span>&lt;span class="na">js&lt;/span>&lt;span class="err">&amp;#34;&lt;/span> &lt;span class="err">|&lt;/span> &lt;span class="na">relURL&lt;/span> &lt;span class="err">}}&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&amp;lt;/&lt;/span>&lt;span class="nt">script&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">script&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nb">window&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addEventListener&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;DOMContentLoaded&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">new&lt;/span> &lt;span class="nx">PagefindUI&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">element&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s2">&amp;#34;#search&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">showSubResults&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">translations&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">placeholder&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s2">&amp;#34;搜尋卡片或文章…&amp;#34;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">script&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">{{ end }}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個細節：&lt;/p></description><content:encoded><![CDATA[<h2 id="靜態站搜尋的問題空間">靜態站搜尋的問題空間</h2>
<p>靜態站沒有後端可以接查詢，所有搜尋工作必須在兩個時點之一完成：<strong>build 時</strong>產生索引、<strong>client runtime</strong> 執行匹配。這個前提決定了所有靜態站搜尋方案共同面對的兩個設計軸：</p>
<table>
  <thead>
      <tr>
          <th>設計軸</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>索引產生時機</td>
          <td>build 時靜態產生，或 client 載入後動態建立</td>
      </tr>
      <tr>
          <td>索引交付方式</td>
          <td>一次全量下載，或按查詢 lazy-load</td>
      </tr>
  </tbody>
</table>
<p>方案差異來自這兩軸的組合。Pagefind 選的是「build 時產生、按需載入」，它的所有設計決策都是這個選擇的延伸。</p>
<hr>
<h2 id="核心設計索引切片與按需載入">核心設計：索引切片與按需載入</h2>
<p><strong>商業邏輯</strong>：搜尋索引的 scaling 關鍵是<strong>單次查詢需要下載多少資料</strong>，而非壓縮率或演算法效率。若索引是一整包、每次查詢都要先整包載入，訪客體驗與站的大小線性綁定 — 站大 10 倍，首次搜尋延遲 10 倍。</p>
<p>要脫離這條綁定，索引必須能以「與查詢相關」的粒度切片、按需傳輸。這把「索引多大」的問題從訪客手上移回 build pipeline。</p>
<p><strong>CASE</strong>：Pagefind 的索引是三層結構：</p>
<table>
  <thead>
      <tr>
          <th>層次</th>
          <th>內容</th>
          <th>大小</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>pagefind-entry.json</code></td>
          <td>索引目錄，記載有哪些 chunk 與 fragment</td>
          <td>&lt;10KB</td>
      </tr>
      <tr>
          <td><code>index/*.pf_index</code></td>
          <td>倒排索引切片，依 term 前綴分片</td>
          <td>10-50KB / chunk</td>
      </tr>
      <tr>
          <td><code>fragment/*.pf_fragment</code></td>
          <td>每篇文章的 metadata、URL、摘要</td>
          <td>2-5KB / fragment</td>
      </tr>
  </tbody>
</table>
<p>查「WAF」時，client 下載路徑是：entry（10KB）→ 涵蓋 &ldquo;W&rdquo; 的 index chunk（~30KB）→ 命中文章的 fragment（每筆 3KB）。總傳輸量與全站大小幾乎脫鉤 — 站擴大 10 倍，單次搜尋仍然只下載「W」那個 chunk 與少數 fragment。</p>
<hr>
<h2 id="架構選擇爬-rendered-html">架構選擇：爬 rendered HTML</h2>
<p><strong>商業邏輯</strong>：索引內容的來源有兩種可能：<strong>source 層</strong>（markdown、frontmatter、結構化資料）或 <strong>output 層</strong>（render 後的 HTML）。選哪一層決定工具與 framework 的耦合程度 — source 層要求工具懂特定 framework 的內容模型；output 層只要求結果是 HTML。</p>
<p>Pagefind 選 output 層。含義是：它跟 Hugo、Jekyll、Zola、Next.js static export 完全解耦，只要該 framework 產出的是 HTML，Pagefind 都能索引。</p>
<p><strong>CASE</strong>：此選擇在 blog 端的具體要求：希望被搜到的內容必須出現在 rendered HTML 上。frontmatter 的 <code>description</code> 欄位若只存在於 markdown source、沒被 theme 輸出成 <code>&lt;meta&gt;</code> 或可見文字，就不會進索引。</p>
<p>這個 blog 天然滿足 — theme 把 description 寫進 <code>&lt;meta name=&quot;description&quot;&gt;</code>，render hook 也用它做 tooltip。移植到任何其他 static site generator，只要目標的 output HTML 有這些欄位，搜尋整合不用重寫。</p>
<hr>
<h2 id="整合步驟">整合步驟</h2>
<h3 id="1-build-pipeline">1. Build pipeline</h3>
<p><strong>核心動作</strong>：Hugo build 後加一步 Pagefind。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">hugo --minify
</span></span><span class="line"><span class="ln">2</span><span class="cl">npx -y pagefind --site public</span></span></code></pre></div><p>兩步，沒有中間檔。Pagefind 自行讀取 <code>public/</code> 的 HTML，將索引寫回 <code>public/pagefind/</code>。</p>
<h3 id="2-搜尋頁路由">2. 搜尋頁路由</h3>
<p><strong>核心動作</strong>：建立 Hugo 單頁，指向專屬 layout。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="nt">title</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;搜尋&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="nt">layout</span><span class="p">:</span><span class="w"> </span><span class="l">search</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="nt">sitemap</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="nt">disable</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w"></span><span class="nn">---</span></span></span></code></pre></div><p><code>sitemap.disable</code> 避免搜尋頁自己被 Hugo sitemap 收錄。</p>
<h3 id="3-ui-掛載">3. UI 掛載</h3>
<p><strong>核心動作</strong>：在 layout 中載入 Pagefind UI 資源，指定 mount point。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln"> 1</span><span class="cl">{{ define &#34;main&#34; }}
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">data-pagefind-ignore</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">link</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;{{ &#34;</span><span class="na">pagefind</span><span class="err">/</span><span class="na">pagefind-ui</span><span class="err">.</span><span class="na">css</span><span class="err">&#34;</span> <span class="err">|</span> <span class="na">relURL</span> <span class="err">}}&#34;</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;stylesheet&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">id</span><span class="o">=</span><span class="s">&#34;search&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="p">&lt;</span><span class="nt">script</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;{{ &#34;</span><span class="na">pagefind</span><span class="err">/</span><span class="na">pagefind-ui</span><span class="err">.</span><span class="na">js</span><span class="err">&#34;</span> <span class="err">|</span> <span class="na">relURL</span> <span class="err">}}&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nb">window</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;DOMContentLoaded&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="k">new</span> <span class="nx">PagefindUI</span><span class="p">({</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">element</span><span class="o">:</span> <span class="s2">&#34;#search&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">showSubResults</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">translations</span><span class="o">:</span> <span class="p">{</span> <span class="nx">placeholder</span><span class="o">:</span> <span class="s2">&#34;搜尋卡片或文章…&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">      <span class="p">});</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">{{ end }}</span></span></code></pre></div><p>兩個細節：</p>
<ul>
<li><code>data-pagefind-ignore</code> 告訴 Pagefind 這頁本身不要進索引（避免搜「搜尋」出現搜尋頁）。</li>
<li><code>relURL</code> 處理 baseURL 的 subpath（例如 <code>/blog/</code>），讓 UI 自動推斷 chunk 相對位置。</li>
</ul>
<h3 id="4-ci-workflow">4. CI workflow</h3>
<p><strong>核心動作</strong>：GitHub Actions 在 Hugo build 步驟後插入 Pagefind。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build Pagefind search index</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npx -y pagefind --site public</span></span></span></code></pre></div><p>ubuntu-latest runner 內建 node，<code>npx -y</code> 首次執行會下載並 cache binary，後續執行直接從 cache 取用。</p>
<hr>
<h2 id="方案的內在屬性">方案的內在屬性</h2>
<p>評估 Pagefind 不看「比較快」「比較省事」這類時間維度，用下列內在屬性：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Pagefind 的特徵</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>覆蓋完整性</td>
          <td>索引全站 HTML；不需要逐 section 註冊</td>
      </tr>
      <tr>
          <td>可逆性</td>
          <td>產物是檔案，移除就是刪除 <code>public/pagefind/</code> 與搜尋頁，無殘留依賴</td>
      </tr>
      <tr>
          <td>維護成本</td>
          <td>build pipeline 多一步；無 runtime 服務、無 key 管理、無版本相依性</td>
      </tr>
      <tr>
          <td>可理解性</td>
          <td>UI drop-in、filter 用 HTML 屬性宣告、三層索引結構直觀</td>
      </tr>
      <tr>
          <td>依賴前提</td>
          <td>要求目標 framework 能產出 HTML（絕大多數 static generator 滿足）</td>
      </tr>
      <tr>
          <td>擴展性</td>
          <td>單次查詢下載量與全站大小脫鉤 — scaling 由 build time 吸收，不轉嫁到訪客</td>
      </tr>
  </tbody>
</table>
<p><strong>內建的一等公民特性</strong>：</p>
<ul>
<li><strong>Filter by facet</strong>：<code>data-pagefind-filter=&quot;type:card&quot;</code> 標在 HTML 元素上，UI 自動出現對應 filter checkbox</li>
<li><strong>Snippet highlighting</strong>：命中的關鍵字在結果摘要中高亮</li>
<li><strong>無障礙</strong>：Component UI（1.5.0+）內建 keyboard navigation、ARIA label、screen reader 公告</li>
</ul>
<p>這些特徵都源自「build 時產生 + 按需載入」這個核心選擇的延伸，不是外掛功能。</p>
<hr>
<h2 id="運作特徵">運作特徵</h2>
<h3 id="zh-tw-走-character-n-gram">zh-tw 走 character n-gram</h3>
<p><strong>核心定義</strong>：Pagefind 對非空白分詞語言採 n-gram — 以字元序列作為匹配單位，而非詞。</p>
<p><strong>行為</strong>：搜「負載平衡」能命中「負載平衡器」、「負載平衡器測試」等任何包含該字元序列的頁面。啟動時會印一行 stemming note，那是針對屈折變化語言（英文、德文）的 stemming 提示，對中文無意義也無限制。</p>
<p><strong>邊界</strong>：少數情境下跨詞邊界的字元組合會誤命中（例如搜「負載過」可能命中「負載過高」與「負載過往」）。在名詞為主的技術站影響極小。</p>
<h3 id="索引來自-rendered-html">索引來自 rendered HTML</h3>
<p><strong>核心定義</strong>：索引內容 = Pagefind 在 <code>public/*.html</code> 看到的可見文字與 meta tag。</p>
<p><strong>含義</strong>：想加入索引的欄位必須出現在 output HTML 上。想排除的區塊用 <code>data-pagefind-ignore</code> 標記。想作為 filter 的屬性用 <code>data-pagefind-filter=&quot;name:value&quot;</code>。</p>
<h3 id="default-ui-的樣式是-pagefind-自家風格">Default UI 的樣式是 Pagefind 自家風格</h3>
<p><strong>核心定義</strong>：<code>PagefindUI</code> component 有固定的視覺設計，透過 CSS variable 可微調顏色、圓角、spacing。</p>
<p><strong>含義</strong>：想要與 theme 完全融合有兩條路 — 覆寫 CSS variable（官方 docs 列出可覆寫清單），或改用 Pagefind JS API 自己組 UI（更完整客製）。</p>
<h3 id="build-pipeline-多一步">Build pipeline 多一步</h3>
<p><strong>核心定義</strong>：Pagefind 是 Hugo build 外的獨立步驟。</p>
<p><strong>含義</strong>：CI 與本地都要記得跑 <code>npx pagefind</code>。這個 blog 以 Makefile 的 <code>make site</code> 封裝 <code>hugo + pagefind</code> 兩步，把「記得」轉成 infrastructure 強制項。</p>
<hr>
<h2 id="適合的場景">適合的場景</h2>
<ul>
<li>靜態站、內容持續成長</li>
<li>部署在 GH Pages / Netlify / Cloudflare Pages 等純靜態平台</li>
<li>希望零外部依賴、完全自託管</li>
<li>內容以文字為主（blog、docs、knowledge base）</li>
<li>未來可能換 framework — 希望搜尋整合不隨之重寫</li>
</ul>
]]></content:encoded></item></channel></rss>