<?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/%E5%B7%A5%E5%85%B7%E8%A8%AD%E8%A8%88/</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/%E5%B7%A5%E5%85%B7%E8%A8%AD%E8%A8%88/index.xml" rel="self" type="application/rss+xml"/><item><title>mdtools：Go + goldmark 的 markdown 工具鏈設計</title><link>https://tarrragon.github.io/blog/posts/mdtoolsgo--goldmark-%E7%9A%84-markdown-%E5%B7%A5%E5%85%B7%E9%8F%88%E8%A8%AD%E8%A8%88/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/posts/mdtoolsgo--goldmark-%E7%9A%84-markdown-%E5%B7%A5%E5%85%B7%E9%8F%88%E8%A8%AD%E8%A8%88/</guid><description>&lt;h2 id="背景為什麼要自訂工具">背景：為什麼要自訂工具&lt;/h2>
&lt;p>Blog 專案的 markdown 規範有三類不同性質的檢查需求：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>基礎格式&lt;/strong>（MD022 / MD024 / MD034 / MD060 等）— 市面 linter 都有，但規則細節不一致，我們對 &lt;code>MD024&lt;/code> 要特殊處理（&lt;code>siblings_only&lt;/code> 模式允許平行結構下的同名標題）。&lt;/li>
&lt;li>&lt;strong>反釣魚校驗&lt;/strong>（R-URL-1/2）— 顯示文字含 TLD 字樣時必須與 href 的 domain 一致，避免釣魚型連結。這條規則不在 markdownlint 標準集內。&lt;/li>
&lt;li>&lt;strong>卡片雙向完整性&lt;/strong>（L1/L2/L4）— 跨文件的圖論檢查：每張卡片至少被一篇正文引用、相對連結目標存在、卡片首段含鄰卡連結。&lt;/li>
&lt;/ol>
&lt;p>三類檢查共享兩個技術需求：&lt;strong>AST 層的語法理解&lt;/strong>、&lt;strong>goldmark 與 Hugo render 的一致性&lt;/strong>。詳細原因寫在&lt;a href="https://tarrragon.github.io/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST&lt;/a>。&lt;/p>
&lt;p>Markdownlint-cli2 涵蓋第一類、無法表達第二、三類。現成方案湊不出來，就自己寫。&lt;/p>
&lt;h2 id="語言選擇go-vs-python-的-tripwire-式決策">語言選擇：Go vs Python 的 tripwire 式決策&lt;/h2>
&lt;p>這是實際討論過的決策，值得留下紀錄。&lt;/p>
&lt;h3 id="表面的直覺blog-用-hugogo-寫的所以用-go-最自然">表面的直覺：Blog 用 Hugo（Go 寫的），所以用 Go 最自然&lt;/h3>
&lt;p>這個推論有個破口：Hugo 雖然用 Go 寫，但我們用的是 &lt;strong>pre-built binary&lt;/strong>。&lt;code>hugo server&lt;/code> 本地跑的是下載好的執行檔，CI 用 &lt;code>peaceiris/actions-hugo&lt;/code> 這類 action，整個 blog 的 build 流程完全不碰 Go toolchain。&lt;/p>
&lt;p>「專案已有 Go 依賴」這個前提不成立。真正要問的是：&lt;strong>我是否願意為這組工具引入 Go toolchain 這個新依賴？&lt;/strong>&lt;/p>
&lt;h3 id="務實的對比">務實的對比&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>Python&lt;/th>
 &lt;th>Go&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pre-commit 啟動速度&lt;/td>
 &lt;td>~50ms（interpreter 啟動）&lt;/td>
 &lt;td>&lt;code>go run&lt;/code> ~500ms/次；pre-build binary 則要 commit 進 repo&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CI 新增依賴&lt;/td>
 &lt;td>&lt;code>setup-python&lt;/code>（runner 通常自帶）&lt;/td>
 &lt;td>&lt;code>setup-go&lt;/code> + build step&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>開發速度（regex / 字串處理）&lt;/td>
 &lt;td>快&lt;/td>
 &lt;td>慢 2-3x，boilerplate 較多&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AST 解析選擇&lt;/td>
 &lt;td>mistune / markdown-it-py&lt;/td>
 &lt;td>&lt;strong>goldmark（與 Hugo 同源）&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Go 唯一的決定性優勢是 goldmark — 跟 Hugo 用同一個 parser 可以保證「lint 通過 ↔ Hugo render 成功」等價。&lt;/p>
&lt;h3 id="關鍵一問現在需要-ast-嗎">關鍵一問：現在需要 AST 嗎？&lt;/h3>
&lt;p>我們最初傾向的是 tripwire 策略：&lt;strong>現在用 Python + regex 先頂著，等 rule 複雜度超過臨界就升級 Go + goldmark&lt;/strong>。Tripwire 條件大致是：&lt;/p>
&lt;ol>
&lt;li>Rule 數量超過 5 條。&lt;/li>
&lt;li>任一規則需要「這段文字在 code block 內嗎」這類上下文判斷。&lt;/li>
&lt;li>Hugo render 結果跟 lint 判讀開始不一致。&lt;/li>
&lt;/ol>
&lt;p>但事實是：&lt;/p>
&lt;ul>
&lt;li>MD024 的 siblings_only 已經需要父子關係追蹤 — 條件 2 馬上命中。&lt;/li>
&lt;li>卡片雙向完整性是當前任務（不是未來可能）— 跨文件檢查 regex 做不到。&lt;/li>
&lt;/ul>
&lt;p>兩個條件當下已經滿足，delay migration 反而要兩次寫工具。所以直接選 Go + goldmark。&lt;/p>
&lt;p>這個決定的邏輯層面是：&lt;strong>當需求已在手上而非 speculative，延遲決策的代價 &amp;gt; 直接上的代價&lt;/strong>。&lt;/p>
&lt;h2 id="為什麼選-goldmark">為什麼選 goldmark&lt;/h2>
&lt;p>三個具體理由：&lt;/p>
&lt;h3 id="1-解析結果與-hugo-一致">1. 解析結果與 Hugo 一致&lt;/h3>
&lt;p>Hugo 的 content render pipeline 走 goldmark。用同一個 parser 寫 lint，可以杜絕「lint 通過但 Hugo render 失敗」或「Hugo 看得懂但 lint 誤判」這類長尾 bug。&lt;/p></description><content:encoded><![CDATA[<h2 id="背景為什麼要自訂工具">背景：為什麼要自訂工具</h2>
<p>Blog 專案的 markdown 規範有三類不同性質的檢查需求：</p>
<ol>
<li><strong>基礎格式</strong>（MD022 / MD024 / MD034 / MD060 等）— 市面 linter 都有，但規則細節不一致，我們對 <code>MD024</code> 要特殊處理（<code>siblings_only</code> 模式允許平行結構下的同名標題）。</li>
<li><strong>反釣魚校驗</strong>（R-URL-1/2）— 顯示文字含 TLD 字樣時必須與 href 的 domain 一致，避免釣魚型連結。這條規則不在 markdownlint 標準集內。</li>
<li><strong>卡片雙向完整性</strong>（L1/L2/L4）— 跨文件的圖論檢查：每張卡片至少被一篇正文引用、相對連結目標存在、卡片首段含鄰卡連結。</li>
</ol>
<p>三類檢查共享兩個技術需求：<strong>AST 層的語法理解</strong>、<strong>goldmark 與 Hugo render 的一致性</strong>。詳細原因寫在<a href="/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST</a>。</p>
<p>Markdownlint-cli2 涵蓋第一類、無法表達第二、三類。現成方案湊不出來，就自己寫。</p>
<h2 id="語言選擇go-vs-python-的-tripwire-式決策">語言選擇：Go vs Python 的 tripwire 式決策</h2>
<p>這是實際討論過的決策，值得留下紀錄。</p>
<h3 id="表面的直覺blog-用-hugogo-寫的所以用-go-最自然">表面的直覺：Blog 用 Hugo（Go 寫的），所以用 Go 最自然</h3>
<p>這個推論有個破口：Hugo 雖然用 Go 寫，但我們用的是 <strong>pre-built binary</strong>。<code>hugo server</code> 本地跑的是下載好的執行檔，CI 用 <code>peaceiris/actions-hugo</code> 這類 action，整個 blog 的 build 流程完全不碰 Go toolchain。</p>
<p>「專案已有 Go 依賴」這個前提不成立。真正要問的是：<strong>我是否願意為這組工具引入 Go toolchain 這個新依賴？</strong></p>
<h3 id="務實的對比">務實的對比</h3>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>Python</th>
          <th>Go</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Pre-commit 啟動速度</td>
          <td>~50ms（interpreter 啟動）</td>
          <td><code>go run</code> ~500ms/次；pre-build binary 則要 commit 進 repo</td>
      </tr>
      <tr>
          <td>CI 新增依賴</td>
          <td><code>setup-python</code>（runner 通常自帶）</td>
          <td><code>setup-go</code> + build step</td>
      </tr>
      <tr>
          <td>開發速度（regex / 字串處理）</td>
          <td>快</td>
          <td>慢 2-3x，boilerplate 較多</td>
      </tr>
      <tr>
          <td>AST 解析選擇</td>
          <td>mistune / markdown-it-py</td>
          <td><strong>goldmark（與 Hugo 同源）</strong></td>
      </tr>
  </tbody>
</table>
<p>Go 唯一的決定性優勢是 goldmark — 跟 Hugo 用同一個 parser 可以保證「lint 通過 ↔ Hugo render 成功」等價。</p>
<h3 id="關鍵一問現在需要-ast-嗎">關鍵一問：現在需要 AST 嗎？</h3>
<p>我們最初傾向的是 tripwire 策略：<strong>現在用 Python + regex 先頂著，等 rule 複雜度超過臨界就升級 Go + goldmark</strong>。Tripwire 條件大致是：</p>
<ol>
<li>Rule 數量超過 5 條。</li>
<li>任一規則需要「這段文字在 code block 內嗎」這類上下文判斷。</li>
<li>Hugo render 結果跟 lint 判讀開始不一致。</li>
</ol>
<p>但事實是：</p>
<ul>
<li>MD024 的 siblings_only 已經需要父子關係追蹤 — 條件 2 馬上命中。</li>
<li>卡片雙向完整性是當前任務（不是未來可能）— 跨文件檢查 regex 做不到。</li>
</ul>
<p>兩個條件當下已經滿足，delay migration 反而要兩次寫工具。所以直接選 Go + goldmark。</p>
<p>這個決定的邏輯層面是：<strong>當需求已在手上而非 speculative，延遲決策的代價 &gt; 直接上的代價</strong>。</p>
<h2 id="為什麼選-goldmark">為什麼選 goldmark</h2>
<p>三個具體理由：</p>
<h3 id="1-解析結果與-hugo-一致">1. 解析結果與 Hugo 一致</h3>
<p>Hugo 的 content render pipeline 走 goldmark。用同一個 parser 寫 lint，可以杜絕「lint 通過但 Hugo render 失敗」或「Hugo 看得懂但 lint 誤判」這類長尾 bug。</p>
<h3 id="2-ast-api-直觀">2. AST API 直觀</h3>
<p>Goldmark 的 AST 節點型別設計貼近 CommonMark spec：<code>Document</code> / <code>Heading</code> / <code>Paragraph</code> / <code>Link</code> / <code>Table</code> / <code>FencedCodeBlock</code>。要寫 rule 時幾乎不需要翻對照表，直接比對心中的 markdown 結構。</p>
<h3 id="3-活躍且嵌入在主流-go-生態">3. 活躍且嵌入在主流 Go 生態</h3>
<p>Goldmark 是 Hugo 使用的 parser，社群活躍、bug fix 持續進來。不會變成 abandoned dependency。</p>
<h2 id="架構設計單一-binary--子命令">架構設計：單一 binary + 子命令</h2>
<p>三個檢查功能分開寫比較好懂，但如果寫成三個 binary，每次 pre-commit 都要 parse markdown 三次，對大型 repo（我們這個已經超過 300 個 markdown）會明顯拖慢。</p>
<p>折衷方案是<strong>單一 binary + 子命令</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">scripts/mdtools/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── main.go                    # subcommand dispatcher
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── cmd/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   ├── fmt.go                 # mdtools fmt [--fix|--check]
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── lint.go                # mdtools lint
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   └── cards.go               # mdtools cards
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">├── internal/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">│   ├── astutil/               # goldmark 封裝（parse, walk, parent chain）
</span></span><span class="line"><span class="ln">10</span><span class="cl">│   ├── rules/                 # 規則定義（可被三個子命令共用）
</span></span><span class="line"><span class="ln">11</span><span class="cl">│   │   ├── config.go          # 全域開關與參數
</span></span><span class="line"><span class="ln">12</span><span class="cl">│   │   ├── headings.go        # 標題規則
</span></span><span class="line"><span class="ln">13</span><span class="cl">│   │   ├── urls.go            # URL + 反釣魚
</span></span><span class="line"><span class="ln">14</span><span class="cl">│   │   ├── tables.go          # 表格正規化
</span></span><span class="line"><span class="ln">15</span><span class="cl">│   │   ├── frontmatter.go     # front matter schema
</span></span><span class="line"><span class="ln">16</span><span class="cl">│   │   └── identifiers.go     # 識別碼白名單（CVE、KB、...）
</span></span><span class="line"><span class="ln">17</span><span class="cl">│   └── report/                # 統一錯誤輸出格式
</span></span><span class="line"><span class="ln">18</span><span class="cl">└── README.md</span></span></code></pre></div><p>三個子命令共享 <code>internal/astutil</code> 和 <code>internal/rules</code>，同一個 parse 結果可以在不同規則間重用。</p>
<h2 id="實際走訪md024-siblings_only-在-goldmark-上怎麼寫">實際走訪：MD024 siblings_only 在 goldmark 上怎麼寫</h2>
<p>這段是示範 AST-based rule 的可讀性，不是最終實作版本。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">package</span> <span class="nx">rules</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="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s">&#34;bytes&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="s">&#34;github.com/yuin/goldmark/ast&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="s">&#34;github.com/yuin/goldmark/text&#34;</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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// CheckSiblingsOnlyHeadings walks the document and flags headings</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// that share the same text with a sibling under the same parent heading.</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="nf">CheckSiblingsOnlyHeadings</span><span class="p">(</span><span class="nx">doc</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">src</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">[]</span><span class="nx">Violation</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="kd">var</span> <span class="nx">violations</span> <span class="p">[]</span><span class="nx">Violation</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="c1">// parentMap[level] 保留目前走到的各層 heading，作為後續 H(n+1) 的 parent context</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">parentMap</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">int</span><span class="p">]</span><span class="o">*</span><span class="nx">ast</span><span class="p">.</span><span class="nx">Heading</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="c1">// 每個 parent context 下，收集已見過的子 heading 文字</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">seenUnderParent</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="o">*</span><span class="nx">ast</span><span class="p">.</span><span class="nx">Heading</span><span class="p">]</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nx">ast</span><span class="p">.</span><span class="nf">Walk</span><span class="p">(</span><span class="nx">doc</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">n</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">entering</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">(</span><span class="nx">ast</span><span class="p">.</span><span class="nx">WalkStatus</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">entering</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">            <span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">h</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">n</span><span class="p">.(</span><span class="o">*</span><span class="nx">ast</span><span class="p">.</span><span class="nx">Heading</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">            <span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="nx">text</span> <span class="o">:=</span> <span class="nb">string</span><span class="p">(</span><span class="nx">h</span><span class="p">.</span><span class="nf">Text</span><span class="p">(</span><span class="nx">src</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="nx">parent</span> <span class="o">:=</span> <span class="nx">parentMap</span><span class="p">[</span><span class="nx">h</span><span class="p">.</span><span class="nx">Level</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span> <span class="c1">// 直接上層 heading</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="nx">seen</span><span class="p">,</span> <span class="nx">exists</span> <span class="o">:=</span> <span class="nx">seenUnderParent</span><span class="p">[</span><span class="nx">parent</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">exists</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">            <span class="nx">seen</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">            <span class="nx">seenUnderParent</span><span class="p">[</span><span class="nx">parent</span><span class="p">]</span> <span class="p">=</span> <span class="nx">seen</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="k">if</span> <span class="nx">prev</span><span class="p">,</span> <span class="nx">dup</span> <span class="o">:=</span> <span class="nx">seen</span><span class="p">[</span><span class="nx">text</span><span class="p">];</span> <span class="nx">dup</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">            <span class="nx">violations</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">violations</span><span class="p">,</span> <span class="nx">Violation</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">                <span class="nx">Rule</span><span class="p">:</span>    <span class="s">&#34;MD024-siblings_only&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">                <span class="nx">Node</span><span class="p">:</span>    <span class="nx">h</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">                <span class="nx">Message</span><span class="p">:</span> <span class="s">&#34;duplicate heading under the same parent: &#34;</span> <span class="o">+</span> <span class="nx">text</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">                <span class="nx">Prev</span><span class="p">:</span>    <span class="nx">prev</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">            <span class="p">})</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">            <span class="nx">seen</span><span class="p">[</span><span class="nx">text</span><span class="p">]</span> <span class="p">=</span> <span class="nx">h</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">
</span></span><span class="line"><span class="ln">49</span><span class="cl">        <span class="nx">parentMap</span><span class="p">[</span><span class="nx">h</span><span class="p">.</span><span class="nx">Level</span><span class="p">]</span> <span class="p">=</span> <span class="nx">h</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">        <span class="c1">// 進到更深層時，清空下層的舊狀態</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">        <span class="k">for</span> <span class="nx">lv</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">Level</span> <span class="o">+</span> <span class="mi">1</span><span class="p">;</span> <span class="nx">lv</span> <span class="o">&lt;=</span> <span class="mi">6</span><span class="p">;</span> <span class="nx">lv</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">            <span class="nb">delete</span><span class="p">(</span><span class="nx">parentMap</span><span class="p">,</span> <span class="nx">lv</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">
</span></span><span class="line"><span class="ln">55</span><span class="cl">        <span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">57</span><span class="cl">
</span></span><span class="line"><span class="ln">58</span><span class="cl">    <span class="k">return</span> <span class="nx">violations</span>
</span></span><span class="line"><span class="ln">59</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>對比 regex 版本要自己寫「目前 H2 是誰」狀態機 + 「切回上層時清狀態」— goldmark 的 walker pattern 把階層邏輯外部化到樹結構，rule 本身只處理「同一 parent 下有沒有重複」的核心語義。</p>
<p>幾百行 regex 才能穩定做到的事，AST 版本大概 30 行。規則越多，這個倍率越明顯。</p>
<h2 id="pre-commit-與-ci-整合">Pre-commit 與 CI 整合</h2>
<h3 id="本地開發githookspre-commit-與-githookspre-push">本地開發：<code>.githooks/pre-commit</code> 與 <code>.githooks/pre-push</code></h3>





<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"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cp"></span><span class="nb">set</span> -euo pipefail
</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="c1"># 確保 binary 最新</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">if</span> <span class="o">[[</span> ! -x bin/mdtools <span class="o">]]</span> <span class="o">||</span> <span class="o">[[</span> scripts/mdtools/main.go -nt bin/mdtools <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;Rebuilding mdtools...&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="o">(</span><span class="nb">cd</span> scripts/mdtools <span class="o">&amp;&amp;</span> go build -o ../../bin/mdtools .<span class="o">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"># 三段式檢查</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">bin/mdtools fmt --fix   <span class="c1"># 自動修格式；改動會 re-stage</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">git add <span class="k">$(</span>git diff --name-only --cached --diff-filter<span class="o">=</span>AM <span class="p">|</span> grep <span class="s1">&#39;\.md$&#39;</span> <span class="o">||</span> <span class="nb">true</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">bin/mdtools lint        <span class="c1"># 結構檢查，失敗即阻擋</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">bin/mdtools cards       <span class="c1"># 跨文件檢查，失敗即阻擋</span></span></span></code></pre></div><p><code>pre-push</code> 補上全量 gate：</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">make check</span></span></code></pre></div><p>關鍵設計：</p>
<ul>
<li><code>mdtools fmt --fix</code> 會改檔，改完後要 <code>git add</code> 回 staged，否則 commit 進去的還是舊內容。</li>
<li><code>lint</code> 和 <code>cards</code> 不改檔，只讀與報告。</li>
<li><code>pre-commit</code> 保持 staged-file scoped，讓 commit 回饋夠快；<code>pre-push</code> 跑全量 <code>make check</code>，讓本機結果和 CI 同步。</li>
<li>Binary mtime 檢查避免每次 commit 都 rebuild。</li>
<li><code>bin/mdtools</code> 本身 gitignore，不 commit 進 repo。</li>
</ul>
<h3 id="cigithubworkflowsmd-checkyml">CI：<code>.github/workflows/md-check.yml</code></h3>





<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">md-check</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">on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">push, pull_request]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="nt">jobs</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">check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-go@v5</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">go-version</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;stable&#39;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build mdtools</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">(cd scripts/mdtools &amp;&amp; go build -o ../../bin/mdtools .)</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Format check</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">bin/mdtools fmt --check</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Structural lint</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">bin/mdtools lint</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Cross-file completeness</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">bin/mdtools cards</span></span></span></code></pre></div><p>CI 用 <code>--check</code> 而非 <code>--fix</code> — 任何格式偏差都 fail，不自動修（避免 CI 把修復 commit 推回去造成誤會）。</p>
<h3 id="安裝-hook">安裝 hook</h3>





<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">git config core.hooksPath .githooks
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 或用 Makefile target：</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">make install-hooks</span></span></code></pre></div><h2 id="維運成本的長期考量">維運成本的長期考量</h2>
<h3 id="誤判率是規則生命週期的關鍵">誤判率是規則生命週期的關鍵</h3>
<p>每條規則都可能誤判。我們的處理策略寫在規範的規則擴充流程段：</p>
<ol>
<li>新規則先在 <code>internal/rules/</code> 實作為<strong>可開關</strong>（預設關）。</li>
<li>在代表性檔案上測試誤判率。</li>
<li>誤判率 &lt; 1% 且有明確教材品質收益時，預設開啟。</li>
<li>預設開啟後，同步修正既有違規。</li>
</ol>
<p>關鍵在「預設關閉」這一步 — 給規則一個試水期，不會直接擋 commit。</p>
<h3 id="規則與-spec-文件的同步">規則與 spec 文件的同步</h3>
<p>Rule config 在 <code>internal/rules/config.go</code>，spec 文件在 <code>content/posts/markdown-writing-spec.md</code>。兩者修改時必須同步，否則會出現「spec 寫的規則跟工具實際跑的規則不同步」的沉默 bug。</p>
<p>這是目前靠紀律維持的部分。未來如果發現同步偏差重複發生，可以考慮從 config.go 產生 spec 的片段（或反過來）。目前手動同步的成本還可接受。</p>
<h3 id="規則數量的預期曲線">規則數量的預期曲線</h3>
<p>當前覆蓋 22 條 rule-config 條目。接下來加規則的收益會遞減 — 大部分重要的基礎格式 + 結構 + 跨文件檢查都已在內。未來新增應該集中在：</p>
<ul>
<li>新內容類型帶來的 schema 擴充（例如做 podcast 或者 video posts）。</li>
<li>術語字典完成後的 <strong>L3 術語覆蓋</strong>（正文首次出現術語自動連卡片）。</li>
<li>特定領域的品質檢查（例如紅隊教材「每個案例必須有 3 來源」）。</li>
</ul>
<p>基礎 markdownlint 規則能加的都加完了，再追規則就是在吸邊際收益極低的條目，不值得。</p>
<h2 id="延伸閱讀">延伸閱讀</h2>
<ul>
<li><a href="/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST — 從字串到語法樹的視角轉換</a> — 為什麼要升級到 AST 工具鏈</li>
<li><a href="/blog/posts/blog-markdown-%E5%AF%AB%E4%BD%9C%E8%A6%8F%E7%AF%84%E8%88%87-mdtools-%E6%AA%A2%E6%9F%A5/" data-link-title="Blog Markdown 寫作規範與 mdtools 檢查" data-link-desc="本 blog 的 Markdown 排版規範權威契約。涵蓋 H1 禁用、MD024 siblings_only、反釣魚 TLD 校驗、卡片雙向完整性、front matter schema；改規則時要與 scripts/mdtools 實作同步。">Blog Markdown 寫作規範與 mdtools 檢查</a> — mdtools 檢查的完整規則清單</li>
<li><a href="https://github.com/yuin/goldmark">goldmark 官方 repo</a> — Hugo 所用的 markdown parser</li>
<li><a href="https://pkg.go.dev/github.com/yuin/goldmark/ast">goldmark AST package reference</a> — <code>ast.Walk</code>、節點型別、parent traversal API</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><item><title>Skills — Claude skill 的文章版本</title><link>https://tarrragon.github.io/blog/skills/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/</guid><description>&lt;h2 id="這個資料夾是什麼">這個資料夾是什麼&lt;/h2>
&lt;p>&lt;code>content/skills/&lt;/code> 收錄&lt;strong>從 &lt;code>.claude/skills/&lt;/code> 轉成文章&lt;/strong>的 skill 版本。&lt;/p>
&lt;p>同一份 skill 有兩種存在形式，各自負責不同角色：&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>.claude/skills/&amp;lt;name&amp;gt;/&lt;/code>&lt;/td>
 &lt;td>實際 skill，Claude runtime 呼叫&lt;/td>
 &lt;td>Claude&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>content/skills/&amp;lt;name&amp;gt;/&lt;/code>（本處）&lt;/td>
 &lt;td>文章版本，Hugo 渲染成 blog 頁&lt;/td>
 &lt;td>人類讀者&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩處內容相同、結構略有差異：&lt;code>.claude/&lt;/code> 版本保留原始 &lt;code>SKILL.md + references/&lt;/code> 巢狀結構以配合 Claude 的路徑解析；&lt;code>content/&lt;/code> 版本扁平化（references 內容升級到同層），以契合 blog 的單層文章呈現。&lt;/p>
&lt;h2 id="目前收錄的-skill">目前收錄的 skill&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Skill&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>入口&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>compositional-writing&lt;/td>
 &lt;td>Zettelkasten 式組合寫作方法論&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/compositional-writing/" data-link-title="Compositional Writing — 組合式寫作方法論" data-link-desc="以 Zettelkasten 為核心、針對程式碼註解、文件、log、prompt、欄位、長篇技術文章、外部分析材料轉教學文章、跨多篇 collection 結構的寫作方法論。核心原則 &amp;#43; 觸發路由 &amp;#43; 情境 reference。">/skills/compositional-writing/&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>requirement-protocol&lt;/td>
 &lt;td>需求確認到實作的對話協議（模糊指令、失敗轉折、漸進驗證等）&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/requirement-protocol/" data-link-title="Requirement Protocol — 需求確認到實作的對話協議" data-link-desc="從需求確認到實作的對話協議：模糊指令澄清、可決定 vs 該確認、失敗 2 次轉折、覆寫成本告知、revert checkpoint、漸進驗證、工具切換時機。六大原則 &amp;#43; 五份情境 reference。">/skills/requirement-protocol/&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>frontend-with-playwright&lt;/td>
 &lt;td>框架無關的前端開發 + Playwright 驗證 + Filter × Source 跨領域 stream 操作（DOM / CSS / JS / framework 共處 / a11y / 資料流）&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/" data-link-title="Frontend with Playwright — 框架無關的前端開發 &amp;#43; Playwright 驗證" data-link-desc="框架無關的前端開發協議 &amp;#43; Playwright 驗證：DOM topology 先於 CSS、CSS / JS 邊界辨識、Playwright 三個位置（假設 / 行為 / 互動驗證）、framework 共處、Reactive 效能、A11y、Filter × Source 跨領域 stream 操作。六大原則 &amp;#43; 七份情境 reference。">/skills/frontend-with-playwright/&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>wrap-decision&lt;/td>
 &lt;td>WRAP 決策框架（錨點確認、資料充足度、擴增選項、實境檢驗、機會成本、行前預想與絆腳索）&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/wrap-decision/" data-link-title="WRAP 決策框架 — 認知偏誤防護與決策品質" data-link-desc="WRAP 決策框架的 blog 好讀版：用錨點確認、資料充足度、選項擴增、現實檢驗、機會成本、行前預想與絆腳索防止自動駕駛式決策。">/skills/wrap-decision/&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="把新-skill-轉成文章的標準流程">把新 skill 轉成文章的標準流程&lt;/h2>
&lt;p>以下步驟假設新 skill 已經放在 &lt;code>.claude/skills/&amp;lt;name&amp;gt;/&lt;/code>，包含 &lt;code>SKILL.md&lt;/code> 和（可能的）&lt;code>references/&lt;/code> 子資料夾。&lt;/p>
&lt;h3 id="step-1複製一份到-contentskills">Step 1：複製一份到 content/skills/&lt;/h3>





&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">cp -R .claude/skills/&amp;lt;name&amp;gt; content/skills/&amp;lt;name&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Claude 版保持原樣、不再動它；所有後續修改都只改文章版。&lt;/p>
&lt;h3 id="step-2扁平化-references">Step 2：扁平化 references/&lt;/h3>
&lt;p>Blog 的文章層級為&lt;strong>單層&lt;/strong>，不保留 references 子資料夾。把所有 reference 移到跟 SKILL.md 同一層：&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">&lt;span class="nb">cd&lt;/span> content/skills/&amp;lt;name&amp;gt;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git mv references/*.md .
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">git rm references/.gitkeep &lt;span class="c1"># 如果有&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">rmdir references&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-3把-skillmd-改成小寫-skillmd">Step 3：把 &lt;code>SKILL.md&lt;/code> 改成小寫 &lt;code>skill.md&lt;/code>&lt;/h3>
&lt;p>Hugo pretty URL 會把檔名小寫輸出（&lt;code>SKILL.md&lt;/code> → &lt;code>/skills/&amp;lt;name&amp;gt;/skill/&lt;/code>），但 &lt;code>mdtools cards&lt;/code> 連結檢查以&lt;strong>檔名大小寫敏感&lt;/strong>的方式解析 URL 回檔案位置。若檔案是 &lt;code>SKILL.md&lt;/code>，cards 嘗試開啟 &lt;code>skill.md&lt;/code> 找不到，就會報 &lt;code>L1-broken-link&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<h2 id="這個資料夾是什麼">這個資料夾是什麼</h2>
<p><code>content/skills/</code> 收錄<strong>從 <code>.claude/skills/</code> 轉成文章</strong>的 skill 版本。</p>
<p>同一份 skill 有兩種存在形式，各自負責不同角色：</p>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>角色</th>
          <th>讀者</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.claude/skills/&lt;name&gt;/</code></td>
          <td>實際 skill，Claude runtime 呼叫</td>
          <td>Claude</td>
      </tr>
      <tr>
          <td><code>content/skills/&lt;name&gt;/</code>（本處）</td>
          <td>文章版本，Hugo 渲染成 blog 頁</td>
          <td>人類讀者</td>
      </tr>
  </tbody>
</table>
<p>兩處內容相同、結構略有差異：<code>.claude/</code> 版本保留原始 <code>SKILL.md + references/</code> 巢狀結構以配合 Claude 的路徑解析；<code>content/</code> 版本扁平化（references 內容升級到同層），以契合 blog 的單層文章呈現。</p>
<h2 id="目前收錄的-skill">目前收錄的 skill</h2>
<table>
  <thead>
      <tr>
          <th>Skill</th>
          <th>主題</th>
          <th>入口</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>compositional-writing</td>
          <td>Zettelkasten 式組合寫作方法論</td>
          <td><a href="/blog/skills/compositional-writing/" data-link-title="Compositional Writing — 組合式寫作方法論" data-link-desc="以 Zettelkasten 為核心、針對程式碼註解、文件、log、prompt、欄位、長篇技術文章、外部分析材料轉教學文章、跨多篇 collection 結構的寫作方法論。核心原則 &#43; 觸發路由 &#43; 情境 reference。">/skills/compositional-writing/</a></td>
      </tr>
      <tr>
          <td>requirement-protocol</td>
          <td>需求確認到實作的對話協議（模糊指令、失敗轉折、漸進驗證等）</td>
          <td><a href="/blog/skills/requirement-protocol/" data-link-title="Requirement Protocol — 需求確認到實作的對話協議" data-link-desc="從需求確認到實作的對話協議：模糊指令澄清、可決定 vs 該確認、失敗 2 次轉折、覆寫成本告知、revert checkpoint、漸進驗證、工具切換時機。六大原則 &#43; 五份情境 reference。">/skills/requirement-protocol/</a></td>
      </tr>
      <tr>
          <td>frontend-with-playwright</td>
          <td>框架無關的前端開發 + Playwright 驗證 + Filter × Source 跨領域 stream 操作（DOM / CSS / JS / framework 共處 / a11y / 資料流）</td>
          <td><a href="/blog/skills/frontend-with-playwright/" data-link-title="Frontend with Playwright — 框架無關的前端開發 &#43; Playwright 驗證" data-link-desc="框架無關的前端開發協議 &#43; Playwright 驗證：DOM topology 先於 CSS、CSS / JS 邊界辨識、Playwright 三個位置（假設 / 行為 / 互動驗證）、framework 共處、Reactive 效能、A11y、Filter × Source 跨領域 stream 操作。六大原則 &#43; 七份情境 reference。">/skills/frontend-with-playwright/</a></td>
      </tr>
      <tr>
          <td>wrap-decision</td>
          <td>WRAP 決策框架（錨點確認、資料充足度、擴增選項、實境檢驗、機會成本、行前預想與絆腳索）</td>
          <td><a href="/blog/skills/wrap-decision/" data-link-title="WRAP 決策框架 — 認知偏誤防護與決策品質" data-link-desc="WRAP 決策框架的 blog 好讀版：用錨點確認、資料充足度、選項擴增、現實檢驗、機會成本、行前預想與絆腳索防止自動駕駛式決策。">/skills/wrap-decision/</a></td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="把新-skill-轉成文章的標準流程">把新 skill 轉成文章的標準流程</h2>
<p>以下步驟假設新 skill 已經放在 <code>.claude/skills/&lt;name&gt;/</code>，包含 <code>SKILL.md</code> 和（可能的）<code>references/</code> 子資料夾。</p>
<h3 id="step-1複製一份到-contentskills">Step 1：複製一份到 content/skills/</h3>





<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">cp -R .claude/skills/&lt;name&gt; content/skills/&lt;name&gt;</span></span></code></pre></div><p>Claude 版保持原樣、不再動它；所有後續修改都只改文章版。</p>
<h3 id="step-2扁平化-references">Step 2：扁平化 references/</h3>
<p>Blog 的文章層級為<strong>單層</strong>，不保留 references 子資料夾。把所有 reference 移到跟 SKILL.md 同一層：</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"><span class="nb">cd</span> content/skills/&lt;name&gt;
</span></span><span class="line"><span class="ln">2</span><span class="cl">git mv references/*.md .
</span></span><span class="line"><span class="ln">3</span><span class="cl">git rm references/.gitkeep       <span class="c1"># 如果有</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">rmdir references</span></span></code></pre></div><h3 id="step-3把-skillmd-改成小寫-skillmd">Step 3：把 <code>SKILL.md</code> 改成小寫 <code>skill.md</code></h3>
<p>Hugo pretty URL 會把檔名小寫輸出（<code>SKILL.md</code> → <code>/skills/&lt;name&gt;/skill/</code>），但 <code>mdtools cards</code> 連結檢查以<strong>檔名大小寫敏感</strong>的方式解析 URL 回檔案位置。若檔案是 <code>SKILL.md</code>，cards 嘗試開啟 <code>skill.md</code> 找不到，就會報 <code>L1-broken-link</code>。</p>
<p>避免這個陷阱，把 content 版本的檔名直接改成小寫：</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">git mv content/skills/&lt;name&gt;/SKILL.md content/skills/&lt;name&gt;/skill.md</span></span></code></pre></div><p><code>.claude/</code> 版保留原本的 <code>SKILL.md</code>（符合 Claude skill 慣例）。兩邊檔名不同是刻意的：runtime 讀 <code>.claude/SKILL.md</code>、Hugo 渲染 <code>content/skill.md</code>、cards check 能解析。</p>
<h3 id="step-4修改-skillmd-的內部連結">Step 4：修改 skill.md 的內部連結</h3>
<p>skill.md 裡原本的 <code>references/X.md</code> 引用要改。有兩種合法寫法擇一：</p>
<ul>
<li><strong>Markdown 相對路徑</strong>：<code>./X.md</code> — 最無痛，Hugo render hook 會自動解析到對應頁面</li>
<li><strong>Hugo content-root 絕對路徑</strong>：<code>/skills/&lt;name&gt;/&lt;slug&gt;/</code> — 最穩，跟 blog 其他文章遷移後的寫法一致</li>
</ul>
<p>批次替換範例（將 <code>references/</code> 前綴整串刪除）：</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">sed -i <span class="s1">&#39;&#39;</span> <span class="s1">&#39;s|references/|./|g&#39;</span> content/skills/&lt;name&gt;/skill.md</span></span></code></pre></div><p>記得同時更新 <code>## Directory Index</code> 區塊那張 ASCII tree — 刪掉 <code>└── references/</code> 那一層、所有 reference 檔案縮排提到 skill.md 同層。</p>
<h3 id="step-5建立-_indexmdsection-索引">Step 5：建立 <code>_index.md</code>（section 索引）</h3>
<p><code>content/skills/&lt;name&gt;/_index.md</code> 是 Hugo section 的 landing page，URL 是 <code>/skills/&lt;name&gt;/</code>。</p>
<p>必備欄位（Hugo + mdtools 要求）：</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;&lt;Skill 名稱中英對照&gt;&#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">date</span><span class="p">:</span><span class="w"> </span><span class="l">&lt;YYYY-MM-DD&gt;</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">description</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&lt;一句話描述 skill 做什麼&gt;&#34;</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">tags</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">...]</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>內容建議包含：</p>
<ol>
<li><strong>簡述</strong>：這個 skill 是什麼、解決什麼問題</li>
<li><strong>閱讀順序</strong>：場景 1（第一次接觸）與場景 2（已熟悉、直接解決任務）兩個切入點</li>
<li><strong>觸發路由表</strong>：複製 SKILL.md 的「When to Consult This Skill」表，但把檔案路徑替換成 Hugo 絕對 URL（<code>/skills/&lt;name&gt;/&lt;slug&gt;/</code>）</li>
<li><strong>與 blog 其他資料的關係</strong>：對照表（<code>.claude/skills/&lt;name&gt;/</code>、相關 <code>content/posts/...</code>）</li>
<li><strong>Last Updated</strong>：同步日期與 <code>.claude/</code> 版 SKILL.md 的 version</li>
</ol>
<p>可參考 <a href="https://github.com/tarrragon/blog/blob/main/content/skills/compositional-writing/_index.md">compositional-writing 的 <code>_index.md</code></a> 當範本。</p>
<h3 id="step-6補-hugo-frontmatter-到-skillmd-與每份-reference">Step 6：補 Hugo frontmatter 到 skill.md 與每份 reference</h3>
<p>原始 skill.md 與 reference 的 frontmatter 是為 Claude runtime 設計的（欄位多為 <code>name</code>/<code>license</code>/<code>metadata</code>），Hugo 與 mdtools 需要的是 <code>title</code> + <code>date</code>。每份檔案：</p>
<ol>
<li>把原 frontmatter 保留或替換成 Hugo 規格（<code>title</code>、<code>date</code>、<code>description</code>、<code>tags</code>）</li>
<li>刪掉 body 開頭的 <code># H1</code>（Hugo 會從 <code>title</code> 自動生成 H1，保留會觸發 <code>MD025-no-body-h1</code>）</li>
</ol>
<h3 id="step-7跑-make-check-與-hugo-驗收">Step 7：跑 <code>make check</code> 與 <code>hugo</code> 驗收</h3>





<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">make check    <span class="c1"># fmt + lint + cards，三個閘門全綠</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">hugo          <span class="c1"># 確認無 WARN/ERROR，page count 多了對應檔數</span></span></span></code></pre></div><p>幾個常見訊號與對應的修補點：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>原因</th>
          <th>回頭修</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>lint <code>front-matter-required</code> / <code>MD025</code></td>
          <td>有檔案漏掉 frontmatter 或 body H1 沒刪</td>
          <td>Step 6</td>
      </tr>
      <tr>
          <td>cards <code>L1-broken-link target not found</code> 且路徑是 <code>skill/</code></td>
          <td>SKILL.md 沒改成 skill.md（檔名大小寫）</td>
          <td>Step 3</td>
      </tr>
      <tr>
          <td>hugo <code>REF_NOT_FOUND</code></td>
          <td>連結還指向 <code>references/</code></td>
          <td>Step 4</td>
      </tr>
  </tbody>
</table>
<h3 id="step-8更新本-contentskills_indexmd-的目前收錄表">Step 8：更新本 <code>content/skills/_index.md</code> 的「目前收錄」表</h3>
<p>把新 skill 加入上方的表格，含主題一句話與 Hugo URL。</p>
<h3 id="step-9commit">Step 9：commit</h3>
<p>單一 commit 收尾：</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">git add .claude/skills/&lt;name&gt; content/skills/&lt;name&gt; content/skills/_index.md
</span></span><span class="line"><span class="ln">2</span><span class="cl">git commit -m <span class="s2">&#34;docs(skills): import &lt;name&gt; skill&#34;</span></span></span></code></pre></div><hr>
<h2 id="設計決策備忘">設計決策備忘</h2>
<h3 id="為什麼-claude-保留-referencescontent-扁平">為什麼 <code>.claude/</code> 保留 references/、<code>content/</code> 扁平？</h3>
<p><code>.claude/</code> 是 Claude runtime 的 SKILL 執行環境，SKILL.md 的路徑解析（<code>references/X.md</code>）是 skill 原生協議的一部分 — 改了會破壞 Claude 讀取行為。</p>
<p><code>content/</code> 是 blog 文章，Hugo 的 pretty URL 傾向單層結構（<code>/skills/name/page/</code> 比 <code>/skills/name/references/page/</code> 乾淨）。扁平化也讓 render hook 的 tooltip 與 mdtools 的 card graph 不必穿一層目錄。</p>
<p>兩種結構並行、各自最佳化、用 step 1 的複製動作維持同步。</p>
<h3 id="為什麼不直接-symlink-contentskills--claudeskills">為什麼不直接 symlink <code>content/skills/</code> → <code>.claude/skills/</code>？</h3>
<p>Symlink 會讓兩邊共用 frontmatter 與路徑規範。<code>.claude/</code> 版的 frontmatter 是 skill protocol（<code>name</code>/<code>license</code>/<code>metadata</code>），與 Hugo 要的（<code>title</code>/<code>date</code>）相衝；body 開頭的 <code># H1</code> 是 Claude 讀者的 context signpost，但在 Hugo 會跟 <code>title</code> 生的 H1 重複。結構上看似省事，語意上兩邊是不同受眾的產出，應該允許各自演化。</p>
<hr>
<h2 id="last-updated">Last Updated</h2>
<p>2026-04-24 — 初版：compositional-writing 轉文章完成，記錄標準流程。</p>
]]></content:encoded></item><item><title>什麼是 AST — 從字串到語法樹的視角轉換</title><link>https://tarrragon.github.io/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/</guid><description>&lt;h2 id="為什麼會碰到這個詞">為什麼會碰到這個詞&lt;/h2>
&lt;p>最初的問題很小：blog 文章數量成長後，每次 commit 都會收到 markdownlint 的同類警告反覆出現。有三個代表性的：&lt;/p>
&lt;ul>
&lt;li>&lt;code>MD034/no-bare-urls&lt;/code> — 裸 URL 散落在段落與表格。&lt;/li>
&lt;li>&lt;code>MD024/no-duplicate-heading&lt;/code> — 平行結構章節（例如 13 個案例各自有 &lt;code>### 弱點環節&lt;/code>）全部被判重複。&lt;/li>
&lt;li>&lt;code>MD060/table-column-style&lt;/code> — 表格管線前後空白不一致。&lt;/li>
&lt;/ul>
&lt;p>前兩個用現成工具 &lt;code>--fix&lt;/code> 不一定修得乾淨，因為 &lt;code>MD024&lt;/code> 的「重複」在我們的語境下是&lt;strong>合法的平行結構&lt;/strong>（不同父標題下重名其實是特色），而「裸 URL 轉換」要處理表格儲存格、程式碼區塊等特殊情境，單純 regex 會誤判。&lt;/p>
&lt;p>討論到後來關鍵字出現：&lt;strong>要做得精確，可能要用 AST 工具，而不是 regex 工具&lt;/strong>。&lt;/p>
&lt;p>那麼 AST 到底是什麼？跟我們熟悉的 regex / 字串處理差在哪？&lt;/p>
&lt;h2 id="regex-工具看世界的方式字元序列">Regex 工具看世界的方式：字元序列&lt;/h2>
&lt;p>Regex 工具處理 markdown 的方式是「逐行掃描 + pattern matching」。它看到的是字元流，沒有語法結構的概念。&lt;/p>
&lt;p>舉個例子：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-markdown" data-lang="markdown">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="gu">## 【案例一】Uber 2022
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="gu">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">事件中攻擊者取得 &lt;span class="sb">`session_token`&lt;/span>，參考：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">https://www.uber.com/newsroom/security-update/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">| 事件 | 來源 |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">| --------- | ---------------- |
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">| Uber 2022 | https://uber.com |&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Regex 工具看到的是：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">Line 1: &amp;#34;## 【案例一】Uber 2022&amp;#34; ← ^#{1,6} match → heading
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Line 3: &amp;#34;事件中攻擊者取得 `session_token`...&amp;#34; ← 無 pattern 命中
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">Line 4: &amp;#34;https://www.uber.com/...&amp;#34; ← ^https?:// match → bare URL!
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">Line 7: &amp;#34;| Uber 2022 | https://uber.com |&amp;#34; ← ^\| match → table row&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每一行獨立判讀，沒有上下文。Regex 工具不知道 line 4 到底是「段落的一部分」、「引用區塊裡的連結」、還是「程式碼範例」。它只看 pattern。&lt;/p>
&lt;h2 id="ast-工具看世界的方式語法樹">AST 工具看世界的方式：語法樹&lt;/h2>
&lt;p>AST = Abstract Syntax Tree，抽象語法樹。AST 工具先把整段 markdown 用 parser &lt;strong>解析成結構化的樹&lt;/strong>，然後工具在樹上走訪（traverse），操作「節點」而不是「行」。&lt;/p>
&lt;p>同一段 markdown，goldmark（Hugo 內建的 markdown parser）解析後的樹大致是：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">Document
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">├── Heading (level=2)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">│ └── Text: &amp;#34;【案例一】Uber 2022&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">├── Paragraph
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">│ ├── Text: &amp;#34;事件中攻擊者取得 &amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">│ ├── CodeSpan: &amp;#34;session_token&amp;#34; ← 知道這是 inline code
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">│ ├── Text: &amp;#34;，參考：&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">│ └── AutoLink: &amp;#34;https://www.uber.com/...&amp;#34; ← 知道這是段落中的裸 URL
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">└── Table
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> ├── TableHeader: [事件, 來源]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> └── TableRow
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> ├── TableCell: &amp;#34;Uber 2022&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> └── TableCell: AutoLink &amp;#34;https://uber.com&amp;#34; ← 知道這是表格儲存格中的 URL&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>對同一個 URL，AST 工具能分辨「它在段落裡」還是「它在表格儲存格裡」還是「它在程式碼區塊裡」— 因為節點的父子關係已經是樹的一部分。&lt;/p></description><content:encoded><![CDATA[<h2 id="為什麼會碰到這個詞">為什麼會碰到這個詞</h2>
<p>最初的問題很小：blog 文章數量成長後，每次 commit 都會收到 markdownlint 的同類警告反覆出現。有三個代表性的：</p>
<ul>
<li><code>MD034/no-bare-urls</code> — 裸 URL 散落在段落與表格。</li>
<li><code>MD024/no-duplicate-heading</code> — 平行結構章節（例如 13 個案例各自有 <code>### 弱點環節</code>）全部被判重複。</li>
<li><code>MD060/table-column-style</code> — 表格管線前後空白不一致。</li>
</ul>
<p>前兩個用現成工具 <code>--fix</code> 不一定修得乾淨，因為 <code>MD024</code> 的「重複」在我們的語境下是<strong>合法的平行結構</strong>（不同父標題下重名其實是特色），而「裸 URL 轉換」要處理表格儲存格、程式碼區塊等特殊情境，單純 regex 會誤判。</p>
<p>討論到後來關鍵字出現：<strong>要做得精確，可能要用 AST 工具，而不是 regex 工具</strong>。</p>
<p>那麼 AST 到底是什麼？跟我們熟悉的 regex / 字串處理差在哪？</p>
<h2 id="regex-工具看世界的方式字元序列">Regex 工具看世界的方式：字元序列</h2>
<p>Regex 工具處理 markdown 的方式是「逐行掃描 + pattern matching」。它看到的是字元流，沒有語法結構的概念。</p>
<p>舉個例子：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln">1</span><span class="cl"><span class="gu">## 【案例一】Uber 2022
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">事件中攻擊者取得 <span class="sb">`session_token`</span>，參考：
</span></span><span class="line"><span class="ln">4</span><span class="cl">https://www.uber.com/newsroom/security-update/
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">| 事件      | 來源             |
</span></span><span class="line"><span class="ln">7</span><span class="cl">| --------- | ---------------- |
</span></span><span class="line"><span class="ln">8</span><span class="cl">| Uber 2022 | https://uber.com |</span></span></code></pre></div><p>Regex 工具看到的是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Line 1: &#34;## 【案例一】Uber 2022&#34;            ← ^#{1,6} match  → heading
</span></span><span class="line"><span class="ln">2</span><span class="cl">Line 3: &#34;事件中攻擊者取得 `session_token`...&#34;  ← 無 pattern 命中
</span></span><span class="line"><span class="ln">3</span><span class="cl">Line 4: &#34;https://www.uber.com/...&#34;            ← ^https?:// match → bare URL!
</span></span><span class="line"><span class="ln">4</span><span class="cl">Line 7: &#34;| Uber 2022 | https://uber.com |&#34;   ← ^\| match     → table row</span></span></code></pre></div><p>每一行獨立判讀，沒有上下文。Regex 工具不知道 line 4 到底是「段落的一部分」、「引用區塊裡的連結」、還是「程式碼範例」。它只看 pattern。</p>
<h2 id="ast-工具看世界的方式語法樹">AST 工具看世界的方式：語法樹</h2>
<p>AST = Abstract Syntax Tree，抽象語法樹。AST 工具先把整段 markdown 用 parser <strong>解析成結構化的樹</strong>，然後工具在樹上走訪（traverse），操作「節點」而不是「行」。</p>
<p>同一段 markdown，goldmark（Hugo 內建的 markdown parser）解析後的樹大致是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">Document
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── Heading (level=2)
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">│   └── Text: &#34;【案例一】Uber 2022&#34;
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── Paragraph
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   ├── Text: &#34;事件中攻擊者取得 &#34;
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── CodeSpan: &#34;session_token&#34;            ← 知道這是 inline code
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   ├── Text: &#34;，參考：&#34;
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">│   └── AutoLink: &#34;https://www.uber.com/...&#34;  ← 知道這是段落中的裸 URL
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">└── Table
</span></span><span class="line"><span class="ln">10</span><span class="cl">    ├── TableHeader: [事件, 來源]
</span></span><span class="line"><span class="ln">11</span><span class="cl">    └── TableRow
</span></span><span class="line"><span class="ln">12</span><span class="cl">        ├── TableCell: &#34;Uber 2022&#34;
</span></span><span class="line"><span class="ln">13</span><span class="cl">        └── TableCell: AutoLink &#34;https://uber.com&#34;  ← 知道這是表格儲存格中的 URL</span></span></code></pre></div><p>對同一個 URL，AST 工具能分辨「它在段落裡」還是「它在表格儲存格裡」還是「它在程式碼區塊裡」— 因為節點的父子關係已經是樹的一部分。</p>
<p>這個差異乍看像技術細節，實際影響的是能寫出什麼樣的規則。</p>
<h2 id="典型意外情境regex-會誤判的三個-case">典型意外情境：regex 會誤判的三個 case</h2>
<h3 id="程式碼區塊內的-url">程式碼區塊內的 URL</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln">1</span><span class="cl"><span class="gu">## 測試範例
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">```bash
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s"></span>curl https://example.com/api  <span class="c1"># 這是程式碼範例，不該報 MD034</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s">```</span></span></span></code></pre></div><p>Regex 看到 <code>https://</code> 開頭就標記裸 URL。AST 知道這一行在 <code>FencedCodeBlock</code> 子樹內，跳過。</p>
<h3 id="front-matter-裡的--被當-heading">Front matter 裡的 <code>#</code> 被當 heading</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln">1</span><span class="cl">---
</span></span><span class="line"><span class="ln">2</span><span class="cl">title: &#34;Python 的 # 註解語法&#34;
</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></span><span class="line"><span class="ln">5</span><span class="cl">真正的文章內容...</span></span></code></pre></div><p>Regex 看到 <code>^#</code> 就當 heading 記一筆（title 裡面有 <code>#</code> 字元）。AST 知道 <code>---...---</code> 區塊是 YAML front matter，title 的值是字串。</p>
<h3 id="平行結構標題被誤判為重複">平行結構標題被誤判為重複</h3>
<p>在多案例教材裡：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln">1</span><span class="cl"><span class="gu">## 【案例一】Uber 2022
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="gu">### 弱點環節 ← 第 1 次出現
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="gu">### 攻擊路徑
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="gu">## 【案例二】Okta 2023
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">### 弱點環節 ← 第 2 次出現，regex 會直接報重複</span></span></code></pre></div><p>要用 regex 實作「不同父標題下允許重複」這種 <code>siblings_only</code> 規則，需要自己維護狀態機追蹤「目前 H2 是誰」「遇到 H3 時算哪個 H2 底下」。遇到 H4/H5 階層更複雜。</p>
<p>用 AST，父子關係已經內建在樹結構裡：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 偽代碼，實際用 goldmark walker 取代</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">h2</span> <span class="o">:=</span> <span class="k">range</span> <span class="nf">allHeadingsAtLevel</span><span class="p">(</span><span class="nx">doc</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">children</span> <span class="o">:=</span> <span class="nf">childrenOfType</span><span class="p">(</span><span class="nx">h2</span><span class="p">,</span> <span class="nx">Heading</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">checkDuplicates</span><span class="p">(</span><span class="nx">children</span><span class="p">)</span>  <span class="c1">// 自動只比對同一 H2 下的子標題</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>不用追蹤狀態，邏輯上直接表達。</p>
<h2 id="為什麼對我們特別重要goldmark--hugo-的-parser">為什麼對我們特別重要：goldmark = Hugo 的 parser</h2>
<p>Hugo（blog 的 static site generator）內建的 markdown parser 就是 goldmark。用 goldmark 寫 lint 有個平凡但關鍵的保證：<strong>lint 的判讀跟 Hugo render 的判讀完全一致</strong>。</p>
<p>如果用不同的 parser 寫 lint（例如 Python 的 <code>mistune</code>、JavaScript 的 <code>markdown-it</code>），很可能遇到這種尷尬：</p>
<ul>
<li>Lint 通過，但 Hugo 解析不出來，render 失敗或跑版。</li>
<li>Lint 報錯，但 Hugo 看得懂、實際沒有問題。</li>
</ul>
<p>兩套 parser 解讀差異是長尾 bug 的溫床。用同一個 parser 可以從源頭杜絕這類不一致。</p>
<h2 id="什麼時候-ast-不是必要的">什麼時候 AST 不是必要的</h2>
<p>不要為了「比較先進」就上 AST。Regex 在下列情境完全夠用：</p>
<ul>
<li>檢查每行開頭字元（<code>^#</code>、<code>^|</code>、<code>^- </code>）。</li>
<li>簡單字串替換（例如 URL 前後加 <code>&lt;&gt;</code> 包裹）。</li>
<li>不需要知道上下文的格式正規化（行尾空白、tab 轉空白）。</li>
</ul>
<p>需要 AST 才能穩定做到的是：</p>
<ul>
<li>判斷「這段文字在 code block 內嗎？」</li>
<li>判斷「這個 heading 的父 heading 是誰？」</li>
<li>追蹤跨文件的連結關係（卡片 backlink 完整性）。</li>
<li>檢查「這個 Strong 節點是不是整個段落的唯一子節點？」（MD036 粗體當標題濫用）</li>
</ul>
<p>一個實務判準：<strong>如果 rule 需要「知道這段文字處在什麼結構中」，regex 會卡住；AST 天生就有這個資訊。</strong></p>
<h2 id="我們的判斷什麼時機該升級到-ast">我們的判斷：什麼時機該升級到 AST</h2>
<p>blog 專案一開始也考慮過用 Python + regex 先頂著，等規則變複雜再升級 Go + goldmark。後來有兩件事讓我們直接選 AST：</p>
<ol>
<li><strong>MD024 siblings_only</strong> 已經是「需要上下文」的規則，regex 做得到但會寫得脆弱。</li>
<li><strong>知識卡片雙向完整性</strong>是當前在做的工作（每張卡片要被正文連到、每張卡片首段要有鄰卡連結），這類<strong>跨文件 + 段落歸屬</strong>的檢查，regex 做不出來。</li>
</ol>
<p>當需求已經在手上，延遲決策反而更貴。對我們來說，AST 不是超前部署，是<strong>現在的 blocker</strong>。</p>
<h2 id="延伸閱讀">延伸閱讀</h2>
<ul>
<li><a href="/blog/posts/blog-markdown-%E5%AF%AB%E4%BD%9C%E8%A6%8F%E7%AF%84%E8%88%87-mdtools-%E6%AA%A2%E6%9F%A5/" data-link-title="Blog Markdown 寫作規範與 mdtools 檢查" data-link-desc="本 blog 的 Markdown 排版規範權威契約。涵蓋 H1 禁用、MD024 siblings_only、反釣魚 TLD 校驗、卡片雙向完整性、front matter schema；改規則時要與 scripts/mdtools 實作同步。">Blog Markdown 寫作規範與 mdtools 檢查</a> — 所有規則的正式契約</li>
<li><a href="/blog/posts/mdtoolsgo--goldmark-%E7%9A%84-markdown-%E5%B7%A5%E5%85%B7%E9%8F%88%E8%A8%AD%E8%A8%88/" data-link-title="mdtools：Go &#43; goldmark 的 markdown 工具鏈設計" data-link-desc="mdtools 的架構決策：選 Go &#43; goldmark 的理由（與 Hugo 同源保證 lint↔render 等價）、單 binary 多子命令設計、pre-commit 整合、規則開啟紀律。">mdtools：Go + goldmark 的 markdown 工具鏈設計</a> — 如何把 AST 能力組裝成 pre-commit hook</li>
<li><a href="https://github.com/yuin/goldmark">goldmark 官方 repo</a> — Hugo 所用的 markdown parser</li>
</ul>
]]></content:encoded></item></channel></rss>