今天要解的問題
kiwi-walk 是用 Hugo 加 PaperMod 主題做的個人部落格,部署在 Cloudflare Pages:推到 main 就建正式站,而在 PR 時會建個 preview,網址是 <hash>.kiwi-walk.pages.dev。
但打開 preview 要選連結時,卻會調轉到正式的 kiwi-walk.com 的 hostname,導致我驗證上的困難。
若拿現成的 preview 來量,首頁的 href 依 host 統計:
$ curl -s https://1f96308e.kiwi-walk.pages.dev/ | grep -o 'href="https://[^/"]*/' | sort | uniq -c | sort -rn
22 href="https://kiwi-walk.com/
1 href="https://www.linkedin.com/
1 href="https://www.instagram.com/
1 href="https://medium.com/
1 href="https://github.com/
撇除四個社群連結,22 個站內連結全部指向正式站。所以若我在 preview 首頁點任何一篇文章,反而是跑到正式站,看不到在這個分支要驗收的內容。

技術背景
Hugo 的絕對網址從哪來
hugo.yml 第一行是:
baseURL: "https://kiwi-walk.com/"
模板裡用 .Permalink 的地方,輸出的就是 baseURL 加上路徑的絕對網址;.RelPermalink 只輸出路徑。這個站兩種都有,例如 PaperMod 的 breadcrumb 和自己寫的系列導覽:
{{- /* themes/PaperMod/layouts/partials/breadcrumbs.html */ -}}
<a href="{{ $bc_pg.Permalink }}">{{ $bc_pg.Name }}</a>
{{- /* layouts/partials/series-nav.html */ -}}
<p class="series-nav-group"><a href="{{ $term.RelPermalink }}#{{ .id }}">{{ .title }}</a></p>
所以 preview 上的連結並不是全部壞掉:系列導覽用的是 /blogs/... 相對路徑,本來就留在 preview 上;選單、breadcrumb、文章卡片、上一篇/下一篇、標籤用的是絕對網址,才會跳走。
Pages 在建置時給了什麼
Cloudflare Pages 建置時會注入幾個環境變數,Build configuration 文件 列了用途,這次用到兩個:
| 變數 | 文件寫的用途 |
|---|---|
CF_PAGES_BRANCH | 依分支調整建置,例如在 production 關掉 debug log |
CF_PAGES_URL | 讓建置工具知道這次部署會在哪個網址 |
另一件事是 preview 本身已經不會被收錄。同一個路徑,preview 和正式站的回應頭不一樣:
$ curl -sI https://1f96308e.kiwi-walk.pages.dev/ | grep -iE "^HTTP|x-robots"
HTTP/2 200
x-robots-tag: noindex
$ curl -sI https://kiwi-walk.com/ | grep -iE "^HTTP|x-robots"
HTTP/2 200
這決定了後面一個代價可以接受(見下一段)。
想法與取捨
這次只有一個做法,在動手前就定了:建置時依分支決定 baseURL。main 照 hugo.yml,其他分支用 CF_PAGES_URL。
為什麼在建置時決定
Pages 的 build command 寫在 Cloudflare dashboard,不在 repo 裡,preview 和正式站一直用同一個指令,所以也是同一個 baseURL。preview 的網址每次部署都不同,能知道它的只有建置當下的 CF_PAGES_URL。所以做法是 repo 裡放一支 script 讀這兩個變數,dashboard 的 build command 改成執行它。
已知的代價
- preview 的 canonical 會指向 pages.dev。正式站的 canonical 是 SEO 的重點,preview 上的不是:上面的
curl顯示 preview 回x-robots-tag: noindex,canonical 指向哪裡不影響收錄。 - 切換步驟有順序。dashboard 的 build command 必須在 script 進到
main之後才改,否則正式站的建置會找不到 script 而失敗。回退也只在 dashboard:把 build command 改回原值,不用動 repo。
跟原本寫的不一樣的一處
原本規劃的 script 只看 CF_PAGES_BRANCH:不是 main 就帶 --baseURL "$CF_PAGES_URL/"。在本機直接跑的時候,兩個變數都沒有,這會變成 --baseURL /。所以多加一個條件:CF_PAGES_URL 是空的也走正式站那條路。
實作
build script
#!/bin/sh
# Cloudflare Pages build command: `sh scripts/pages-build.sh` (docs/deploy.md §7).
# Production (main) keeps hugo.yml's baseURL https://kiwi-walk.com/; a preview
# build uses its own deployment URL, so links on a preview stay on that host.
# Pages sets CF_PAGES_BRANCH and CF_PAGES_URL; extra arguments go to hugo.
set -eu
if [ "${CF_PAGES_BRANCH:-}" = "main" ] || [ -z "${CF_PAGES_URL:-}" ]; then
echo "[pages-build] baseURL from hugo.yml (branch: ${CF_PAGES_BRANCH:-unset})"
exec hugo --minify "$@"
fi
base="${CF_PAGES_URL%/}/"
echo "[pages-build] preview baseURL $base (branch: $CF_PAGES_BRANCH)"
exec hugo --minify --baseURL "$base" "$@"
${CF_PAGES_URL%/}/:先去掉可能有的結尾斜線再補一個,不管變數有沒有帶/,都只會有一個。- 每條路都先
echo一行,Pages 的 build log 裡就能直接看到這次走了哪條、baseURL是什麼,不用從輸出的 HTML 反推。 "$@"把多餘的參數轉給 Hugo,本機測試時可以加--quiet。
本地模擬兩種環境
不用真的部署,把 Pages 會注入的變數自己設好就能跑。先模擬 preview:
$ CF_PAGES_BRANCH=test CF_PAGES_URL=https://test.example.pages.dev sh scripts/pages-build.sh
[pages-build] preview baseURL https://test.example.pages.dev/ (branch: test)
$ grep -o 'href="https://kiwi-walk.com/[^"]*"' public/index.html | wc -l
0
$ grep -o 'href="https://test.example.pages.dev/[^"]*"' public/index.html | wc -l
22
$ grep -o '<link rel="canonical" href="[^"]*"' public/index.html
<link rel="canonical" href="https://test.example.pages.dev/"
首頁的 22 個站內連結全部換到 preview 自己的 host。再挑一篇有系列導覽的文章,把各區塊的連結 host 分開看:
menu ['https://test.example.pages.dev']
breadcrumbs ['https://test.example.pages.dev']
prev/next ['https://test.example.pages.dev']
tags ['https://test.example.pages.dev']
series nav []
系列導覽是空的,因為它本來就只有 /blogs/... 這種相對路徑,沒有任何 https:// 連結。
然後模擬正式站,CF_PAGES_URL 故意也給一個值,確認 main 不會拿它來用:
$ make clean && CF_PAGES_BRANCH=main CF_PAGES_URL=https://abc123.kiwi-walk.pages.dev sh scripts/pages-build.sh
[pages-build] baseURL from hugo.yml (branch: main)
$ grep -o '<link rel="canonical" href="[^"]*"' public/index.html
<link rel="canonical" href="https://kiwi-walk.com/"
$ grep -o '<meta property="og:url" content="[^"]*"' public/index.html
<meta property="og:url" content="https://kiwi-walk.com/"
$ grep -o '<link>[^<]*</link>' public/index.xml | head -1
<link>https://kiwi-walk.com/</link>
$ grep -o '<loc>[^<]*</loc>' public/sitemap.xml | head -1
<loc>https://kiwi-walk.com/blogs/books/</loc>
切換在 dashboard
script 進 repo 之後,真正讓它生效的是 dashboard 的一格設定:Workers & Pages → kiwi-walk → Settings → Build,把 Build command 改成 sh scripts/pages-build.sh,Build output directory 維持 public。改之前先把舊的指令抄下來,那就是回退值。部署文件裡另外寫了檢查方式,改完後在線上跑:
# production:canonical 仍是正式網域
curl -s https://kiwi-walk.com/ | grep -o '<link rel="canonical" href="[^"]*"'
# preview:站內連結指向 preview 自己
curl -s https://<branch>.kiwi-walk.pages.dev/ | grep -o 'href="https://[^/"]*/' | sort | uniq -c
寫這篇時,dashboard 這一步還沒改,所以上面 preview 的數字來自本地模擬,不是線上的 preview。CF_PAGES_URL 在線上實際帶的是哪個網址,也要等改完的第一個 preview 建置,從 build log 那行 [pages-build] preview baseURL … 確認。
踩到的坑
--minify 會不會改到正式站
script 裡兩條路都加了 --minify。如果原本 dashboard 的指令沒有這個參數,正式站的 HTML 就會跟著變,等於順手改了不該改的東西。
查 hugo.yml,minify 本來就在設定檔裡開著:
minify:
disableXML: true
minifyOutput: true
光看設定還不夠放心,所以直接比輸出:同一份原始碼,先跑一次單純的 hugo 存到另一個目錄當基準,再跑 CF_PAGES_BRANCH=main sh scripts/pages-build.sh,兩邊整個目錄比:
$ diff -rq <基準目錄> public && echo identical
identical
逐檔相同。「正式站輸出不變」這件事就不靠推論,而是 diff 的結果。
preview 建完還有 39 個檔案含 https://kiwi-walk.com
模擬 preview 之後,對整個 public/ 搜正式網域,還有 39 個檔案命中:
$ grep -rl 'https://kiwi-walk.com' public | wc -l
39
看起來像漏換。把每個檔案命中處的前後文印出來看,全部是作者自己寫的內容,例如:
- 文章裡的
curl範例,例如curl -sD - -o /dev/null https://kiwi-walk.com/ - 鐵人賽文章結尾那行「本文同步發表於 kiwi-walk.com」,連結本來就該指向正式站
- 講 Cloudflare 轉址的文章裡「301 到 https://kiwi-walk.com」這類敘述,以及它出現在列表頁的摘要
- kiwi-walk 專案頁的網站連結,來自 front matter 的
live: https://kiwi-walk.com
這些是作者寫死在 Markdown 裡的網址,不經過 baseURL,也不該被換掉。模板產生的連結已經全部換好了。圖片的 https://cdn.kiwi-walk.com/... 同理,兩種建置都不變。
小結
在 preview 上點來點去會跳回正式站的原因,是 hugo.yml 寫死的 baseURL 經由 .Permalink 變成絕對網址,而 preview 和正式站共用同一個 build command。現在用一支 script 讀 Pages 注入的 CF_PAGES_BRANCH 和 CF_PAGES_URL:main 照舊,其他分支換成部署自己的網址。本地模擬時,首頁 22 個站內連結從指向正式站變成全部指向 preview 自己;正式站的輸出跟改之前逐檔相同。
這次最有用的是兩個「不改程式、只量輸出」的檢查:把 Pages 的環境變數自己設好就能在本機重現兩種建置,不用等部署;要證明正式站沒變,直接 diff -rq 整個輸出目錄,比讀設定推論可靠。