<?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>Goldmark on Tarragon</title><link>https://tarrragon.github.io/blog/tags/goldmark/</link><description>Recent content in Goldmark 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/goldmark/index.xml" rel="self" type="application/rss+xml"/><item><title>Blog Markdown 寫作規範與 mdtools 檢查</title><link>https://tarrragon.github.io/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/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/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/</guid><description>&lt;h2 id="這篇要解決什麼">這篇要解決什麼&lt;/h2>
&lt;p>隨著 blog 文章與知識卡片成長，純靠寫作紀律維持排版一致性越來越不可靠。反覆踩到的問題橫跨兩個層級：&lt;/p>
&lt;p>&lt;strong>結構與安全層級&lt;/strong>（這是工具鏈存在的主要理由）：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>裸 URL 在段落與表格中爆版&lt;/strong>（MD034），降低閱讀體驗。&lt;/li>
&lt;li>&lt;strong>表格管線風格混用&lt;/strong>（MD060），同一張表格有的有空白、有的沒有。&lt;/li>
&lt;li>&lt;strong>平行模板章節重複標題&lt;/strong>（MD024），例如多案例文章的 &lt;code>### 弱點環節&lt;/code> 出現 13 次。&lt;/li>
&lt;li>&lt;strong>顯示文字與實際 href 不一致&lt;/strong>（反釣魚）— 不在標準 markdownlint 規則內，但紅隊教材脈絡下必要。&lt;/li>
&lt;li>&lt;strong>卡片雙向完整性&lt;/strong>（orphan 卡片、斷連結、K4 合規）— 跨文件檢查，現成工具做不到。&lt;/li>
&lt;li>&lt;strong>Front matter schema&lt;/strong> — Hugo 依賴 YAML front matter 提供 title / date / weight 等欄位，缺失會破壞列表渲染、排序、SEO。&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>基礎格式層級&lt;/strong>（容易被忽略但影響 parser 穩定性或語義結構）：&lt;/p>
&lt;ul>
&lt;li>正文禁止使用 H1（嚴於 MD025）— Hugo front matter &lt;code>title&lt;/code> 已產生 H1。&lt;/li>
&lt;li>標題前後需保留空行（MD022），parser 才能正確識別標題邊界。&lt;/li>
&lt;li>標題結尾禁止標點（MD026）— 例如 &lt;code>## 常見問題：&lt;/code> 應改為 &lt;code>## 常見問題&lt;/code>。&lt;/li>
&lt;li>禁止用 &lt;code>**bold**&lt;/code> 段落當標題（MD036）— 破壞語義階層與 TOC 產生。&lt;/li>
&lt;li>程式碼區塊需註明語言（MD040），影響 syntax highlighting 與 accessibility。&lt;/li>
&lt;li>列表前後需空行（MD032）、fenced code block 前後需空行（MD031）— 否則部分 parser 會把列表吃進段落。&lt;/li>
&lt;li>有序列表編號風格一致（MD029）— 全部 &lt;code>1.&lt;/code> 或全部 &lt;code>1./2./3.&lt;/code>。&lt;/li>
&lt;li>檔案結尾需有換行（MD047），POSIX 規範。&lt;/li>
&lt;li>行長度上限（MD013）— &lt;strong>預設關閉&lt;/strong>，中英混用技術寫作不適用 80-char 慣例。&lt;/li>
&lt;/ul>
&lt;p>前兩類混合在同一份寫作規範裡，因為都由同一個工具鏈檢查、都要落地到相同的 pre-commit hook。純靠紀律記住這十幾條在大型 repo 上不可行，純 regex 又無法穩定處理「平行結構下的標題重複」「卡片段落歸屬」這類語意判斷。因此 blog 專案採用 Go + goldmark AST 做自訂 linter：&lt;code>scripts/mdtools&lt;/code>。本文是 linter 與寫作規範的對齊文件；AGENTS.md 引用本文作為排版規範來源。&lt;/p>
&lt;hr>
&lt;h2 id="1-工具總覽">1. 工具總覽&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>子命令&lt;/th>
 &lt;th>職責&lt;/th>
 &lt;th>改檔&lt;/th>
 &lt;th>觸發時機&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>mdtools fmt [--fix|--check]&lt;/code>&lt;/td>
 &lt;td>格式正規化（URL、表格、空行、列表間距、trailing newline）&lt;/td>
 &lt;td>&lt;code>--fix&lt;/code> 會改&lt;/td>
 &lt;td>pre-commit（&lt;code>--fix&lt;/code>）、pre-push / CI（&lt;code>--check&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>mdtools lint&lt;/code>&lt;/td>
 &lt;td>結構檢查（標題、反釣魚、code block 語言、front matter schema）&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>pre-commit、pre-push、CI&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>mdtools cards&lt;/code>&lt;/td>
 &lt;td>跨文件完整性（連結、orphan、K4）&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>pre-commit、pre-push、CI&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>工具原始碼在 &lt;code>scripts/mdtools/&lt;/code>，binary build 到 &lt;code>bin/mdtools&lt;/code>（已 gitignore）。&lt;/p>
&lt;p>作用範圍是 &lt;code>content/**/*.md&lt;/code>。&lt;code>public/&lt;/code>、&lt;code>themes/&lt;/code>、&lt;code>node_modules/&lt;/code> 等輸出或第三方資源不檢查。&lt;/p>
&lt;hr>
&lt;h2 id="2-標題規則">2. 標題規則&lt;/h2>
&lt;h3 id="21-標題結構與格式規則">2.1 標題結構與格式規則&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>正文禁止使用 H1&lt;/strong>。Hugo 的 front matter &lt;code>title&lt;/code> 會自動產生 H1，若正文再寫 &lt;code># ...&lt;/code> 會出現兩個 H1 並列，破壞語義階層與 SEO 訊號。正文一律從 H2 開始，最深到 H6。&lt;/li>
&lt;li>&lt;strong>同一父標題（直接上層）底下，子標題文字必須唯一&lt;/strong>（MD024 siblings_only 模式）。&lt;/li>
&lt;li>不同父標題底下，子標題允許重名。&lt;/li>
&lt;li>標題前後需保留空行（MD022），&lt;code>mdtools fmt --fix&lt;/code> 自動補。&lt;/li>
&lt;li>&lt;strong>標題結尾禁止標點&lt;/strong>（MD026）— 禁用字元：&lt;code>.&lt;/code>、&lt;code>,&lt;/code>、&lt;code>:&lt;/code>、&lt;code>;&lt;/code>、&lt;code>。&lt;/code>、&lt;code>，&lt;/code>、&lt;code>：&lt;/code>、&lt;code>；&lt;/code>。允許 &lt;code>?&lt;/code>、&lt;code>！&lt;/code>、&lt;code>？&lt;/code>、&lt;code>!&lt;/code> 作為語氣結尾。&lt;code>mdtools fmt --fix&lt;/code> 自動去除結尾禁用標點。&lt;/li>
&lt;li>&lt;strong>禁止用粗體當標題&lt;/strong>（MD036）— 若段落整段只由 &lt;code>**文字**&lt;/code> 或 &lt;code>*文字*&lt;/code> 組成，視為視覺性標題濫用。&lt;code>mdtools lint&lt;/code> 只報警、不自動修；作者需手動判斷正確的標題層級（通常是 H3 / H4）並改寫。&lt;/li>
&lt;/ul>
&lt;h3 id="22-補充範例md026-與-md036-的典型誤用">2.2 補充範例：MD026 與 MD036 的典型誤用&lt;/h3>
&lt;p>MD026（標題尾標點）常見誤用：&lt;/p></description><content:encoded><![CDATA[<h2 id="這篇要解決什麼">這篇要解決什麼</h2>
<p>隨著 blog 文章與知識卡片成長，純靠寫作紀律維持排版一致性越來越不可靠。反覆踩到的問題橫跨兩個層級：</p>
<p><strong>結構與安全層級</strong>（這是工具鏈存在的主要理由）：</p>
<ul>
<li><strong>裸 URL 在段落與表格中爆版</strong>（MD034），降低閱讀體驗。</li>
<li><strong>表格管線風格混用</strong>（MD060），同一張表格有的有空白、有的沒有。</li>
<li><strong>平行模板章節重複標題</strong>（MD024），例如多案例文章的 <code>### 弱點環節</code> 出現 13 次。</li>
<li><strong>顯示文字與實際 href 不一致</strong>（反釣魚）— 不在標準 markdownlint 規則內，但紅隊教材脈絡下必要。</li>
<li><strong>卡片雙向完整性</strong>（orphan 卡片、斷連結、K4 合規）— 跨文件檢查，現成工具做不到。</li>
<li><strong>Front matter schema</strong> — Hugo 依賴 YAML front matter 提供 title / date / weight 等欄位，缺失會破壞列表渲染、排序、SEO。</li>
</ul>
<p><strong>基礎格式層級</strong>（容易被忽略但影響 parser 穩定性或語義結構）：</p>
<ul>
<li>正文禁止使用 H1（嚴於 MD025）— Hugo front matter <code>title</code> 已產生 H1。</li>
<li>標題前後需保留空行（MD022），parser 才能正確識別標題邊界。</li>
<li>標題結尾禁止標點（MD026）— 例如 <code>## 常見問題：</code> 應改為 <code>## 常見問題</code>。</li>
<li>禁止用 <code>**bold**</code> 段落當標題（MD036）— 破壞語義階層與 TOC 產生。</li>
<li>程式碼區塊需註明語言（MD040），影響 syntax highlighting 與 accessibility。</li>
<li>列表前後需空行（MD032）、fenced code block 前後需空行（MD031）— 否則部分 parser 會把列表吃進段落。</li>
<li>有序列表編號風格一致（MD029）— 全部 <code>1.</code> 或全部 <code>1./2./3.</code>。</li>
<li>檔案結尾需有換行（MD047），POSIX 規範。</li>
<li>行長度上限（MD013）— <strong>預設關閉</strong>，中英混用技術寫作不適用 80-char 慣例。</li>
</ul>
<p>前兩類混合在同一份寫作規範裡，因為都由同一個工具鏈檢查、都要落地到相同的 pre-commit hook。純靠紀律記住這十幾條在大型 repo 上不可行，純 regex 又無法穩定處理「平行結構下的標題重複」「卡片段落歸屬」這類語意判斷。因此 blog 專案採用 Go + goldmark AST 做自訂 linter：<code>scripts/mdtools</code>。本文是 linter 與寫作規範的對齊文件；AGENTS.md 引用本文作為排版規範來源。</p>
<hr>
<h2 id="1-工具總覽">1. 工具總覽</h2>
<table>
  <thead>
      <tr>
          <th>子命令</th>
          <th>職責</th>
          <th>改檔</th>
          <th>觸發時機</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>mdtools fmt [--fix|--check]</code></td>
          <td>格式正規化（URL、表格、空行、列表間距、trailing newline）</td>
          <td><code>--fix</code> 會改</td>
          <td>pre-commit（<code>--fix</code>）、pre-push / CI（<code>--check</code>）</td>
      </tr>
      <tr>
          <td><code>mdtools lint</code></td>
          <td>結構檢查（標題、反釣魚、code block 語言、front matter schema）</td>
          <td>否</td>
          <td>pre-commit、pre-push、CI</td>
      </tr>
      <tr>
          <td><code>mdtools cards</code></td>
          <td>跨文件完整性（連結、orphan、K4）</td>
          <td>否</td>
          <td>pre-commit、pre-push、CI</td>
      </tr>
  </tbody>
</table>
<p>工具原始碼在 <code>scripts/mdtools/</code>，binary build 到 <code>bin/mdtools</code>（已 gitignore）。</p>
<p>作用範圍是 <code>content/**/*.md</code>。<code>public/</code>、<code>themes/</code>、<code>node_modules/</code> 等輸出或第三方資源不檢查。</p>
<hr>
<h2 id="2-標題規則">2. 標題規則</h2>
<h3 id="21-標題結構與格式規則">2.1 標題結構與格式規則</h3>
<ul>
<li><strong>正文禁止使用 H1</strong>。Hugo 的 front matter <code>title</code> 會自動產生 H1，若正文再寫 <code># ...</code> 會出現兩個 H1 並列，破壞語義階層與 SEO 訊號。正文一律從 H2 開始，最深到 H6。</li>
<li><strong>同一父標題（直接上層）底下，子標題文字必須唯一</strong>（MD024 siblings_only 模式）。</li>
<li>不同父標題底下，子標題允許重名。</li>
<li>標題前後需保留空行（MD022），<code>mdtools fmt --fix</code> 自動補。</li>
<li><strong>標題結尾禁止標點</strong>（MD026）— 禁用字元：<code>.</code>、<code>,</code>、<code>:</code>、<code>;</code>、<code>。</code>、<code>，</code>、<code>：</code>、<code>；</code>。允許 <code>?</code>、<code>！</code>、<code>？</code>、<code>!</code> 作為語氣結尾。<code>mdtools fmt --fix</code> 自動去除結尾禁用標點。</li>
<li><strong>禁止用粗體當標題</strong>（MD036）— 若段落整段只由 <code>**文字**</code> 或 <code>*文字*</code> 組成，視為視覺性標題濫用。<code>mdtools lint</code> 只報警、不自動修；作者需手動判斷正確的標題層級（通常是 H3 / H4）並改寫。</li>
</ul>
<h3 id="22-補充範例md026-與-md036-的典型誤用">2.2 補充範例：MD026 與 MD036 的典型誤用</h3>
<p>MD026（標題尾標點）常見誤用：</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">#### 字型選擇說明：        ← 違規（結尾 `：`）
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="gu"></span>#### 字型選擇說明          ← 合法</span></span></code></pre></div><p>中文寫作習慣用冒號引入後續內容，這個模式在「段首句」合理、在「標題」就不合理 — 標題本身的存在就暗示了後續有內容，冒號變成冗餘訊號。</p>
<p>MD036（粗體當標題）常見誤用：</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></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></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">### 字型選擇說明           ← 合法：用正式的 H3 取代</span></span></code></pre></div><p>差異看起來微小，實際影響包含：Hugo TOC 不會抓到、卡片反向連結失效、screen reader 無法跳轉。這是「語義 vs 視覺」錯位的典型案例，AST linter 容易檢出（Paragraph 節點唯一子節點為 Strong/Emph）。</p>
<h3 id="23-為什麼採-siblings_only-而非全域唯一">2.3 為什麼採 siblings_only 而非全域唯一</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">### 攻擊路徑
</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">## 【案例二】Okta 2023
</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"></span>### 攻擊路徑</span></span></code></pre></div><p>重名只有在同層並列時才代表結構錯誤。強制全域唯一會逼作者寫 <code>### 【案例二】弱點環節</code>，破壞平行結構的視覺一致性，收益並不大。</p>
<hr>
<h2 id="3-url-與連結規則">3. URL 與連結規則</h2>
<h3 id="31-裸-url-轉換mdtools-fmt---fix-自動處理">3.1 裸 URL 轉換（<code>mdtools fmt --fix</code> 自動處理）</h3>
<p>段落或表格儲存格內的裸 URL 會自動包成 markdown 連結。顯示文字依路徑可識別性分級：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>顯示文字</th>
          <th>範例（before → after）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>路徑含識別碼（例如 CVE）</td>
          <td><code>domain.com/識別碼</code></td>
          <td><code>https://nvd.nist.gov/vuln/detail/CVE-2023-34362</code> → <code>[nvd.nist.gov/CVE-2023-34362](https://nvd.nist.gov/vuln/detail/CVE-2023-34362)</code></td>
      </tr>
      <tr>
          <td>路徑冗長但無識別性</td>
          <td><code>domain.com</code></td>
          <td><code>https://www.cisa.gov/news-events/alerts/2024/06/03/snowflake-recommends-...</code> → <code>[cisa.gov](https://www.cisa.gov/news-events/alerts/2024/06/03/snowflake-recommends-...)</code></td>
      </tr>
      <tr>
          <td>已是 markdown 連結</td>
          <td>不動</td>
          <td>—</td>
      </tr>
  </tbody>
</table>
<p>識別碼偵測用 regex 白名單，初始清單專注在高頻識別碼格式（例如 <code>CVE-YYYY-N</code>），其他格式以「遇到再加」原則擴充。清單維護在 <code>scripts/mdtools/internal/rules/identifiers.go</code>。</p>
<h3 id="32-反釣魚校驗mdtools-lint-強制檢查">3.2 反釣魚校驗（<code>mdtools lint</code> 強制檢查）</h3>
<p>Markdown 語法允許顯示文字與實際 href 完全不符，這是釣魚攻擊的結構基礎。本規則在 AST 層阻擋此模式。</p>
<ul>
<li><strong>R-URL-1（URL 樣顯示文字一致性）</strong>：若顯示文字含 <code>.com</code> / <code>.org</code> / <code>.gov</code> / <code>.net</code> / <code>.io</code> / <code>.dev</code> / <code>.tw</code> 等 TLD 字樣，則顯示文字的 domain 必須等於 href 的 domain（含子網域比對）。</li>
<li><strong>R-URL-2（描述型顯示文字自由）</strong>：顯示文字不含 TLD 字樣時，視為人類可讀描述，不做 domain 比對。</li>
</ul>
<p>違規範例（會被 lint 阻擋）：</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="nt">nvd.nist.gov</span>](<span class="na">https://malicious.example.com/fake</span>)     ← 顯示文字暗示 NVD，href 卻不是
</span></span><span class="line"><span class="ln">2</span><span class="cl">[<span class="nt">cisa.gov/advisory</span>](<span class="na">https://cisa-gov.evil.example</span>)     ← 顯示文字抄 CISA 格式，domain 不符</span></span></code></pre></div><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="nt">Uber 事件公告</span>](<span class="na">https://www.uber.com/newsroom/security-update/</span>)
</span></span><span class="line"><span class="ln">2</span><span class="cl">[<span class="nt">nvd.nist.gov/CVE-2023-34362</span>](<span class="na">https://nvd.nist.gov/vuln/detail/CVE-2023-34362</span>)</span></span></code></pre></div><p>這條規則在紅隊 / 安全相關教材中特別重要：讀者本來就該對來源警戒，排版規則不該削弱這個警戒訊號。縮短顯示文字提升可讀性，反釣魚校驗守住安全底線，兩者互補。</p>
<h3 id="33-例外情境">3.3 例外情境</h3>
<ul>
<li><strong>程式碼區塊</strong>（fenced code block，<code>```</code> 包圍）內的 URL <strong>不做任何處理</strong>（不縮短、不校驗）。代碼範例經常需要展示完整 URL 給讀者複製執行。</li>
<li><strong>引用區塊</strong>（<code>&gt;</code> 開頭）內的 URL <strong>比照段落處理</strong>，會縮短也會做反釣魚校驗。</li>
</ul>
<hr>
<h2 id="4-表格規則">4. 表格規則</h2>
<ul>
<li>統一使用 <strong>aligned 風格</strong>：每欄內容用空白補齊到該欄的最大寬度，使 <code>|</code> 在 monospace 渲染下垂直對齊。</li>
<li>欄位分隔線使用 <code>| --- |</code> 形式，不含對齊冒號 <code>:</code>（分隔線內的 <code>-</code> 數量跟隨該欄寬度自動填足）。</li>
<li>寬度計算使用顯示寬度（display width）— CJK 字元佔 2 欄寬、ASCII 佔 1 欄寬，分隔列與資料列按同一套寬度對齊。</li>
<li><code>mdtools fmt --fix</code> 自動正規化：插入新行或改動欄寬時會全表重算，作者不需手工維持對齊。</li>
</ul>
<p>選 aligned 而非 compact 的理由是<strong>原始檔可讀性</strong>：技術教材的表格常需在 code review 裡對照，aligned 風格讓 reviewer 直接看出哪些欄位對應哪些內容，不用在腦中解析鋸齒狀的 pipes。手工對齊在長表格反覆編輯時確實會失效（新增一行就全表要重對齊），但這正是 <code>mdtools fmt --fix</code> 接手的地方。</p>
<hr>
<h2 id="5-基礎格式細節">5. 基礎格式細節</h2>
<p>這節整理容易被忽略、但會影響 parser 正確性或渲染品質的小規則。</p>
<h3 id="51-程式碼區塊必須註明語言md040">5.1 程式碼區塊必須註明語言（MD040）</h3>
<p>由 <code>mdtools lint</code> 檢查。未註明語言的 fenced code block 會被報警：</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="sb">`                   ← 違規：缺語言標示
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="sb">func main() {
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="sb">    fmt.Println(&#34;hi&#34;)
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="sb">}
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="sb">`</span>`<span class="sb">`
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="sb">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="sb">`</span>`<span class="sb">`go                 ← 合法
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="sb">func main() {
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="sb">    fmt.Println(&#34;hi&#34;)
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="sb">}
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="sb">`</span>``</span></span></code></pre></div><p>純文字輸出（例如 terminal output、log 片段）使用 <code>text</code> 或 <code>plain</code>：</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="s">```text
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s"></span>Error: permission denied
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">```</span></span></span></code></pre></div><p>Shell 範例統一用 <code>bash</code>（即使是 zsh 語法，讓 syntax highlighter 有合理預設）；純設定檔依實際格式（<code>toml</code>、<code>yaml</code>、<code>json</code>、<code>ini</code>）。</p>
<h3 id="52-fenced-code-block-前後需空行md031">5.2 fenced code block 前後需空行（MD031）</h3>
<p>由 <code>mdtools fmt --fix</code> 自動處理。缺空行會讓前後段落被 parser 併入 code block 或反之。</p>
<h3 id="53-列表前後需空行md032">5.3 列表前後需空行（MD032）</h3>
<p>由 <code>mdtools fmt --fix</code> 自動處理。</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></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">-</span> 列表項一           ← 違規：列表前無空行，會被部分 parser 當段落延續
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">-</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></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 class="k">-</span> 列表項一           ← 合法
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">-</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></span></code></pre></div><h3 id="54-有序列表編號一致性md029">5.4 有序列表編號一致性（MD029）</h3>
<p>由 <code>mdtools fmt --fix</code> 正規化。本專案採 <code>ordered</code> 風格（全部遞增編號）：</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="k">1.</span> 第一步
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">2.</span> 第二步           ← 合法
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">3.</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="k">1.</span> 第一步
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">1.</span> 第二步           ← 違規：混用風格（fmt --fix 會改成 1./2./3.）
</span></span><span class="line"><span class="ln">7</span><span class="cl">2. 第三步</span></span></code></pre></div><p>選擇 <code>ordered</code> 的理由：原始檔可讀性高，作者直接看到步驟數；插入新項目的對齊代價比全部重新渲染低。</p>
<h3 id="55-段落間空行">5.5 段落間空行</h3>
<p>段落之間、標題前後、列表與段落之間都需空行。<code>mdtools fmt --fix</code> 會自動規範化多餘 / 缺失的空行，作者不需手工維護。</p>
<h3 id="56-檔案結尾需有換行md047">5.6 檔案結尾需有換行（MD047）</h3>
<p>POSIX 文字檔規範；缺失時 git diff 會出現 <code>\ No newline at end of file</code>。<code>mdtools fmt --fix</code> 自動補。</p>
<h3 id="57-tab-字元md010-僅限-fenced-code-block">5.7 Tab 字元（MD010）— 僅限 fenced code block</h3>
<p>由 <code>mdtools lint</code> 檢查（warn 等級）。Prose / 列表 / 表格 / 引用等非 code-block 行內若出現 tab 字元，會被標記並建議改成空白；fenced code block 內的 tab 保留（Go 原始碼依 gofmt 慣例用 tab，文章要讓讀者能直接複製貼用）。</p>
<p>Repo 根目錄的 <code>.markdownlint.json</code> 用 <code>&quot;MD010&quot;: { &quot;code_blocks&quot;: false }</code> 告知 IDE 的 markdownlint extension 採用同一套 policy，讓編輯器跟 CI 的警告保持一致。</p>
<h3 id="58-行長度上限md013-預設關閉">5.8 行長度上限（MD013）— 預設關閉</h3>
<p>本規則<strong>預設關閉</strong>。中英混用的技術寫作不適用 80-char 慣例：</p>
<ul>
<li>中文每字元算 1 個寬度時，80-char ≈ 40 個中文字，寫到一半就要斷行，嚴重影響可讀性。</li>
<li>中文每字元算 2 個寬度時，80-char 相當於 20-30 個中文字，更離譜。</li>
<li>Markdown 編輯器普遍支援軟斷行與 IDE word wrap，實體行長度對閱讀體驗影響小。</li>
</ul>
<p>若未來需要打開（例如發現真的有人寫出 2000-char 單行段落），建議上限 <strong>400 字元</strong>（軟上限，warn 不阻擋）。設定在 <code>scripts/mdtools/internal/rules/config.go</code> 的 <code>LineLengthLimit</code> 欄位。</p>
<h3 id="59-裝飾符號禁用emoji--視覺記號">5.9 裝飾符號禁用（emoji / 視覺記號）</h3>
<blockquote>
<p><strong>本節本身豁免</strong>：規範要描述「哪些符號禁用」必然要列舉這些符號（use-mention distinction）。本節舉例的 emoji 屬 mention（指稱）、非 use（裝飾使用）、不違反規則。掃描指令會 hit 到本節、判讀時跳過。</p></blockquote>
<p><code>content/**</code> 正文不可使用 emoji（如 ✅ ❌ ⚠️ 🚨 🟡 🟢 ⭐ 📌 💡 ⚡ 🎯）與裝飾性 unicode 符號（✓ ✗ ✘）。<strong>表格、列表、行內標記都不行</strong>。</p>
<p><strong>替換策略</strong>（emoji 承載的語意要回到文字結構、不是純粹刪除符號）：</p>
<table>
  <thead>
      <tr>
          <th>原寫法</th>
          <th>改成</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>表格 status <code>| ✅ 解了 |</code></td>
          <td>純文字描述：「解了」/「是」/「適用」</td>
      </tr>
      <tr>
          <td>表格 status <code>| ❌ 漏 |</code></td>
          <td>純文字描述：「漏」/「否」/「不適用」</td>
      </tr>
      <tr>
          <td>列表優缺點 <code>- ✅ 簡單</code> / <code>- ❌ 慢</code></td>
          <td>拆成 <code>**優點**：簡單</code> / <code>**缺點**：慢</code> 段落或標題段</td>
      </tr>
      <tr>
          <td>列表錯誤示範 <code>- ❌ 把 key 寄 email</code> / <code>- ✅ 用 CSR</code></td>
          <td>拆成 <code>**錯誤做法**：</code> / <code>**正確做法**：</code> 標題段</td>
      </tr>
      <tr>
          <td>行內視覺強調 <code>🚨 critical</code></td>
          <td>markdown 粗體 <code>**critical**</code> 或引用塊 <code>&gt; **critical**：...</code></td>
      </tr>
  </tbody>
</table>
<p><strong>理由</strong>：</p>
<ul>
<li><strong>Grep-ability</strong>：emoji 無法用 plain text grep 命中；視覺結構容易掩蓋語意結構、reviewer 看不出「優 / 缺」是用 emoji 區分還是用標題段區分</li>
<li><strong>CLI parser 相容性</strong>：部分 multi-byte emoji 在 Rust-based CLI 工具（如某些 mdtools / pagefind / lint pipeline）觸發 char-boundary panic</li>
<li><strong>跨語境穩定</strong>：emoji 在不同字型 / 平台 / 終端機渲染差異大、容易斷行或顯示為框</li>
</ul>
<p><strong>掃描指令</strong>（提交前自己跑一次、有 hit 就替換）：</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">rg <span class="s2">&#34;✅|❌|⚠️|🚨|🟡|🟢|🔴|🟠|🔵|⭐|📌|💡|⚡|🎯|✨|📝|🔍|🛠|⛔|✓|✗|✘&#34;</span> content/</span></span></code></pre></div><blockquote>
<p>本規則目前<strong>未進 <code>mdtools lint</code> 自動掃描</strong>、靠人工 grep。未來會加進 lint pipeline。</p></blockquote>
<hr>
<h3 id="510-位置引用與數量命名候選掃描ref1--ref2警告層">5.10 位置引用與數量命名候選掃描（REF1 / REF2、警告層）</h3>
<p><code>mdtools lint</code> 對 <code>content/**</code> 跑兩個警告層掃描、來源是引用紀律卡（<a href="/blog/report/reference-by-semantic-title-not-number/" data-link-title="引用章節用語意標題、不用位置編號：編號是結構排列的 derivation、會隨版本漂移" data-link-desc="跨段落、跨檔引用結構單位（章節 / 階段 / 條列項）時、引用語意標題（副標題）、不引用位置編號（Stage 3、第 5 章、第 3 點）。編號是「目前結構排列」的 derivation、不是 fact；結構重排時編號全部位移、引用點不會報錯、而是 silent 指向錯的內容 — 比 broken link 更難偵測。標題的存在意義就是承載可被引用的語意。是 #44 SSoT 在結構引用維度的實例、#93 identifier-as-fact 家族的 sibling、#84 命名承載語意的引用面延伸。">#155 引用章節用語意標題</a>、<a href="/blog/report/name-collections-by-role-not-count/" data-link-title="集合命名用角色、不內嵌數量：「核心七問」的七是成員數的 derivation、加一問就全面失真" data-link-desc="「核心七問」「成長六階段」「四大支柱」這類名稱把成員數量烤進名字裡 — 數量是集合當前成員的 derivation、不是集合的語意身分；成員增減時名稱失真、且名稱是被複製最多次的字串、缺陷隨每次引用繁殖。修法：命名只承載角色與層級（核心問題 / 次要問題 / 撞牆階段）、數量讓清單自己呈現。本卡是 #155 的命名端 sibling（#155 修引用端、本卡讓「語意標題是穩定錨」的前提真正成立）、#44 SSoT 在名稱內容的實例、#84 命名檢驗的數量維度。">#156 集合命名用角色</a>）：</p>
<ul>
<li><strong>REF1-positional-anchor</strong>：正文中的位置式引用候選 —「見第 3 點」「詳見第五章」「§4」。位置編號是當下排列的衍生值、目標是活文件時、結構重排會讓引用 silent 指向錯的內容。</li>
<li><strong>REF2-count-in-name</strong>：標題與 front matter <code>title</code> 中內嵌成員數的集合命名 —「六大原則」「遷移五階段流程」。成員增減時名稱先失真、且名稱是被複製最多次的字串。</li>
</ul>
<p>兩個規則都停在警告層、<strong>命中是候選、不是判決</strong> — 回報前要做語意判定：</p>
<table>
  <thead>
      <tr>
          <th>命中情境</th>
          <th>判定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>引用發布方凍結的編號（RFC 段號、法條條號）</td>
          <td>合規、編號是 fact</td>
      </tr>
      <tr>
          <td>數字緊鄰它描述的清單（「確認三件事：」）</td>
          <td>合規、漂移在編輯當下可見</td>
      </tr>
      <tr>
          <td>外部凍結品牌名（SOLID 五原則）</td>
          <td>合規、數量由發布方凍結</td>
      </tr>
      <tr>
          <td>目標是內部活文件 / 內部活集合</td>
          <td>改語意標題引用 / 角色命名</td>
      </tr>
  </tbody>
</table>
<p>掃描器內建兩個自動豁免：被 <code>「」</code> 包住的命中視為反例引用、直接跳過；「第」開頭的序數（第三階段）不屬 REF2 的集合命名。既有內容的命中屬歷史基線、依「changed-set scoped lint」原則處理 — 新增與修改的檔案要判讀、存量警告分開回報。</p>
<h3 id="511-否定起手候選掃描pos-negation-lead警告層">5.11 否定起手候選掃描（POS-negation-lead、警告層）</h3>
<p><code>mdtools lint</code> 對 <code>content/**</code> 跑否定起手掃描、來源是 <a href="/blog/report/lead-with-the-point-cross-language/" data-link-title="重點優先陳述是跨語言的資訊結構原則、不是中文句型問題" data-link-desc="正向陳述優先的本質是資訊結構效率：讀者拿到核心概念的認知步驟越少越好。「不是 X、而是 Y」表達能力差、是因為它讓讀者先處理一個被否定的錯誤理解 X 才拿到正確的 Y、重點後置多繞一步。這個缺陷跨語言成立——英文 not X but Y、日文 X ではなく Y 同樣高頻、換語言不打破（證偽過的反例假設）。判別線是「核心概念在不在最前」、統一了 #94（重點先行合法）與 #149（重點後置違規）、且可操作。LLM 系統性放水的根因是高頻偏置（把語料高頻句型評為表達好、高頻不等於資訊結構優、跨語言）。主解是強制執行重點位置判準、#165 的異源視角降為補充。">#166 重點優先陳述是跨語言的資訊結構原則</a>（搭配 <a href="/blog/report/register-violation-needs-cross-style-eyes/" data-link-title="register 違規：偵測可機械化、判定要靠文體異源的眼睛" data-link-desc="寫作規範的違規分兩類：形式違規（emoji / 編號 / broken link）可完全機械判定、該進工具鏈；register / 品味違規（概念前置 / 否定起手 / 喊話 / 誇飾）的判定有不可消除的品味核心。「不是 X、而是 Y」的陷阱是偵測可機械化（grep 抓得到句型）偽裝成判定可機械化、誘導無限投入更精緻的判定方法（grep → 概念位置 → 行為測試）、但判定始終在品味側、始終放水。更深一層：產出這類違規的 LLM 跟審查它的 LLM 共享文體直覺、同源自審對 register 違規有結構上限、加再多輪次都跨不過。結構解是引入文體異源的視角（人類冷讀 / reader-simulation / 對抗文體 reviewer）、並接受 100% 自動 catch 不可能。">#165 register 違規要文體異源</a>）：</p>
<ul>
<li><strong>POS-negation-lead</strong>：正文中「不是 X、而是 Y」「不是 X — 是 Y」「與其 X、不如 Y」的重點後置候選。核心概念（Y）被擠到「而是 / 不如」之後、讀者要先處理一個被否定的 X 才拿到重點。這是資訊結構效率問題、跨語言成立（英文「not X but Y」、日文「X ではなく Y」）、不是中文特有句型 — 偵測可機械化、判定不可。pattern 涵蓋的連接詞（而是 / 「— 是」/ 不如）枚舉不完、判準是「核心概念在不在句首」而非哪個連接詞 — 漏掉的變體只是讓候選 silent 到有人讀到（規則第一版就漏了「不是 X — 是 Y」、靠人發現才補）。</li>
</ul>
<p>判定用「重點位置」、<strong>命中是候選、不是判決</strong>：</p>
<table>
  <thead>
      <tr>
          <th>命中情境</th>
          <th>判定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>核心概念第一次正面出現在句首（「有深度、不是非黑即白的二元」）</td>
          <td>合規、重點先行</td>
      </tr>
      <tr>
          <td>明示反例 / 對照段落內的否定（見 <a href="/blog/report/positive-rewrite-preserves-contrast/" data-link-title="正向改寫要保留對照論據、不能空降結論" data-link-desc="把「X、不是 Y」改成正向陳述時、若直接刪除「不是 Y」會讓結論失去推理依據、變成空降斷言。對照論據（Y 是讀者直覺會想到的替代方案）跟結論（X）是同一個推理單元、抽掉一半就不成立。正向改寫的合法做法是補解釋、改成對照表、或保留 Y 當對比錨點 — 直接拿掉是 worst。">#94</a>）</td>
          <td>合規、否定是對照本體</td>
      </tr>
      <tr>
          <td>核心概念被擠到「而是」之後（「不是二元、而是有深度」）</td>
          <td>改正向、把核心概念移到句首</td>
      </tr>
  </tbody>
</table>
<p>掃描器豁免兩類命中：被 <code>「」</code> 包住的引用（反例引用 / 句型佔位符）、以及 backtick 行內程式碼內的 pattern（grep regex / 技術識別碼）。講這個句型的 meta 卡（#165 / #166）與本規範段落會大量自我觸發 — 屬候選人判定豁免、跟 REF1 / REF2 對 #155 / #156 一致。既有內容的命中（全 <code>content/</code> 約數百個）屬歷史基線、依「changed-set scoped lint」原則處理 — 偵測把候選池曝光、判定漸進進行、存量警告分開回報。</p>
<h2 id="6-front-matter-schemamdtools-lint">6. Front matter schema（<code>mdtools lint</code>）</h2>
<p>Hugo 依賴 YAML front matter 提供 title / date / weight 等欄位給 render pipeline。缺欄位會讓列表頁、排序、SEO 壞掉，但 Hugo 本身不會失敗（靜默接受不完整資料），所以必須由 linter 守住。</p>
<h3 id="61-通用層contentmd">6.1 通用層（<code>content/**/*.md</code>）</h3>
<p>所有內容文章必須有：</p>
<ul>
<li><code>title</code>：字串，不可空。</li>
<li><code>date</code>：<code>YYYY-MM-DD</code> 格式（ISO 8601 date）。</li>
</ul>
<p><strong>Hugo <code>_index.md</code> section 頁面例外</strong>：這類檔案是 Hugo 的 section 列表 landing page，不是內容文章，沒有語意上的「日期」。只要求 <code>title</code>，不強制 <code>date</code>。</p>
<h3 id="62-推薦層警告不阻擋">6.2 推薦層（警告，不阻擋）</h3>
<p>推薦填寫（<code>mdtools lint</code> warn level）：</p>
<ul>
<li><code>description</code>：字串，建議 30–150 字，影響 SEO 與列表頁預覽。</li>
<li><code>tags</code>：陣列，至少 1 個標籤。</li>
</ul>
<p>推薦層是歷史內容的緩衝區，不是新增內容的放行條件。新增文章必須同時填寫 <code>description</code> 與 <code>tags</code>；修改既有文章時，若同一檔案缺少推薦欄位，應在同次變更補齊，避免每次驗證都被舊 warning 淹沒。</p>
<p>驗證時先跑 changed-set scoped lint 判斷本次變更品質，再視需要跑 full lint 觀察整體基線。回報 full lint 結果時，要把歷史 warning、已知 warning 與本次新增問題分開描述。</p>
<h3 id="63-卡片嚴格層contentbackendknowledge-cards">6.3 卡片嚴格層（<code>content/backend/knowledge-cards/**</code>）</h3>
<p>知識卡片額外要求（對應 <code>.codex/briefs/knowledge-cards.md</code> K2）：</p>
<ul>
<li><code>title</code>、<code>date</code>、<code>description</code> 必填。</li>
<li><code>weight</code>：整數，決定在 <code>_index.md</code> 主題表格中的排序位置。</li>
</ul>
<h3 id="64-禁止欄位">6.4 禁止欄位</h3>
<p>以下欄位存在時 <code>mdtools lint</code> 警告（避免語義混淆）：</p>
<ul>
<li><code>author</code>：本專案為單作者 blog，統一於 Hugo 設定。</li>
<li><code>permalink</code>：使用 Hugo 預設路徑規則，避免手動覆蓋。</li>
</ul>
<p>若未來需要鬆綁，在 <code>scripts/mdtools/internal/rules/frontmatter.go</code> 的 <code>DisallowedFields</code> 清單調整。</p>
<h3 id="65-slug-必填跟檔名對齊">6.5 slug 必填、跟檔名對齊</h3>
<p>所有 content 文章 frontmatter 必須有 <code>slug</code> 欄位，值跟檔名（不含 <code>.md</code>）對齊。</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">slug</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;visual-tool-error-layer-alignment&#34;</span><span class="w">   </span><span class="c"># 跟檔名對齊</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">date</span><span class="p">:</span><span class="w"> </span><span class="ld">2026-04-28</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w"></span><span class="nn">---</span></span></span></code></pre></div><p><strong>為什麼必填</strong>：</p>
<p>slug 是 URL 的核心識別、跨多個工具共用（Hugo build、mdtools lint、跨檔 markdown link、search index）。若不顯式定義，slug 散落在三處推導鏈：</p>
<table>
  <thead>
      <tr>
          <th>來源</th>
          <th>推導值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Hugo 預設（從 title 用 urlize）</td>
          <td>runtime 推導、隨 hugo 版本變化</td>
      </tr>
      <tr>
          <td>mdtools 字面比對</td>
          <td>檔名 stem</td>
      </tr>
      <tr>
          <td>跨檔連結時的引用</td>
          <td>寫作者手動算 / 複製</td>
      </tr>
  </tbody>
</table>
<p>三個推導鏈不一致時 = silent broken link（mdtools pass 但 hugo build 後 404、或反過來）。把 slug 升成 frontmatter 顯式 fact、所有工具基於同一 source、消除 derivation 鏈。</p>
<p>詳細論述見 <a href="/blog/report/url-slug-must-be-explicit-fact/" data-link-title="URL slug 必須顯式定義為 fact：跨工具 identifier 用單一定義源" data-link-desc="URL slug 在 Hugo 預設下從 title 自動推導、在 mdtools lint 下從檔名讀、在跨檔連結時又要寫第三個值 — 一個 identifier 散落在三個推導鏈、典型 SSoT 違反。當多個工具共用一個 identifier、推導不一致 = silent broken link。修法：把 slug 從 derivation（runtime 推導）升級成 fact（frontmatter 顯式定義）、檔名 / 連結都基於這個 fact。本卡是 #44 在 toolchain integration 情境的具體實例、是 #82 字面 vs 行為在 identifier 維度的展現。">report #93 URL slug 必須顯式定義為 fact</a>。</p>
<p><strong>檔名對齊規則</strong>：</p>
<ul>
<li>檔名命名建議：英文小寫、kebab-case 或 snake_case、不含中文（避免 hugo <code>urlize</code> 規則跨版本變動）</li>
<li>slug 值 == 檔名 stem（不含 <code>.md</code>）</li>
<li>修檔名時必須同步修 slug；修 slug 時必須同步 rename 檔案</li>
</ul>
<p><strong>Hugo <code>_index.md</code> 例外</strong>：section 列表頁已有 <code>slug:</code> 欄位指定資料夾路徑、不適用本規則。</p>
<hr>
<h2 id="7-卡片雙向完整性mdtools-cards">7. 卡片雙向完整性（<code>mdtools cards</code>）</h2>
<p>作用範圍：<code>content/**/*.md</code>，重點關注 <code>content/backend/knowledge-cards/</code>。</p>
<table>
  <thead>
      <tr>
          <th>層級</th>
          <th>規則</th>
          <th>實作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>L1 連結有效性</strong></td>
          <td>所有相對連結 <code>[...](/posts/markdown-writing-spec/path)</code> / <code>[...](/posts/path)</code> 的目標檔案必須存在</td>
          <td>AST 抽 Link node → 解析相對路徑 → stat 檔案</td>
      </tr>
      <tr>
          <td><strong>L2 卡片 orphan 偵測</strong></td>
          <td>每張卡片至少被 <code>content/**</code> 中一篇非卡片正文引用</td>
          <td>建反向索引 → 找無 incoming edge 的卡片</td>
      </tr>
      <tr>
          <td><strong>L4 卡片 K4 結構合規</strong></td>
          <td>卡片首段與「概念位置」段各至少 1 個相鄰卡片連結</td>
          <td>AST 定位段落節點 → 統計子樹 Link 數</td>
      </tr>
  </tbody>
</table>
<p>L3（正文首次出現術語必須連結到卡片）暫不納入，待術語字典（<code>.codex/briefs/knowledge-web-expansion.md</code>）啟動後再開。</p>
<h3 id="為什麼要做跨文件檢查">為什麼要做跨文件檢查</h3>
<p>知識卡片是 blog 的核心知識資產。隨著卡片數量增加：</p>
<ul>
<li><strong>Orphan 卡片</strong>（沒有正文連結進來）會變成知識死角，讀者無法發現。</li>
<li><strong>斷掉的相對連結</strong>（檔案被改名或移動）肉眼難以發現，只有讀者點擊失敗才暴露。</li>
<li><strong>K4 合規</strong>（首段 + 概念位置段要有鄰卡連結）保證卡片間的知識網不會鬆散。</li>
</ul>
<p>這些檢查用 regex 做都卡在「段落歸屬怎麼判斷」。AST 天生知道節點的父子結構，做起來自然。</p>
<hr>
<h2 id="8-執行時機">8. 執行時機</h2>
<h3 id="pre-commit-hookgithookspre-commit">Pre-commit hook（<code>.githooks/pre-commit</code>）</h3>
<ol>
<li><code>mdtools fmt --fix</code> — 自動修格式；改動會 <code>git add</code> 回 staged，避免改完又沒進 commit。</li>
<li><code>mdtools lint</code> — 結構檢查；失敗阻擋 commit。</li>
<li><code>mdtools cards</code> — 完整性檢查；失敗阻擋 commit。</li>
</ol>
<h3 id="pre-push-hookgithookspre-push">Pre-push hook（<code>.githooks/pre-push</code>）</h3>
<p><code>pre-push</code> 的責任是把 CI 同款全量檢查提前到本機。<code>pre-commit</code> 為了速度只處理 staged markdown；<code>pre-push</code> 會跑 <code>make check</code>，也就是 <code>mdtools fmt --check content/</code>、<code>mdtools lint content/</code>、<code>mdtools cards content/</code>，讓整個 <code>content/</code> 的格式與連結 drift 在推送前被攔下。</p>
<p>啟用 hook：</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 config core.hooksPath .githooks
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 或：make install-hooks</span></span></span></code></pre></div><h3 id="cigithubworkflowsmd-checkyml">CI（<code>.github/workflows/md-check.yml</code>）</h3>
<p>三個子命令都跑 <code>--check</code> / 嚴格模式，任何違規 fail CI。</p>
<hr>
<h2 id="9-寫作者使用指引">9. 寫作者使用指引</h2>
<ul>
<li>寫作時優先遵循本規範。pre-commit / pre-push 報錯時讀訊息修正；<strong>不可用 <code>git commit --no-verify</code> 或跳過 hook 的方式繞過檢查</strong>。</li>
<li>新增案例平行章節（例如多個「工具評測」「事件時序」）時不需登記到任何白名單 — siblings_only 自動判讀。</li>
<li>新增 URL 時優先採用裸 URL 轉換段的分級形式；若顯示文字含 TLD 字樣，確認 domain 與 href 完全一致。</li>
<li>新增卡片時確認首段與「概念位置」段各有至少一個相鄰卡片連結（L4 要求）；確認 front matter 含 <code>title</code> / <code>date</code> / <code>description</code> / <code>weight</code>（卡片嚴格層）。</li>
<li>程式碼區塊養成習慣先寫語言標示再填內容；純文字輸出用 <code>text</code>。</li>
</ul>
<hr>
<h2 id="10-規則擴充流程">10. 規則擴充流程</h2>
<p>新規則進入本文的路徑：</p>
<ol>
<li>先在 <code>scripts/mdtools/internal/rules/</code> 實作為可開關的 rule（預設關）。</li>
<li>在代表性檔案上測試誤判率。</li>
<li>誤判率 &lt; 1% 且有明確教材品質收益時，預設開啟並更新本文。</li>
<li>預設開啟後同步修正既有違規；若違規數量大，可分批 PR。</li>
</ol>
<hr>
<h2 id="11-為什麼自訂而不是用現成-markdownlint">11. 為什麼自訂而不是用現成 markdownlint</h2>
<p><code>markdownlint-cli2</code> 的 MD022 / MD024 / MD026 / MD029 / MD031 / MD032 / MD034 / MD036 / MD040 / MD047 / MD060 這些基礎規則都有（MD013 預設關閉、MD025 本規範嚴於原版），為什麼還要自寫？</p>
<p>關鍵差在<strong>卡片雙向完整性</strong>、<strong>反釣魚校驗</strong>、<strong>Front matter schema</strong> 這三類檢查，屬於跨文件 / AST 層 / 業務邏輯層的自訂邏輯，現成 linter 無法表達。這些檢查是 blog 品質的核心訊號，必須跟基礎格式檢查放在同一個工具鏈、同一次 AST parse 內處理，避免多個工具重複解析、重複維護。</p>
<p>另外 goldmark 是 Hugo 內建的 markdown parser。用同一個 parser 做 lint 保證「lint 通過 → Hugo render 一致」，杜絕兩套 parser 解讀不同的長尾 bug。</p>
<hr>
<p>本文為 blog 專案 Markdown 寫作規範的單一真實來源。repo 根目錄的 <code>AGENTS.md</code> 引用本文作為排版規範權威，規則與 <code>scripts/mdtools</code> 實作保持同步。</p>
]]></content:encoded></item><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>什麼是 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>