# 只用 Astro 內容集合經營部落格 — 捨棄 CMS、選擇 MDX，並用型別取代外鍵

> 這個部落格為什麼沒有 CMS：由圖片決定的目錄結構、選擇 MDX 而非 Markdown 的判斷，以及取代外鍵的標籤登錄表。

- Source: https://oharu121.com/zh-tw/blog/astro-content-collections-no-database-mdx-tags-cjk-fonts/
- Published: 2026-08-04T00:50:42+09:00
- Updated: 2026-08-10T17:44:46+09:00
- Tags: Astro, 國際化, Markdown

---
## 引言

在這個部落格還沒有任何一篇文章的時候，我評估過導入 headless CMS。對一個工作就只是發布文字的網站來說，那是很自然的答案，而我還是放棄了。

理由是結構性的，不是喜好問題。**這裡的一篇文章不是一列資料。** 它是一個目錄，裡面裝著三份翻譯、它們共用的 `_images/` 資料夾，以及負責繪製圖表的元件，而我希望這些能夠一起進到同一個可供審閱的提交裡。CMS 做不到這件事，因為內文會被放到 API 的另一側。

因此這個儲存庫**沒有資料庫，也沒有 CMS**：五個執行時期相依套件、一份 git 歷史，以及一套在內容有誤時就會失敗的建置。本文整理從這個前提推導出來的判斷。目錄結構是由圖片決定的，不是由語言決定的。在還沒有東西可寫之前，所有檔案就都改成了 `.mdx`。標籤取得了原本應該由外鍵提供的同一性。而字型堆疊則是依語言切換，而不是合併在一起。

## 一套 headless CMS 原本會付出的代價

CMS 賣的是網頁編輯器、媒體庫、依語系區分的內容模型、工作流程狀態，以及一個儀表板。拿這個儲存庫來對照，這些東西不是早就免費到手，就是反而礙事。

**檔案系統就是綱要，git 就是修訂歷史。** 一篇文章、它的圖片、它的兩份翻譯，以及繪製圖表的元件，構成一次提交與一份差異。把內文切出去放到託管服務上，就再也沒有任何一個修訂版本代表*這篇完整的文章*：圖片在儲存庫裡，內文在 CMS 裡，兩邊各自依自己的節奏偏離。

*Figure — CommitBoundary: 提交邊界在左右兩欄是同一個東西。改變的只是落在它裡面的內容。*

真正拍板的是翻譯的校閱。**翻譯就是一份差異**，要和它所依據的文字並排著讀。在 CMS 裡它會變成一張表單，而表單並不知道上週的英文版寫了什麼。

另一半是驗證。既然內容就是檔案，它就能通過與程式碼相同的檢查：

```json title="package.json"
"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 指南都給出同樣的結構：一種語言一個目錄。

```text
src/content/blog/
  en/my-post.md
  ja/my-post.md
  zh-tw/my-post.md
```

在文章還沒有圖表之前，這樣沒有問題。一旦有了圖表，它就得放在某個地方，而兩個選項都不好。放在一起，你就得維護同一張 PNG 的三份副本。把它提到 `src/assets/blog/my-post/`，你就放棄了就近放置：圖片不再跟著文章走，刪掉文章之後檔案還留在原地。

修正的方式是不再把語言當成主軸。**主體是文章，語言是文章裡某個檔案的屬性。**

```text
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，把檔名當成語系：

```ts title="src/content.config.ts"
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 的檔案裡：

```mdx
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` 就是兩個彼此無關的頁面，各自列出互有重疊的文章，而兩者都不是那個標籤。再加上第三種語言，就變成三個。

這正是沒有資料庫真正咬人的地方。若是關聯式綱要，標籤會是一列資料，文章與標籤之間的關聯會是外鍵，而這個約束會是資料庫的工作。換成檔案，約束就得自己建。**標籤需要一個與語言無關的同一性，再加上各語言的標示文字：**

```ts title="src/data/tags.ts"
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：

```ts title="src/content.config.ts"
tags: z.array(z.enum(TAG_SLUGS)).default([]),
```

**打錯的標籤現在會變成一個列出所有合法選項的建置錯誤**，而不是一個安靜地產生零篇文章的標籤頁。這就是參照完整性，只是改由型別在建置時執行，而不是由資料庫在寫入時執行。自由字串的標籤是安靜地失敗的，而那是內容缺陷最糟的壞法：沒有任何錯誤，頁面就只是不在那裡。

同樣的形狀也解決了封面圖。把共用縮圖做成以鍵引用的登錄表之後，二十篇文章可以共用一張圖片，替代文字也只要在圖片旁邊為每種語言寫一次，而不是在二十份 frontmatter 裡寫二十次。由於該項目本身已經是最佳化過的 `ImageMetadata`，Open Graph 圖片完全不需要產生器：

```ts
const og = await getImage({ src: cover.src, width: 1200, height: 630 });
```

我原本已經把 Satori 算進預算了，結果並不需要它。

## 一套字型堆疊只屬於一種語言

這一項，我光靠推理是找不出來的。

漢字在 Unicode 裡是統一的：日文與中文對於*寫法不同*的字共用同一個碼位。直、骨、今在同一個碼位上，各自有日文與繁體中文的不同字形。**你會拿到哪一種字形，完全取決於字型**，而文字再怎麼正確，也修不好錯誤的字形。

所以那個看起來理所當然的單一堆疊是錯的：

```css
/* 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`。頁面算繪出來了，也沒有東西缺漏。只是那些字形細微而一致地錯著：我看不出來，母語讀者一眼就看得出來。

修正的方式是依語言選擇，而不是把它們合併：

```css title="src/styles/global.css"
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` 根本不需要翻譯），而在標籤無可避免時，用算繪而不是燒進圖片。

## 翻譯是產生出來的，不是存起來的

三種語言都上線了。兩週後，英文裡一句寫錯的話被修好，另外兩種語言的版本就此變成錯的，而且沒有任何東西會說出這件事。沒有錯誤、沒有警告，建置也不會失敗。

我最初的答案是偵測。每一份翻譯檔案都記下翻譯當下來源的雜湊值：

```yaml
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 專屬的。這些判斷共有的是一個前提：**既然沒有資料庫，那麼原本該由資料庫執行的每一項約束，都必須在別的地方建起來，而最便宜的地方就是型別系統與建置。**

如果你要從檔案開始做一個多語言部落格，真正起作用的順序是：

1. 內容結構由你的**圖片**決定，不是由你的語言決定。
2. 在需要之前就先轉成 `.mdx`。成本是零，而且那是圖表能被翻譯的唯一途徑。
3. 給標籤一個與語言無關的同一性，並讓無效的標籤變成建置錯誤。
4. 字型依語言選擇。絕不合併 CJK 的堆疊。
5. 在發布時產生翻譯，而不是打造一套東西去監看它們偏移。
