Markdown 冪等區段合併
程序,用於在自動化中維護 Markdown 文件中一個規範的 ## Heading 區段:當區段存在時取代它,不存在時附加它,並且在應用兩次時產生相同的位元組輸出。非顯而易見的部分是區段結束的位置——而經典的錯誤是相信一個簡單的 ^## 正規表達式可以找到這個邊界。
使用時機
- 一個代理程式或腳本保持一個規範區段在它不完全擁有的文件中保持最新狀態——
README.md、AGENTS.md、CLAUDE.md、手冊、變更日誌前言——遵循「存在即取代,缺失即附加」的規則。 - 你看到以下任何症狀:
- 維護的區段在每次執行後被複製。
- 合併在一個圍欄程式碼區塊中間截斷了區段。
- 一個
## ...行在程式碼圍欄內被視為下一個區段。 - 一個
###子標題過早結束了取代範圍。 - 更新器從不收斂:第二次執行仍然變更檔案。
- 合併後,某點以下的所有內容呈現為一個巨大的程式碼區塊(一個遺留的圍欄定界器重新開啟為一個新圍欄)。
- 你正在讀取或計數區段,使用
^##風格模式,而不僅僅是寫入它們——提取和驗證都遇到相同的陷阱。
陷阱:程式碼圍欄內的標題
天真的實作將取代範圍視為「## X 行到下一行 ^## 匹配之前的行」。它以兩種方式失效。
陷阱 1 — 圍欄中的字面值。 區段主體合法地包含在圍欄程式碼區塊內的 ## ... 行:一個報告模板、一個範例文件、一個引用的差異比較。一個不感知圍欄的掃描會將第一個這樣的行誤讀為下一個區段邊界,並在那裡截斷範圍:
## 自動化筆記
機器人刷新此區段。
```text
## 每週報告 <- 天真 ^## 掃描在此停止,在圍欄內
- 重點:
- 風險:
```
保持模板在圍欄中,使其不會變成真正的標題。
## 授權 <- 真正的下一個區段
取代直到圍欄行的位置會留下一個陳舊的尾部。量化的損壞(見測試過的陷阱裝置)比簡單的重複更糟:
- 圍欄內的
## 每週報告字面值被留在任何圍欄之外,並呈現為一個真正的、看起來像重複的標題。 - 舊區塊的結尾圍欄定界器(開放的
```行)也被留下,重新開啟為一個新圍欄,並吞沒之後的所有東西——接下來的## 授權區段根本不再是一個標題。 - 合併不是冪等的:每次執行都再次匹配標題,再次在一個圍欄行處截斷,並再次增長檔案。
陷阱 2 — ### 子標題。 一個簡單的 ^## 正規表達式也匹配 ###、####,等等。如果區段包含子標題,範圍在第一個 ### ... 行結束,並且舊的子區段在新的之後仍然存在——在合併點下方出現重複的內容。
相同的兩個陷阱適用於讀取一個區段(提取其內容)和驗證一個區段(grep -c '^## X' 可能計數過多圍欄內的字面值並被它們欺騙)。
安全邊界:兩種方法
方法 1 — 字面值區域感知的標題掃描
在掃描行時追蹤字面值區域狀態,並且僅在一行位於 frontmatter、圍欄和支援的原始 HTML 區塊之外時才將其視為標題或邊界:
- 圍欄在一行打開,其第一個非空白字元(最多 3 個前導空格)是 3 個以上反引號或 3 個以上波浪號的連續序列。
- 它只在一個具有相同字元的連續序列的行上關閉,至少一樣長,且該行上沒有其他內容。一個未關閉的圍欄一直運行到文件結尾(CommonMark 行為)。
- 在圍欄內部時,沒有什麼是標題,也沒有什麼是邊界。
- 在圍欄掃描之前,一個前導的精確行 frontmatter 區塊被排除:YAML 以
---打開並以精確的---或...關閉;TOML 以+++打開和關閉。一個看起來像標題的行在該區塊內是中繼資料/註解內容,而不是 Markdown 區段邊界。一個打開器沒有其精確的關閉器,在任何寫入之前會失敗關閉。 - 在頂層,第 0–3 列的CommonMark 0.31.2 原始 HTML 區塊類型 1–7 也被排除:字面值內容標籤(
pre/script/style/textarea)、註解、處理指令、宣告、CDATA、列出的區塊標籤,以及完整的僅標籤行。類型 6/7 在空白行之前或 EOF 處結束;類型 7 不能中斷段落。標籤和屬性語法即使在忽略大小寫匹配下也保持僅 ASCII;Unicode 大小寫摺疊相似外觀是普通的 Markdown。圍欄和 HTML 狀態共享一個獨佔掃描器,因此一個區域內部的定界器保持為字面值資料。 - 這個變異的參考在模糊的 EOF 處故意比 CommonMark 更嚴格:一個被識別的類型 1–5 打開器沒有其明確的結束條件會失敗關閉。一個可能的類型 7 打開器在一個不支援的容器/縮排上下文之後也會失敗關閉,而不是猜測。相同的規則涵蓋一個可能的連結參考定義後跟
===,其中部分解析器無法證明 setext 是否結束了段落。可能的連結標籤在行之間以轉義感知的括號處理追蹤;一個未轉義的]沒有立即的冒號會釋放可能的定義狀態。簡單的無空白定義加標籤形式保持為內聯 HTML。 - 區段邊界是下一個1 級或 2 級的標題。常見的形式是
^##[^#](排除###);參考將其強化為^ {0,3}#{1,2}([ \t]|$),這額外將 H1 視為一個邊界(一個部分邊界絕不能被吞入取代中),接受 CommonMark 允許的 1–3 空格縮排,並應用 CommonMark 的空格規則,因此一個##hashtag風格的段落行不是一個錯誤的邊界。###仍然不是一個邊界。
當文件格式不是你可以更改的,或者工具必須在任意 Markdown 上工作時,使用此方法。
方法 2 — 固定標記
將維護的區段包裹在獨特的哨兵行中——HTML 註解在渲染的 Markdown 中是不可見的:
<!-- managed-section:begin automation-notes -->
## 自動化筆記
...主體,歡迎使用圍欄範本...
<!-- managed-section:end automation-notes -->
取代在精確的開始/結束標記行之間的所有內容;當開始標記不存在時附加整個標記的區塊。標題掃描不再屬於範圍查找的一部分。
當你擁有文件格式時使用此方法:它是最穩健的邊界(它能夠承受標題重新命名和任何主體內容),代價是植入標記。保持每個區段的標記字串唯一,並且即使在此處也保持下面的單一 H2 不變量——標記保護範圍,而不是文件的標題結構。一個誠實的說明:在程式碼圍欄內標記行的文字複製仍然會混淆不感知圍欄的標記搜尋,所以要么保持標記文字不在圍欄範例中,要么使用相同的圍欄感知掃描來找到標記。
不變量
在每次合併前後強制執行這些;它們使「取代或附加」良好定義:
- 區塊以其自己的純
## Heading行開頭——在開頭##和非空內容之間使用恰好一個 ASCII 空格(或對於空的 H2 使用精確的##)。多餘的分隔空格/製表符和 CommonMark 結尾雜湊序列在此處不是規範的。標題是合併內容的一部分,不是可以與之漂移的配置。 - 在區塊內恰好一個 H1/H2 級別的標題——其自己的第一個 ATX 行(字面值區域感知計數)。一個具有第二個 ATX 或 setext H1/H2 的區塊會在後續執行中默默地吸收或劃分一個區段。在第一次寫入之前拒絕可能的 setext 標題,以便附加和取代使用相同的驗證邊界。區塊內部的圍欄或原始 HTML 標題字面值是可以的——它們不計數。
- 在目標中(在字面值區域之外)最多一個無歧義的標題副本。 如果文件已經包含重複或一個分隔符/1–3 空格/結尾雜湊別名的受管理 H2,則停止並報告,而不是「修復」一種形式而留下另一種。
- 一個規範的分隔符形狀。 儲存區塊時沒有尾隨空白行;在區塊和後續區段之間寫入恰好一個空白行。讀取範圍和寫入形狀然後收斂到相同的位元組,這使得第二次執行成為無操作。
- 在目標或區塊中沒有未閉合的圍欄。 CommonMark 將未閉合的圍欄運行到文件結尾,因此在目標中它默默地將區段延伸到 EOF(一個取代會重寫整個視覺上被吞沒的尾部),並且在區塊中它會吞沒合併區段之後的任何內容。兩者都是格式錯誤的輸入:停止並報告。
- 在規範區塊或取代的範圍內沒有 setext 標題。 直接在段落文字下方的
===或---底線是真實的標題,逐行的^#掃描無法將其視為邊界。在新附加的區塊中接受一個會使下一次執行失敗而不是收斂;跨越一個進行取代會刪除一個區段邊界。在兩種情況下在寫入之前停止(將標題轉換為 ATX 形式,或使用固定標記)。參見docs/setext-block-heading-contract.md。 - 沒有未閉合的開頭 frontmatter。 當第一行是精確的
---(YAML)或+++(TOML)時,僅通過第一個精確匹配的關閉器忽略其看起來像標題的行。如果沒有關閉器存在,則停止而不是猜測打開器是中繼資料還是普通的 Markdown 主題中斷。 - 沒有歧義的顯式結束原始 HTML。 CommonMark 允許類型 1–5 在缺少結束條件時運行到 EOF,但在那裡附加一個 H2 會將其隱藏在原始 HTML 內部並破壞收斂。在目標和區塊中都要求顯式的結束條件。對於類型 7,保留其無段落中斷規則,並在不支援的容器/縮排或可能的參考加 setext 上下文(包括多行連結標籤)使該決策模糊時失敗關閉。
- 僅 ASCII 區塊空白。 空白行、ATX 標題修剪和結尾序列、setext 上下文和圍欄關閉器使用 ASCII 空格/製表符,恰好如 CommonMark 0.31.2 所指定。NBSP、EM SPACE、換頁符、垂直製表符和其他 Unicode 空白保持為內容,而不是被默默地剝離。參見
docs/commonmark-ascii-whitespace-contract.md。
驗證方法
在合併後運行所有三個檢查;它們一起捕獲邊界錯誤、重複區段和附帶編輯:
-
兩次應用零差異。 應用相同的合併兩次;在第二次運行後
git diff必須為空(或比較前後的位元組)。對於位於文件尾部的區段,一個等效的檢查是區塊等於文件的整個尾部區段。python scripts/merge_section.py target.md section.md python scripts/merge_section.py target.md section.md # 第二次運行 git diff --exit-code target.md # 在提交第一次運行後:為空參考實作的
--check模式以 diff 風格的退出碼(0 = 規範,1 = 合併會變更檔案)編碼了相同的想法,CI 可以直接調用。 -
標題出現次數 = 1。 使用感知圍欄的掃描計數等於標題的行。一個純粹的
grep -c '^## 自動化筆記$'受到此技能所討論的陷阱的影響——僅當你確定主體不包含精確標題行的圍欄副本時才可接受。 -
git diff --stat觸及恰好一個檔案——目標。其他任何東西意味著自動化寫入了不該寫入的地方。
參考實作
scripts/merge_section.py 在無依賴的 Python 3(僅標準函式庫)中實現了方法 1 以及上述每個不變量。在這裡選擇 Python 而非 shell 有一個重要的原因:對換行符和 BOM 的顯式位元組級控制使兩次應用零差異可證明,而 shell 文字管道往往在你背後標準化行結尾。該演算法可以直接移植到任何語言。
python scripts/merge_section.py TARGET.md SECTION.md # 就地合併
python scripts/merge_section.py TARGET.md SECTION.md --check # 漂移檢查
SECTION.md 是規範區塊:第一行是精確的 ## Heading,在非空內容前有一個 ASCII 空格,或對於空的 H2 使用精確的 ##。目標的 LF/CRLF 風格和 UTF-8 BOM 被保留;缺失的目標被創建(附加到一個空文件中)。變更的位元組被刷新到一個獨佔的同目錄臨時檔案並以一次原子取代安裝。臨時檔案以 POSIX 模式 0600 或受保護的 Windows SYSTEM/Owner-Rights-only DACL 開始,其身份、位元組、元數據和 Windows DACL 在提交前被重新檢查。現有的 POSIX 擁有者/群組/模式/有界擴展屬性被保留。對於現有的 Windows 目標,ReplaceFileW 向前攜帶其文件化的 DACL、檔案屬性和命名串流;精確的擁有者/群組/SACL 保留不予保證。缺失的目標保留私有權限。目標和規範區塊都以無跟隨常規檔案快照讀取。符號連結、非常規檔案和多個硬連結輸入被拒絕,因為取代會改變其語意;Windows 重解析點和 EFS 加密的 Windows 目標在內容被讀取前被拒絕。目標的身份、元數據和位元組在提交前立即被重新檢查。在該檢查之前完成的變更被檢測到,但檢查和取代是分開的操作;當現有目標不能承受丟失的更新時,對每個寫入者進行外部序列化。缺失目標的創建使用無取代提交,並且永遠不會覆蓋並發創建。在 Windows 上,ReplaceFileW 使用一個...
[為翻譯截斷]