
只用 Astro 內容集合經營部落格 — 捨棄 CMS、選擇 MDX,並用型別取代外鍵
這個部落格為什麼沒有 CMS:由圖片決定的目錄結構、選擇 MDX 而非 Markdown 的判斷,以及取代外鍵的標籤登錄表。
本頁目錄
引言
在這個部落格還沒有任何一篇文章的時候,我評估過導入 headless CMS。對一個工作就只是發布文字的網站來說,那是很自然的答案,而我還是放棄了。
理由是結構性的,不是喜好問題。這裡的一篇文章不是一列資料。 它是一個目錄,裡面裝著三份翻譯、它們共用的 _images/ 資料夾,以及負責繪製圖表的元件,而我希望這些能夠一起進到同一個可供審閱的提交裡。CMS 做不到這件事,因為內文會被放到 API 的另一側。
因此這個儲存庫沒有資料庫,也沒有 CMS:五個執行時期相依套件、一份 git 歷史,以及一套在內容有誤時就會失敗的建置。本文整理從這個前提推導出來的判斷。目錄結構是由圖片決定的,不是由語言決定的。在還沒有東西可寫之前,所有檔案就都改成了 .mdx。標籤取得了原本應該由外鍵提供的同一性。而字型堆疊則是依語言切換,而不是合併在一起。
一套 headless CMS 原本會付出的代價
CMS 賣的是網頁編輯器、媒體庫、依語系區分的內容模型、工作流程狀態,以及一個儀表板。拿這個儲存庫來對照,這些東西不是早就免費到手,就是反而礙事。
檔案系統就是綱要,git 就是修訂歷史。 一篇文章、它的圖片、它的兩份翻譯,以及繪製圖表的元件,構成一次提交與一份差異。把內文切出去放到託管服務上,就再也沒有任何一個修訂版本代表這篇完整的文章:圖片在儲存庫裡,內文在 CMS 裡,兩邊各自依自己的節奏偏離。
真正拍板的是翻譯的校閱。翻譯就是一份差異,要和它所依據的文字並排著讀。在 CMS 裡它會變成一張表單,而表單並不知道上週的英文版寫了什麼。
另一半是驗證。既然內容就是檔案,它就能通過與程式碼相同的檢查:
"check": "astro check && pnpm run check:i18n && pnpm run thumbnails:check"scripts/i18n-check.ts 有 213 行,會回報綱要本身處理不了的事:磁碟上沒有任何語系引用的圖片、應該一致卻在各語系之間出現分歧的 frontmatter 欄位、來源有而翻譯缺少的圖表。這些是跨檔案的完整性約束,而它們能在 CI 裡執行,正是因為檔案就在儲存庫裡。
連搜尋都能在沒有 CMS 的情況下活下來。pagefind --site dist 會在建置之後為輸出的 HTML 建立索引,所以網站帶的是靜態索引,而不是查詢層,搜尋也不需要有任何東西持續執行。
我放棄的東西是真實的,而且範圍很窄:沒有網頁編輯器,所以無法用手機寫作,非技術背景的投稿者也沒辦法送出文章。對一個作者住在終端機裡的單人部落格來說,那算不上代價。CMS 原本要賣給我的那個儀表板,是 scripts/status.ts,76 行。
清點語料庫之後才有了文體規則
影響延伸得最遠的結果,出現在文章這一側,而不是建置那一側。以檔案形式存在的語料庫可以被清點。 因此這個部落格所遵循的文體規則是量測值,而不是意見:em dash 在我已發布的語料庫裡,每篇中位數是 1,而智能體寫的最初幾份草稿是 17;粗體則是每 100 字 1.1 到 2.9,對比 0.42 與 0.20。scripts/prose-check.ts 把其中能機械判定的一半轉成結束代碼,於是偏離的草稿是被指令擋下來,而不是被感覺擋下來。
CMS 並不會讓這一切變得不可能,因為匯出功能是存在的。它會做的,是把語料庫放在匯出的一側,把從語料庫推導出來的規則放在另一側,分開做版本控制,也各自過期。另一半原因是作者只有一個人:多作者的語料庫在你能量測出個人文體之前,就已經被平均成一種團體風格,而每個數字都得先有一個作者欄位才會有意義。
有一個陷阱隨之而來,而它也正是最明顯的反駁。智能體寫的文章會進到下一次量測所讀取的同一個儲存庫,於是量測這個儲存庫,最後會變成量測智能體逐漸收斂到自己的習慣上。上面那些數字,是從這套工具存在之前我所發布的文章裡數出來的,智能體寫的草稿在其中只作為對照樣本出現。
目錄結構是由圖片決定的
每一份 Astro 的 i18n 指南都給出同樣的結構:一種語言一個目錄。
src/content/blog/ en/my-post.md ja/my-post.md zh-tw/my-post.md在文章還沒有圖表之前,這樣沒有問題。一旦有了圖表,它就得放在某個地方,而兩個選項都不好。放在一起,你就得維護同一張 PNG 的三份副本。把它提到 src/assets/blog/my-post/,你就放棄了就近放置:圖片不再跟著文章走,刪掉文章之後檔案還留在原地。
修正的方式是不再把語言當成主軸。主體是文章,語言是文章裡某個檔案的屬性。
src/content/blog/ astro-content-collections-no-database-mdx-tags-cjk-fonts/ en.mdx ja.mdx <- absent means "not translated", which is a valid state zh-tw.mdx _images/ pipeline.png <- one copy, all three languages載入器把資料夾當成 slug,把檔名當成語系:
const blog = defineCollection({ loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: './src/content/blog' }), // ...});項目 ID 產生出來會像 astro-content-collections-no-database-mdx-tags-cjk-fonts/ja,再由一個輔助函式拆回去。每個語系的檔案都用同一個字串引用 ./_images/pipeline.png,而 Astro 只會為這三者輸出一份最佳化過的衍生圖片。
我請人去驗證這個說法,而不是假設它成立。用一篇文章建置,會在各種格式與寬度下產生 12 個衍生圖片。再加入第二篇沿用同一張封面圖的文章,產生的仍然是 12 個,而不是 24 個。資產管線是依內容去除重複的,所以共用是免費的。
這個結構的代價,是它不是官方文件所展示的樣子,因此你從教學文章抄來的東西都得改過才能用。代價就只有這一項,而且只要有一篇文章放進一張圖片,就值得付。
在動筆之前就把所有檔案換成 MDX
我的文章原本是 Markdown。Astro 兩種都支援,上面那個集合的 glob 也兩種都收,所以沒有任何東西逼我做決定。即使如此,我還是在這個儲存庫的第一天就把全部換成了 .mdx。
這次轉換的成本是零,這是論證的前半。MDX 是 Markdown 的超集,所以一個純散文的檔案在改副檔名之後位元組完全相同,行為也一樣。沒有遷移工作,也沒有相容模式。
後半是 .md 做不到的事。把文字燒進 PNG 的圖表,永遠只能是一種語言,而在三個網站中的兩個上,那都是錯的語言。元件可以讀取當前的語系,再為它繪出標籤。而這個選項,只存在於編譯器允許你 import 的檔案裡:
import Figure from '@/components/article/Figure.astro';import Architecture from './_figures/Architecture.astro';
<Figure> <Architecture /> <Fragment slot="caption">What the reader should take from it.</Fragment></Figure>這個賭注大約花了三十篇文章才回收。在今天這個儲存庫裡的 35 個語系檔案中,有 20 個 import 了圖表元件,13 個什麼都沒有 import。那 13 個正是這條規則之所以一律適用、而不是逐案判斷的理由:不會有某一刻,一篇文章因為多了一張圖而必須改名,也不必回答新檔案該用哪個副檔名。
隨之而來的有三個語法差異,而三個都會在建置時連同行號與欄號一起失敗,所以它們是麻煩,不是危險。註解是 {/* … */},因為 <!-- … --> 在 MDX 裡不合法。散文中裸露的 { 或 < 會被當成 JSX 解析,所以 `{ foo: 1 }` 需要反引號,而那本來就該加。還有,要翻譯的文字放在子節點裡,絕不放在屬性裡:<Callout>text</Callout> 撐得過再翻成兩種語言,而 <Callout title="text" /> 則是在邀請譯者去改寫一個屬性。
同一個標籤以兩個名字存在
在日文文章裡我把標籤打成 生成AI,英文則是 Generative AI。兩個都正確,而且有一整年都沒有出現看得見的問題。
問題出在同一性,不是翻譯。 如果標籤就是該語言 frontmatter 裡的那串文字,那麼 /tags/生成AI 與 /tags/generative-ai 就是兩個彼此無關的頁面,各自列出互有重疊的文章,而兩者都不是那個標籤。再加上第三種語言,就變成三個。
這正是沒有資料庫真正咬人的地方。若是關聯式綱要,標籤會是一列資料,文章與標籤之間的關聯會是外鍵,而這個約束會是資料庫的工作。換成檔案,約束就得自己建。標籤需要一個與語言無關的同一性,再加上各語言的標示文字:
export const TAGS = { 'generative-ai': { en: 'Generative AI', ja: '生成AI', 'zh-tw': '生成式 AI' }, 'i18n': { en: 'i18n', ja: '国際化', 'zh-tw': '國際化' },} as const satisfies Record<string, Record<Locale, string>>;frontmatter 裡放的是 slug。/tags/generative-ai 與 /ja/tags/generative-ai 於是成為同一組文章,只是掛在各自語言的標題底下。
我原本沒料到自己會在意的,是從這份登錄表推導出 Zod 的 enum:
tags: z.array(z.enum(TAG_SLUGS)).default([]),打錯的標籤現在會變成一個列出所有合法選項的建置錯誤,而不是一個安靜地產生零篇文章的標籤頁。這就是參照完整性,只是改由型別在建置時執行,而不是由資料庫在寫入時執行。自由字串的標籤是安靜地失敗的,而那是內容缺陷最糟的壞法:沒有任何錯誤,頁面就只是不在那裡。
同樣的形狀也解決了封面圖。把共用縮圖做成以鍵引用的登錄表之後,二十篇文章可以共用一張圖片,替代文字也只要在圖片旁邊為每種語言寫一次,而不是在二十份 frontmatter 裡寫二十次。由於該項目本身已經是最佳化過的 ImageMetadata,Open Graph 圖片完全不需要產生器:
const og = await getImage({ src: cover.src, width: 1200, height: 630 });我原本已經把 Satori 算進預算了,結果並不需要它。
一套字型堆疊只屬於一種語言
這一項,我光靠推理是找不出來的。
漢字在 Unicode 裡是統一的:日文與中文對於寫法不同的字共用同一個碼位。直、骨、今在同一個碼位上,各自有日文與繁體中文的不同字形。你會拿到哪一種字形,完全取決於字型,而文字再怎麼正確,也修不好錯誤的字形。
所以那個看起來理所當然的單一堆疊是錯的:
/* wrong — every CJK page renders with Japanese glyph shapes */font-family: system-ui, 'Hiragino Sans', 'Noto Sans TC', sans-serif;Hiragino Sans 涵蓋了繁體中文頁面所需要的字,所以瀏覽器永遠不會落到 Noto Sans TC。頁面算繪出來了,也沒有東西缺漏。只是那些字形細微而一致地錯著:我看不出來,母語讀者一眼就看得出來。
修正的方式是依語言選擇,而不是把它們合併:
html[lang^='ja'] { --font-sans: system-ui, 'Hiragino Sans', 'Noto Sans JP', sans-serif;}
html[lang^='zh'] { --font-sans: system-ui, 'PingFang TC', 'Noto Sans TC', sans-serif;}這同時也是上面那個 MDX 判斷最強的論據,因為同樣的陷阱也適用於產生出來的圖表,而且在那裡更糟。我那條從 SVG 轉 PNG 的管線固定使用 Hiragino Sans,因為單寫 sans-serif 會讓 cairosvg 算繪出豆腐字。把它對準繁體中文標籤,你會得到看似合理卻寫錯的字形被燒進 PNG 裡,沒有回退,也沒有警告。老實說豆腐字還比較好,因為豆腐字是一眼就看得出來的。圖表元件沒有這種失敗方式,因為它直接繼承頁面所選的堆疊。
所以我寫進圖表 skill 裡的規則是:優先使用與語言無關的標籤(POST /articles → validate → queue 根本不需要翻譯),而在標籤無可避免時,用算繪而不是燒進圖片。
翻譯是產生出來的,不是存起來的
三種語言都上線了。兩週後,英文裡一句寫錯的話被修好,另外兩種語言的版本就此變成錯的,而且沒有任何東西會說出這件事。沒有錯誤、沒有警告,建置也不會失敗。
我最初的答案是偵測。每一份翻譯檔案都記下翻譯當下來源的雜湊值:
translation: 'machine'sourceHash: 'a3f8c21b09e4d7f2'檢查時重新計算、比對、回報偏移。我做出來了。然後我發現難的並不是雜湊。難的是決定什麼才算是一次變更,而關於它的每一個問題,都是沒有標準答案的判斷:
- 重新排版一個段落算不算變更?
- 修改程式碼區塊裡的註解算不算變更?程式碼本身在三種語言裡完全相同,但改動過的註解是讀者會讀到的文字。
- 把圖片檔名改掉算不算變更?
更糟的是,這條規則是一個只進不退的棘輪。五十篇文章用同一套定義算過雜湊,之後你把定義改得更精細,五十篇就會同時回報為過期。你只能全部重新翻譯,或是閉著眼睛把雜湊重新蓋一次,順手毀掉當初做這東西所要的訊號。
不要把翻譯維護在來源旁邊。要在發布時從來源產生它。 發布會先從當前的來源重新產生每一個目標語系,然後才切換狀態,所以文字唯一能抵達網站的路徑就是這一條,而線上的翻譯必定來自線上的來源。兩者根本沒有機會分歧,也就沒有東西需要偵測。有一條規則讓它滴水不漏:在來源被編輯之後重新發布,會刷新所有已經上線的語系,而不只是你當時想到的那一個。
它是有代價的。翻譯是重新產生而不是逐處修補,所以重新產生的過程必須保留那些來源未曾變動之處的手動修正。這是實實在在的取捨,但比起一套我終究會學會不去信任的帳務系統,它小得多。我會留下的教訓是:當一項檢查很難調校時,先問問能不能直接把縫隙關起來,而不是去監看它。
這也是同一種直覺,和沒有資料庫是一致的。偏移是一種推導出來的狀態,而我當時距離把它存起來只差一次提交。
兩個軸,不是一個
每篇文章只有一個 status: draft | published 沒辦法表達一篇三語文章的狀態,因為這些狀態彼此獨立:英文已上線、日文翻譯完成但尚未發布、中文還沒寫。所以欄位有兩個,各自回答不同的問題:
status:draft/ready/published,依語系而定,用來控制發布translation:source/translated,記錄這篇文章是用哪一種語言寫的
translation 以前還有第三個值 machine,刻意與 reviewed 區分,好讓網站能對未經校閱的輸出向讀者示警。我把它拿掉了,因為沒有任何東西能驗證它。手動編輯過的檔案與機器輸出無法區分,所以這個欄位是一項沒有任何步驟會去更新的自我聲明,而在十九篇文章之中,它一次都沒有被設定過。
這也順帶解決了另一個問題:一篇文章是不是必須三種語言都存在才能發布?並不需要。缺少某個翻譯是一種永久且合法的狀態,所以 astro.config.mjs 完全沒有設定 i18n 的 fallback:不存在的語系會回傳 404,而不是安靜地端出另一種語言,hreflang 也只會宣告實際建置出來的內容。把搜尋引擎指向一種你根本沒寫的語言,比承認你沒寫還要糟。
總結
這裡沒有任何奇特的東西,而且除了載入器的設定之外,也沒有什麼是 Astro 專屬的。這些判斷共有的是一個前提:既然沒有資料庫,那麼原本該由資料庫執行的每一項約束,都必須在別的地方建起來,而最便宜的地方就是型別系統與建置。
如果你要從檔案開始做一個多語言部落格,真正起作用的順序是:
- 內容結構由你的圖片決定,不是由你的語言決定。
- 在需要之前就先轉成
.mdx。成本是零,而且那是圖表能被翻譯的唯一途徑。 - 給標籤一個與語言無關的同一性,並讓無效的標籤變成建置錯誤。
- 字型依語言選擇。絕不合併 CJK 的堆疊。
- 在發布時產生翻譯,而不是打造一套東西去監看它們偏移。