今天要解的問題
我時常會將筆記庫 llm-wiki 匯入到我的 kiwi-walk 部落格。在筆記是用 [[頁名]] 互相連結,轉成文章後需要變成站內連結。
我在〈人生就是冰淇淋,要盡快享用啊!〉裡點「歡迎光臨,休南洞書店」連結,結果 404 頁面,但我確實是有發過文章,才發現是網址的問題(後面「踩到的坑」會講)。修完之後,我請 Claude 想辦法避免再發生,它生出了一支小工具,把全站的站內連結都檢查一遍。
結果它又抓到 21 個「壞掉」的連結,散在工程和 ideas 的文章裡,例如 /blogs/ideas/主動休閒/、/blogs/art/養活創作的正職/。這些頁面在部落格上都不存在。
原因是我當初自己建了一個 vault-forge 的 GUI 工具,能把 llm-wiki 的筆記直接轉進部落格的 git repo,整個過程沒有任何 AI 介入。那時的想法是「想要之前的文章可以吃到之後文章的連結,讓別人在逛這個網站,也能享受到各種引用」,結果現在卡到自己。
但在那之前,讀者點下去就是 404;又或許那篇文章哪天真的發了,結果我改了路徑,舊文也不一定連得到它。
想法與取捨
改成純文字:連結就永遠消失了
最省事的做法是對方還沒發布就只留文字。把筆記轉成文章的轉寫規則本來也是這樣寫的:「那一頁 blog 上已經有對應文章就改成站內連結,沒有就只留顯示文字」。
問題是這樣一來,引用就只活在發文的那一刻。等〈主動休閒〉哪天發了,所有提過它的舊文都已經是純文字,要接回去只能人工一篇一篇找。這正好跟「舊文吃到新文的連結」相反。
先猜好網址:猜錯就永遠是 404
那 21 個連結是 vault-forge 留下的,它的做法是先猜網址:[[主動休閒]] 直接寫成 /blogs/ideas/主動休閒/。
但文章的網址要到發布那天才決定:放哪個分類、目錄取什麼名字。我的主題研究文章規定目錄用英文 kebab-case,所以 ideas/主動休閒/ 這種猜法很可能永遠對不上。猜對了,發布前還是 404;猜錯了,發布後也是 404。
記名字,build 時再查
兩條路的共同問題,是在「寫文章的時候」就決定連結的終點。改成反過來:原稿只記「要連到哪一頁」,用的是筆記庫的頁名;網站每次 build 時,再去查這個名字現在有沒有對應的文章。
- 原稿寫
[主動休閒](wiki:主動休閒)。 - 每篇從筆記庫轉過來的文章,在 front matter 寫上
wiki: <頁名>。 - build 時,連結的模板去找
wiki:對得上的文章:找到就輸出它真正的網址,找不到就輸出不能點的文字。
這樣〈主動休閒〉不管哪天發、放在哪個目錄,只要它的 wiki: 是 主動休閒,下一次部署時所有提到它的舊文都會接上,不用回頭改任何一篇。Cloudflare Pages 每次 push 都會從頭 build 整個網站,所以「下一次部署」就是舊文重新被檢查的時機。

我也問過筆記庫要不要跟著調整。答案是不用:頁名本來就是筆記庫裡的連結鍵,轉換和查找都在部落格這端做。唯一要顧的是筆記頁改名,所以 wiki: 可以是清單,把筆記頁的 aliases 一起帶上,任何一個名字對得到就算。
還沒發布的連結要長什麼樣
找不到的連結有兩種呈現:完全當一般文字,或是讓讀者看得出「這裡之後會有東西」。Claude 建議後者,理由是讀者知道之後會有,又不會誤點;我選了虛線底線加一個「即將發布」的小標籤。下圖是同一個清單裡兩種狀態並排:〈興趣的三種功能〉已經發布,是一般連結;另外兩條還沒發。

2026-10-12 更新:上線後看了實際畫面覺得虛線加標籤太醜,改成跟內文同色的一般文字,只在滑鼠移上去時提示「這篇即將發布」。上圖和下面「即將發布」的 CSS 是舊版。
CI 要檢查全站,還是只檢查改到的檔案
連結檢查工具做好後,下一個問題是要不要放進 GitHub Actions。在加上 build 時解析之前,全站有 21 個「壞連結」,如果檢查全站,每個 PR 都會因為這些跟它無關的舊連結亮紅燈,很快就沒人會理它。Claude 原本的建議是先只檢查有改到的檔案,等舊帳清完再改成全站。
改成 build 時解析之後,這個顧慮就消失了:預留連結不再是 404,全站真正的壞連結是 0 個。所以 CI 直接檢查全站,只有「網址真的寫錯」才算失敗,預留連結只列出來。
實作
front matter 的 wiki:
只有一個名字就寫字串,有別名就寫清單,頁名放第一個。這是〈興趣的升級路徑〉的寫法,別名都是從筆記頁的 aliases 帶過來的:
wiki:
- 興趣的升級路徑
- 興趣升級
- 從學習到研究
- 興趣變現路徑
- 興趣加值
已經發布、而且來自筆記庫的 28 篇文章,用一支一次性的腳本補上了 wiki::把部落格目錄名和筆記頁名都拿掉標點、轉小寫後比對,27 篇直接對上,例如 FUN的力量 對 fun的力量、歡迎光臨,休南洞書店 對 歡迎光臨-休南洞書店。另一篇〈你的人生,他們六個說了算!〉的筆記頁名短了一截,是另外比對出來的。
一次建好的查找表
連結的模板每遇到一個連結都要查一次,所以先把全站整理成兩張表,用 partialCached 讓整次 build 只算一次:
{{- $wiki := dict -}}
{{- $url := dict -}}
{{- range site.Pages -}}
{{- $p := . -}}
{{- $url = merge $url (dict (urls.Parse $p.RelPermalink).Path $p.RelPermalink) -}}
{{- with $p.Params.wiki -}}
{{- range (cond (reflect.IsSlice .) . (slice .)) -}}
{{- $wiki = merge $wiki (dict (lower (string .)) $p.RelPermalink) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- return (dict "wiki" $wiki "url" $url) -}}
wiki 表把每個頁名(含別名,一律轉小寫)對到文章的網址;url 表是全站所有頁面的網址,key 用解碼後的路徑。cond (reflect.IsSlice .) . (slice .) 讓字串和清單兩種寫法走同一個迴圈。
連結的 render hook
Hugo 把 Markdown 轉成 HTML 時,每個 [文字](目的地) 都會先交給 layouts/_default/_markup/render-link.html,由它決定輸出什麼。重點是這段:
{{- if hasPrefix $dest "wiki:" -}}
{{- $name = strings.TrimPrefix "wiki:" (index (split $dest "#") 0) -}}
{{- else -}}
{{- $path := strings.TrimSuffix "/" $u.Path -}}
{{- with site.GetPage $path -}}
{{- $href = .RelPermalink -}}
{{- else -}}
{{- with index $idx.url (printf "%s/" $path) -}}
{{- $href = . -}}
{{- else -}}
{{- $name = path.Base $path -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- with $name -}}
{{- with index $idx.wiki (lower .) -}}
{{- $href = . -}}
{{- else -}}
{{- $pending = true -}}
{{- end -}}
{{- end -}}
wiki: 開頭的直接拿頁名去查。/blogs/... 開頭的依序試三種:
site.GetPage用內容路徑查,也就是目錄名,標點照寫也找得到。- 查
url表,給已經照實際網址寫的連結用。 - 都找不到時,取網址最後一段當頁名去查
wiki表。那 21 個先猜網址的舊連結就是走這條,原稿不用改,對方發布後也會自動接上。
最後的輸出只有兩種:
{{- if $pending -}}
<span class="link-pending" title="這篇即將發布">{{ .Text }}<span class="link-pending-tag">即將發布</span></span>
{{- else -}}
<a href="{{ $href | safeURL }}"{{ with .Title }} title="{{ . }}"{{ end }}>{{ .Text }}</a>
{{- end -}}
外部連結和其他路徑不進上面的判斷,$href 維持原本的目的地,照常輸出 <a>。
「即將發布」的樣式
顏色都用部落格既有的 CSS 變數,深色模式不用另外寫:
.post-content .link-pending {
text-decoration: underline dashed var(--secondary);
text-underline-offset: 3px;
text-decoration-thickness: 1px;
cursor: help;
}
.post-content .link-pending-tag {
display: inline-block; /* keeps the dashed underline off the tag */
margin-left: 4px;
padding: 0 5px;
border: 1px dashed var(--tertiary);
border-radius: 4px;
color: var(--secondary);
font-size: 0.72em;
vertical-align: 0.15em;
white-space: nowrap;
}
display: inline-block 是看過畫面才加的:外層的虛線底線會延伸到內層標籤底下,把標籤變成 inline-block 就會擋掉這條底線。
檢查 build 出來的 HTML
連結檢查一開始是讀 Markdown 原稿,比對 ](/...) 指向的頁面存不存在。有了 render hook 之後,原稿裡的連結不再等於讀者看到的連結,所以改成先 build,再檢查輸出的 HTML:
HREF = re.compile(r'<a\b[^>]*?\bhref="?(/[^"\s>]*)')
PENDING = re.compile(r'class="?link-pending(?:"|\s|>)[^>]*>([^<]*)')
def exists(public, url):
path = urllib.parse.unquote(url.split("#", 1)[0].split("?", 1)[0]).strip("/")
target = public / path
return target.is_file() or (target / "index.html").is_file()
<a href="/..."> 指到 build 輸出裡不存在的檔案才算壞掉,例如 /images/ 的路徑打錯;link-pending 只計數,加 --pending 才列出來。正規表示式容許沒有引號的屬性值,因為 Hugo 會把 HTML 壓縮成 class=link-pending 這種寫法。現在全站跑一次是:
0 broken, 31 pending (not yet published) link(s)
放進 GitHub Actions
只在 PR 動到網站內容時跑,Hugo 版本跟 Cloudflare Pages 用同一個範圍(這個站的 PaperMod 版本在 0.146 以後會 build 失敗):
on:
pull_request:
paths:
- 'content/**'
- 'layouts/**'
- 'assets/**'
- 'static/**'
- 'hugo.yml'
- 'themes/**'
- 'src/check_internal_links.py'
- '.github/workflows/links.yml'
workflow_dispatch:
jobs:
check-links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Only PaperMod is used; themes/tranquilpeak is a dead submodule.
- name: Fetch PaperMod
run: git submodule update --init --depth 1 themes/PaperMod
# Same range as Cloudflare Pages' HUGO_VERSION; the pinned PaperMod breaks on >= 0.146.
- uses: peaceiris/actions-hugo@v3
with:
hugo-version: '0.139.4'
extended: true
- name: Check internal links
run: python3 src/check_internal_links.py --pending
repo 裡有兩個主題 submodule,只有 PaperMod 在用,所以只抓它一個。
踩到的坑
全形逗號讓網址 404
最開始那個 404 的連結是這樣寫的:
- [《歡迎光臨,休南洞書店》](/blogs/books/歡迎光臨,休南洞書店/)
目錄名確實是 歡迎光臨,休南洞書店,但 build 出來的資料夾不是:
$ ls public/blogs/books/
...
人生就是冰淇淋要盡快享用啊
...
歡迎光臨休南洞書店
...
Hugo 從目錄名產生網址時會轉小寫,並把全形標點拿掉:,、!、() 都不見了。所以照目錄名寫的連結,只要目錄名有全形標點就會 404。同樣的問題還有另外兩個連結。
當下的修法是把連結改成實際網址。換成 build 時解析之後,site.GetPage 本來就用目錄名查,照目錄名寫也找得到,這類錯誤就不會再出現了。
git add Makefile 沒有加到任何東西
make check-links 這個 target 加在 Makefile 裡,跟著檢查工具一起 commit。過了幾個 commit,要把分支 rebase 到最新的 main 時,git 拒絕了:
error: cannot rebase: You have unstaged changes.
error: Please commit or stash them.
M makefile
repo 追蹤的檔名其實是小寫的 makefile。macOS 的檔案系統不分大小寫,所以用 Makefile 讀寫都正常,但 git add Makefile 比對的是 git 索引裡的路徑,對不上就什麼也沒加,也不會報錯。那次 commit 裡其實沒有 makefile 的改動,看 --stat 才確認:
$ git ls-files | grep -i '^makefile'
makefile
在不分大小寫的檔案系統上,git add 要用 git 索引裡的大小寫,或者 commit 前先看一眼 git status。
「即將發布」被算了兩次
第一版檢查器回報 42 個預留連結,但實際應該是 21 個。原本的正規表示式是 class="?link-pending"?[^>]*>,它也會比對到內層的 class=link-pending-tag:link-pending 後面接 -tag>,剛好被 [^>]* 吃掉。改成 link-pending 後面必須是引號、空白或 > 之後,數字就對了。
小結
部落格的站內連結現在是在 build 時才決定要不要成為連結:寫的時候只記筆記庫的頁名,對方發布後下一次部署就自動接上,在那之前讀者看到的是「即將發布」,不是 404。原本被當成壞掉的 21 個連結、三篇書評裡的引用,都不用改成純文字了;28 篇既有文章補上 wiki: 之後,也都能被別的文章用名字連到。連結檢查改成看 build 出來的 HTML,並放進 CI,現在全站 0 個壞連結、31 個等著對方發布。
那 21 個連結一開始被當成壞掉,其實是刻意留下的。它們的終點要到對方發布那天才確定,所以這次的做法是不在寫的時候寫死網址,只記筆記庫的頁名,讓每次 build 去查。
在一兩個月前的 vault-forge 創建是為了方便轉換,但現在回頭看,帶來的壞處似乎比好處更多。好處是我可以不透過 claude 直接轉換,然後推上去。但相關的連結引用,或是 cdn 圖片,都要靠自己。遠不如直接透過 claude 來做這個動作,讓每一篇文章都是完整有架構的,不要都是我無腦扔上去的。
