我是怎麼把這個部落格架起來的
用 Astro 加 Cloudflare Pages 搭一個零成本的靜態站,記錄過程中九個只有真的部署才會暴露的問題。
這個站從零到上線大約花了一小時。技術選擇很單純:Astro 產生靜態頁面,Cloudflare Pages 負責全球散佈,文章是 Git 倉庫裡的 Markdown 檔。
有趣的不是這個架構本身——網路上已經有幾百篇教學了——而是過程中那些只有真的部署到線上才會暴露的問題。這篇記錄其中九個。
先說架構
一開始有兩條路可選:
- Git-based:文章是 repo 裡的 Markdown,後台透過 GitHub API 提交,push 觸發重新建置。
- 動態資料庫:文章存在資料庫裡,前台即時查詢。
我選了第一條。理由不是「靜態比較快」這種空話,而是免費額度的風險剛好是反過來的:
動態方案的每個頁面請求都會計入 Workers 的每日配額。靜態資產走 CDN,完全不計額度。也就是說,萬一哪天某篇文章被大量轉載,靜態方案反而是那個不會爆的。
加上 Markdown 在自己的 repo 裡,未來想換框架、換平台,成本接近零。
坑 1:子網域是全球共用的
我原本想要的名字已經被一家瑞士的投資公司用掉了。
*.pages.dev 不是每個帳號一份,而是全球共用的命名空間。動工前先查一下:
dig +short your-name.pages.dev
沒有輸出就是還沒被用。
坑 2:淺色模式的語法高亮整個消失
這個最陰險,因為深色模式看起來完全正常。
Astro 的雙主題程式碼高亮輸出長這樣:
<pre style="background-color:#fff;color:#24292e;--shiki-dark-bg:#24292e;--shiki-dark:#e1e4e8">
看出問題了嗎?淺色的值是直接寫在 inline style 裡的,只有深色被存成 CSS 變數。
根本不存在 --shiki-light 這個變數。
而我照著對稱的直覺寫了這樣的 CSS:
.astro-code, .astro-code span {
color: var(--shiki-light) !important; /* 這個變數不存在 */
}
CSS 變數不存在時,整個宣告會變成 invalid at computed-value time,color 退回 inherit——淺色模式下所有程式碼都變成跟內文一樣的顏色。
正確做法是只覆寫深色,淺色讓 inline style 自己處理:
[data-theme="dark"] .astro-code,
[data-theme="dark"] .astro-code span {
background-color: var(--shiki-dark-bg) !important;
color: var(--shiki-dark) !important;
}
坑 3:目錄掉到文章最後面
我這樣寫側邊目錄:
<div class="gap-10 lg:grid lg:grid-cols-[1fr_15rem]">
<article>…</article>
<aside class="order-first lg:order-none">目錄</aside>
</div>
桌機正常,手機上目錄卻跑到整篇文章的最下面。
原因:order-first 只在 flex 或 grid 容器裡有效。而上面那個容器只有 lg: 以上才是 grid,在手機上它就是個普通的 block,order 完全沒作用。
實測目錄在 y=1824,文章在 y=101——讀者要捲過整篇文章才會看到目錄。
改成容器永遠是 grid,只用 lg: 控制欄數就好:
<div class="grid gap-10 lg:grid-cols-[1fr_15rem]">
坑 4:行內程式碼多了反引號
畫面上出現 `npm run build` 這種東西,反引號是看得見的字元。
Tailwind Typography 的預設行為會注入真正的反引號:
.prose code::before { content: "`"; }
移掉:
.prose :where(code):not(:where(pre *))::before,
.prose :where(code):not(:where(pre *))::after {
content: none;
}
坑 5:每個內部連結都多一次轉址
部署上線後實測,才發現站內每一個連結都回 308。
Astro 預設輸出 blog/index.html 這種目錄結構,Cloudflare Pages 於是把 /blog/(帶斜線)當成正規網址。但我在設定裡寫了 trailingSlash: 'never',站內連結全部是 /blog(不帶斜線)。
結果:每個連結都被 308 轉到帶斜線的版本,而 canonical 標記指向的還是那個會被轉走的網址。
解法是讓輸出格式跟設定一致:
build: { format: 'file' } // 輸出 blog.html 而非 blog/index.html
坑 6:修好坑 5 之後,canonical 全變成 .html
改成檔案式輸出後,建置期的 Astro.url.pathname 變成 /index.html、/blog.html。
於是 canonical 產生 https://…/blog.html,但 sitemap 和 RSS 產生的是 https://…/blog。兩份訊號互相矛盾,搜尋引擎會困惑。
寫個小工具統一正規化:
export function cleanPath(pathname: string): string {
const path = pathname
.replace(/\/index\.html$/, '/')
.replace(/\.html$/, '')
.replace(/\/+$/, '');
return path || '/';
}
這個坑的教訓是:修 bug 時要檢查有沒有引進新的不一致。我如果沒有再跑一次驗證,這個問題會一直躺在那裡。
坑 7:免費子網域沒辦法用 Access 保護
原本的計畫是用 Cloudflare Zero Trust Access 把後台入口擋起來,讓陌生人連登入畫面都看不到。
做不到。官方文件寫得很清楚:
Domains must belong to an active zone in your Cloudflare account.
pages.dev 是 Cloudflare 自己的網域,不在我的帳號底下,所以不會出現在選單裡。
這讓我重新想了一次安全模型,結論是這層本來就只是錦上添花:
真正的安全邊界是 GitHub 的 OAuth 授權。沒有授權就沒有任何寫入能力。少了 Access,攻擊面只是「陌生人看得到一個他登不進去的表單」——而這正是絕大多數 Git-based CMS 的常態。
等哪天買了自訂網域,這層可以補回來。
坑 8:後台一片空白,而且沒有任何錯誤
這是最花時間的一個。症狀:
- 頁面回 HTTP 200
- 腳本確實載入了(2.1 MB,網路面板顯示 200)
- console 完全乾淨,沒有任何錯誤
- 但畫面全白,DOM 裡只有一個
<script>
沒有錯誤訊息是最麻煩的——你無從下手。
最後是把套件抓下來直接翻原始碼找到的:
npm pack @sveltia/cms
grep -oE '.{90}nc-root.{90}' package/dist/*.mjs
原來需要一個特定 id 的掛載點,缺了它程式就靜靜地什麼都不做。加上那個 div 就好了。
心得:當瀏覽器什麼都不告訴你的時候,去讀套件的實際產出檔案。壓縮過的程式碼還是可以 grep 的。
坑 9:設定檔的變數不是到處都能用
CMS 的設定裡我寫了:
summary: '{{title}} — {{year}}/{{month}}/{{day}}'
整份設定被拒絕載入,錯誤訊息是「refers to year, but no such field is defined」。
{{year}} 這類日期變數只有在檔名樣板裡才有意義,在其他欄位得引用實際存在的欄位。
回頭看
九個問題裡,只有兩個是在本機發現的。其他七個都得等到真的部署、真的用瀏覽器打開、真的去檢查產出的 HTML 才會現形。
這大概是最實際的一課:「建置成功」跟「東西是對的」是兩件不同的事。建置成功只代表沒有語法錯誤。
幾個現在會固定做的檢查:
# 轉址與狀態碼
curl -s -o /dev/null -w "%{http_code} %{num_redirects}\n" https://your-site/some-page
# canonical 跟 sitemap 說的是不是同一件事
curl -s https://your-site/some-page | grep canonical
curl -s https://your-site/sitemap-0.xml | grep loc
兩行指令,可以省下好幾週之後才會發現的 SEO 問題。