「正則表達式抓得到長得像 Wikilink 的文字,但分不出它是不是躲在程式碼區塊裡——真正安全的做法,是先讓 goldmark 把正文解析成 AST,再談怎麼抓連結。」
今天的實作範圍很聚焦:讓 internal/vault.ParseRawNote 在切出正文字串之後,額外用 goldmark 把正文解析成 ast.Node 節點樹,並新增 Note.BodyAST、Note.BodySource 兩個欄位承接解析結果;原本的 Note.Body 字串欄位維持不動。這一步本身不做任何 Wikilink 或標題的抽取邏輯,純粹是把「結構化理解正文」這個地基先打好,留給接下來幾天使用。
Day 06 封存後,Note.Body 從頭到尾只是一段不透明的字串。接下來 Day 09 要精準抓出 [[Wikilink]]、Day 10 要建立全庫標題/別名索引、Day 11 要偵測孤立筆記與斷鏈——這些工作如果各自在原始字串上跑正則表達式,會撞上同一個結構性問題。想像一篇筆記長這樣:
用法範例:
```
連結語法是 [[某篇筆記]],標題語法是 # 標題
```
程式碼區塊裡的文字並不是真正的 Wikilink 或標題,但 \[\[(.+?)\]\] 或 ^#{1,6}\s 這類正則表達式並不知道自己「目前在不在程式碼區塊裡」,照樣會把它們抓出來,產生錯誤的雙向連結或錯誤的標題索引。要正確排除這種情境,勢必得先知道「哪些文字屬於程式碼區塊」——這正是 AST(而不是原始字串)能提供的資訊。
goldmark 的節點(例如 *ast.Text)內部用 text.Segment(一組起訖 byte 位移)指向原始輸入,而不是自己複製一份字串——這是 goldmark 為了避免大量記憶體複製的設計。也就是說,AST 節點樹脫離「解析時用的那份原始 byte slice」就無法取出實際文字。所以 Note 除了 BodyAST 之外,還得額外保存 BodySource:
// Note 代表一篇已解析的筆記。
type Note struct {
FilePath string
Frontmatter Frontmatter
Body string
RawContent string
// BodyAST 是 Body 以 goldmark 預設 CommonMark parser 解析後的根節點。
BodyAST ast.Node
// BodySource 是 BodyAST 節點內部 Segment 取出文字內容時所需的原始 byte slice。
BodySource []byte
}
解析本身只是一行:
// ParseBodyAST 以 goldmark 預設設定的 CommonMark parser,將正文 byte slice 解析為 AST 節點樹。
// goldmark 的預設 parser 對任何合法 UTF-8 輸入都不會解析失敗,未定義語法會被當成一般文字節點處理。
func ParseBodyAST(body []byte) ast.Node {
return goldmark.DefaultParser().Parse(text.NewReader(body))
}
後續要拿到某個節點對應的文字,呼叫 segment.Value(note.BodySource)(區塊節點則是 node.Lines().Value(note.BodySource))即可——這也是為什麼 BodySource 一定要跟著 BodyAST 一起保存,兩者缺一不可。
ParseRawNote 切出正文字串後,直接內建呼叫 ParseBodyAST,把兩種表示一起塞進回傳的 Note:
bodySource := []byte(body)
return &Note{
FilePath: filePath,
Frontmatter: fm,
Body: body,
RawContent: string(rawContent),
BodyAST: ParseBodyAST(bodySource),
BodySource: bodySource,
}, nil
這裡刻意不是「額外提供一個獨立函式,讓呼叫端自己決定要不要解析 AST」。如果 AST 解析是選擇性的,brain scan 或未來的呼叫端就必須記得多呼叫一次,很容易遺漏;而 goldmark 的預設 parser 對任何合法 UTF-8 輸入都不會解析失敗(未定義語法一律被當成一般文字),所以內建進 ParseRawNote 不會替呼叫端增加新的錯誤處理負擔,函式簽章 (*Note, error) 完全不變。
另一方面,Body string 也刻意保留、沒有被取代。Day 07 的 capture.Render/Write 直接組字串寫檔,brain scan 的輸出也直接印字串欄位——這些既有行為不需要,也不應該被要求先建 AST 再轉回字串。字串與 AST 是同一份正文內容的兩種表示,服務不同用途:字串給「序列化/顯示」,AST 給「結構化分析」。
用前面那個「程式碼區塊裡藏假標題、假 Wikilink」的例子實際跑一次 ParseBodyAST,走訪整棵樹並印出每個節點的 Kind():
source := []byte("```\n# 看起來像標題\n[[看起來像 Wikilink]]\n```\n")
root := vault.ParseBodyAST(source)
ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
fmt.Println(n.Kind())
}
return ast.WalkContinue, nil
})
輸出只有兩個節點:
Document
FencedCodeBlock
整段內容被收斂成單一的 FencedCodeBlock 節點,AST 樹裡完全沒有出現 Heading 節點——# 看起來像標題與[[看起來像 Wikilink]]都只是這個程式碼區塊節點內容的一部分,不會被拆解成一般段落文字或標題。對照組是一段真正的標題加段落:
source := []byte("# 標題\n\n一般文字段落。\n")
這種情況下 AST 就會正確產出 Heading 與 Paragraph 兩個節點。測試也涵蓋了巢狀情境——清單項目內包著一段 fenced 程式碼區塊,確認 ast.Walk 仍然能走訪到每一層,這是 Day 09「跳過程式碼區塊」抓 Wikilink 邏輯能成立的前提。go test ./...、gofmt -l .、go vet ./... 全數通過;brain scan、brain capture 兩個既有指令重新跑過,行為與輸出格式都沒有變化。
AST 並不能完全消除字串比對——goldmark 遇到 [[Wikilink]] 這種非標準 CommonMark 語法,預設還是會把它當成一般文字節點(ast.Text)內容的一部分,不會產生專屬的節點類型。AST 的價值在於「排除誤判」,不是「完全取代字串比對」:先用 ast.Walk 跳過程式碼區塊等節點,只在真正的段落文字節點內容上做 Wikilink 字串比對,範圍就大幅縮小、也不會誤觸程式碼區塊。
今天沒有加任何新指令、也沒有改變任何既有輸出——brain scan 印出來的東西跟昨天一模一樣。改變的是內部:Note 現在同時具備字串與 AST 兩種正文表示,且透過 ParseRawNote 自動產生,未來的 Day 不需要重新設計解析層,直接讀 note.BodyAST 就好。
👉 明天 Day 09,我們會在這棵 AST 上動手,走訪節點樹跳過程式碼區塊,精準抓出真正的 [[Wikilink]]。我們明天見!