iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0

「AST 能幫你排除程式碼區塊的誤判,但它沒辦法幫你把 [[Wikilink]] 直接變成一個節點——這件事還是得靠字串比對,只是比對的範圍變乾淨了。」

今天在 Day 08 打好的 AST 地基上,於 internal/vault 新增 ExtractWikilinks(root ast.Node, source []byte) []string:走訪 Note.BodyAST,跳過程式碼區塊與行內程式碼節點,只在剩下的文字內容裡抓出真正的 [[Wikilink]],支援 [[Target|Alias]] 別名語法並自動去重。實作過程中意外撞見 goldmark 行內 parser 的一個小地雷,順便記錄下來。

AST 不會告訴你「這是 Wikilink」

Day 08 的結語已經先破題:goldmark 是一個 CommonMark parser,[[Wikilink]] 不是 CommonMark 標準語法,goldmark 不認得它,會直接把它當成一般文字節點(ast.Text)內容的一部分。也就是說,AST 樹裡並不會出現一個叫 WikilinkNode 的東西讓你直接抓——AST 真正的價值,是先幫你排除「程式碼區塊」這種誤判來源,讓剩下需要做字串比對的範圍大幅縮小。

Day 08 的測試只驗證了 fenced 程式碼區塊(區塊層級)。但正文裡形似 Wikilink 的假文字,還有另一個更常見的來源:行內程式碼(inline code span)。比如寫技術筆記時,很自然會用反引號標示語法範例:

Wikilink 的語法是 `[[Target]]`,這只是範例,不是真正的連結。

這裡的 `[[Target]]` 是 goldmark 的另一種節點類型 ast.KindCodeSpan,如果沒有特別排除,會被誤判成一條真正的連結。所以 Day 09 要排除的節點,比 Day 08 多了一種:ast.KindFencedCodeBlockast.KindCodeBlock(縮排式程式碼區塊)之外,還要加上 ast.KindCodeSpan

原本的計畫:逐個 Text 節點比對

一開始的設計很直覺:用 ast.Walk,對三種程式碼相關節點在進入時回傳 ast.WalkSkipChildren(跳過子樹但繼續走訪兄弟節點),對其餘的 ast.KindText 節點,逐一取出 textNode.Segment.Value(source) 的內容,跑正則表達式 \[\[([^\]]+)\]\] 比對。邏輯簡單、跟 Day 08 的走訪風格一致,測試也照這個假設寫好了。

結果全部測試都失敗,回傳永遠是 nil

意外發現:goldmark 會把 [ 拆成獨立的 Text 節點

用一段除錯程式碼把每個 Text 節點的內容印出來,馬上就看出問題所在。對輸入 "參考 [[A]] 與 [[B]] 的說明。",實際的節點序列長這樣:

Text node: "參考 ["
Text node: "["
Text node: "A]] 與 ["
Text node: "["
Text node: "B]"
Text node: "] 的說明。"

[[A]] 被硬生生拆成了六個片段,完整的 [[Target]] 字串根本不會出現在任何單一節點裡。原因出在 CommonMark 的連結語法:[ 是連結([text](url))或參考連結([text][ref])的起始符號,goldmark 的行內 parser 為了能在稍後嘗試比對 ]([ 等收尾符號,必須先在每個 [ 出現的地方切開文字節點——即使最後沒有形成合法連結,這個切割動作也已經發生了。逐一比對單一 Text 節點的做法,從根本上就不可能抓到完整的 [[Target]]

修正:串接連續的 Text 兄弟節點,遇到非文字節點才清空

既然問題出在「同一段話被拆成多個相鄰的 Text 節點」,解法就是先把這些片段重新接回去,再整段做正則比對。改用自訂遞迴走訪,取代原本的 ast.Walk + WalkSkipChildren

func ExtractWikilinks(root ast.Node, source []byte) []string {
	var targets []string
	seen := make(map[string]struct{})
	var buf strings.Builder

	flush := func() {
		if buf.Len() == 0 {
			return
		}
		for _, match := range wikilinkPattern.FindAllStringSubmatch(buf.String(), -1) {
			target := parseWikilinkTarget(match[1])
			if _, exists := seen[target]; exists {
				continue
			}
			seen[target] = struct{}{}
			targets = append(targets, target)
		}
		buf.Reset()
	}

	var visit func(n ast.Node)
	visit = func(n ast.Node) {
		for c := n.FirstChild(); c != nil; c = c.NextSibling() {
			switch c.Kind() {
			case ast.KindFencedCodeBlock, ast.KindCodeBlock, ast.KindCodeSpan:
				flush()
				continue
			}
			if textNode, ok := c.(*ast.Text); ok {
				buf.Write(textNode.Segment.Value(source))
				continue
			}
			flush()
			visit(c)
		}
		flush()
	}

	visit(root)
	return targets
}

邏輯拆開來看:

  • 依序走過每個節點的子節點清單,遇到連續的 ast.Text 節點就把內容寫進緩衝區——這一步就是把被 [ 拆散的片段重新接回原本的樣子。
  • 遇到 FencedCodeBlockCodeBlockCodeSpan 這三種要排除的節點,先把緩衝區目前累積的內容比對完並清空,然後直接跳過(不遞迴進入它的子樹),確保程式碼相關內容永遠不會被接進緩衝區。
  • 遇到其他節點(例如 Emphasis、真正的 Link),一樣先清空緩衝區,再遞迴進入該節點處理它自己的子節點——避免不同行內元素之間的文字被誤接在一起,產生不存在的字串。

正則表達式本身維持原案:\[\[([^\]]+)\]\][^\]]+ 排除 ] 字元,天然支援同一段落內出現多個 Wikilink(例如「參考 [[A]][[B]]」)而不會貪婪吃過界。

別名語法與去重

[[Target|Alias]] 是 Obsidian 常見的別名寫法,Alias 只是顯示文字,不影響連結指向的筆記,所以只取 | 前的 Target 部分:

func parseWikilinkTarget(inner string) string {
	target, _, found := strings.Cut(inner, "|")
	if !found {
		return strings.TrimSpace(inner)
	}
	return strings.TrimSpace(target)
}

TrimSpace 是為了容忍 [[Target | Alias]] 這種在 | 前後留空白的寫法。去重則是用 map[string]struct{} 記錄看過的 Target,另外維護一個 slice 依插入順序 append——單靠 map 沒辦法保證輸出順序穩定,而測試與未來除錯都需要「同一份輸入永遠得到同一個順序」。

實機驗證:真連結被抓到,假連結被排除

source := []byte("這只是範例語法 `[[not a real link]]`,真正的連結是 [[RealLink]]。\n")
root := vault.ParseBodyAST(source)
targets := vault.ExtractWikilinks(root, source)
// targets == []string{"RealLink"}

行內程式碼裡的 [[not a real link]] 完全沒有出現在結果裡;fenced 程式碼區塊的情境也一樣:

source := []byte("```\n[[FakeLink]]\n```\n\n[[RealLink]]\n")
// targets == []string{"RealLink"}

別名語法、重複去重、清單巢狀情境也都各自有對應測試覆蓋,go test ./...gofmt -l .go vet ./... 全數通過。既有的 brain scanbrain capture 兩個指令重新跑過,輸出格式與行為都沒有變化——這次完全沒有動到 Note 結構或 ParseRawNote 的函式簽章,ExtractWikilinks 是一個獨立、可重用的新函式。

銜接 Day 10

ExtractWikilinks 今天只交付「能力」本身,還沒有任何呼叫端真正用它做事。Day 10 要建立全庫的標題/別名/標籤索引,會直接呼叫 vault.ExtractWikilinks(note.BodyAST, note.BodySource),把每篇筆記引用的 Wikilink 目標,跟其他筆記的標題與別名對起來,這才是雙向連結真正成形的地方。

👉 明天 Day 10,我們要把散落在各篇筆記裡的 Wikilink,串成一張全庫可查詢的標題/別名索引。我們明天見!


上一篇
剖析 Markdown:使用 goldmark 拆分 Frontmatter 與建構 AST
下一篇
索引與快取:建立本地 Vault Metadata 輕量記憶庫
系列文
打造 AI Agent 驅動的第二大腦:用 Go + Claude Code + Obsidian + Graphify 打造工程師知識作業系統15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言