用 Content Collection 的 schema 驗證 frontmatter。內容進入 Astro Content Layer 時,會先經過 Zod 檢查;缺少必填欄位、型別不對或不符合規則,build 會直接中止,不會拖到部署後才在文章列表、RSS 或頁面模板暴露錯誤。
Day 9:Markdown 與 MDX談正文格式,Day 10:Content Collections談如何把一批內容收進同一個集合。schema 接著定義這個集合的內容契約:每篇必須有哪些欄位、接受什麼型別,以及缺省時要拒絕、忽略還是補值。
本文以 Astro 7.1.1、Zod 4.4.3、Node 24.16.0 為實測基準,查證日期為 2026-07-24。Astro 7 的官方寫法是從
astro/zod匯入z,專案會跟著 Astro 使用同一套 Zod 4 API。
實作專案的 blog collection schema 同時用了幾種驗證策略:
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: './src/content/blog' }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
day: z.number().int().positive(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
author: z.string().optional(),
heroImage: z.string().optional(),
cover: z
.object({
src: image(),
alt: z.string().min(1),
})
.optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
各個 validator 對輸入與輸出的處理不同:
| 寫法 | 接受的 raw frontmatter | 驗證後的資料 |
|---|---|---|
z.string() |
必須存在且是字串 | 保持字串 |
.int().positive() |
必須是大於 0 的整數 | 保持 number |
z.coerce.date() |
必須能轉成有效日期 | 轉成 Date |
.optional() |
可以不填;有填仍要通過前面的規則 | 欄位可能不存在 |
.default(false) |
可以不填;有填仍必須是 boolean | 缺省時補成 false |
image() |
Astro 能接手處理的圖片路徑 | 進入 Astro 資產流程 |
schema: ({ image }) => ... 使用 callback,是因為 schema 需要取得 Astro 提供的 image() helper。圖片欄位會進入 Astro 的資產處理流程,相關細節見 Day 18:Astro Image/Picture 基礎。如果 schema 不需要 context helper,可以直接傳 z.object(...)。
z.string() 只確認型別,所以 title: "" 仍然會通過。若內容規則不接受空標題,要明寫成 z.string().min(1)。schema 嚴格到哪裡由專案決定,Zod 只執行寫下來的規則。
Day 12 的 entry 原本使用合法值:
day: 12
先用 Node 24.16.0 執行 npm run build,Content Layer sync 與整個 build 都成功,文章頁也產生在:
dist/client/blog/day-12-schema-zod-validation/index.html
接著只把同一欄改成字串,其他 frontmatter 與正文不動:
- day: 12
+ day: "十二"
這次 build 在 [content] Syncing content 階段停止,還沒開始 prerender routes:
[InvalidContentEntryDataError] blog → day-12-schema-zod-validation data does not match collection schema.
day: Expected type "number", received "string"
Hint:
See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.
Error reference:
https://docs.astro.build/en/reference/errors/invalid-content-entry-data-error/
Location:
.../app/src/content/blog/day-12-schema-zod-validation.md:0:0
確認錯誤後,day 立即還原成 12。錯誤訊息明確指向 day 的 z.number();同一次 build 中,custom loader 已成功回報 astro@7.1.1 is unchanged,可排除網路載入錯誤。圖片路徑與其他 frontmatter 都沒有變動。
錯誤訊息用終端文字呈現,方便搜尋與複製,不必再對照相同內容的截圖。讀錯誤時依序確認四層資訊,比盯著整段 stack trace 快:
InvalidContentEntryDataError,代表 entry data 不符合 collection schema。blog → day-12-schema-zod-validation 指出 collection 與 entry。day。這次 Location 的行列是 0:0,只能幫忙找到檔案,不能精確指出 YAML 行號。可用的線索是 entry ID、欄位與原因。Astro 的 InvalidContentEntryDataError 官方說明也把排查方向放在必填欄位與欄位型別。
Day 11:Content Loader區分了資料來源;在資料處理流程中,schema 位在 loader 與 store 之間:
來源 → loader → parseData(schema) → store → getCollection() → 頁面
glob() 讀到 Markdown frontmatter 後,先把 raw data 交給 collection schema。Zod 驗證成功,parsed data 才寫進 store;失敗就中止 Content Layer sync。所以下游的 Day 14:文章列表與搜尋拿不到錯型別的 day,Day 20:RSS 與 JSON endpoint也不會輸出一份欄位已壞掉的 feed。
custom loader 取得外部資料後,要自己呼叫:
const data = await parseData({ id, data: rawData });
store.set({ id, data });
依 Content Loader API 的契約,parseData() 才會套 collection schema,store.set() 本身不負責驗證。跳過 parseData(),collection schema 就不會執行。
這裡驗的是 build-time 內容,不是瀏覽器送進來的表單。使用者 request 的資料仍要在 Action 或 API 邊界重新驗證,兩者的分工可對照 Day 21:Actions 與自寫 API。
四種策略的輸入與輸出如下:
| 策略 | 欄位缺少 | 值的型別錯誤 | 驗證後輸出 |
|---|---|---|---|
| required | 失敗 | 失敗 | 保持驗證後型別 |
optional() |
通過 | 仍失敗 | 欄位可能不存在 |
default(value) |
通過 | 仍失敗 | 缺省時補上預設值 |
coerce |
視輸入能否轉換 | 無法轉換才失敗 | 轉成目標型別 |
例如:
draft 不填會由 .default(false) 補成 false,但 draft: "false" 仍是字串,不會被當成 boolean。updatedDate 是 optional;不填可以,有填卻不是有效日期仍會失敗。pubDate 用 z.coerce.date(),可把 YAML 日期或日期字串轉成 Date;無法解析的值還是會失敗。tags 不填會得到空陣列,但 tags: astro 不是陣列,仍不符合 schema。Zod 4 的 default 行為是在輸入為 undefined 時直接回傳預設值;它不是一個「什麼都幫你轉」的寬鬆模式。這個版本差異可在 Zod 4 migration guide核對。
把 fallback 寫進契約,和只在取值時處理 fallback,會在不同時機暴露錯誤。下面這個沒有 schema 的專案呈現了後一種情況。
一個上線中的多語系品牌官網,內容正本是四份語系 JSON,沒有 Content Collection 也沒有驗證層。四份檔案的 key 數量並不一致:英文 89 個,其他三個語系各 83 個。取值經過一層包裝函式:
// 查不到就退回英文;英文也沒有,就回傳字串 'NONE'
if (_get === key) return defText ? defText : 'NONE';
於是「這個語系少了六個字串」不會在任何階段中止流程。build 仍然成功,頁面也照常產生,缺少的欄位則在正式畫面上顯示為 NONE。讀者看到頁面時,錯誤才暴露。
這個設計有它的理由。當翻譯進度落後於功能時,硬性要求四個語系同時齊備,會讓缺一個字串就無法上線;用 fallback 換取「先上線」是可以接受的取捨。差別在最後一層 fallback 選了什麼:NONE 是一個會被渲染出來的字串,不是一個訊號。
對照上表,.optional() 與 .default(value) 也屬於 fallback,但規則寫在契約裡,明講「這個欄位可以缺、缺的時候補什麼」;沒有寫進契約的缺漏就會中止 sync。內容來源是 JSON、YAML 或外部 API 時,可以走 file() 或 custom loader 進 Content Layer;使用 custom loader 時,要在 loader 裡呼叫 parseData()。這樣 key 不完整會讓 build 失敗,不會拖到上線後才變成讀者看到的頁面文字。
要確認專案真的有這道閘門,只需要一個可還原的小實驗:
npm run build,記錄 error class、entry、field 與 reason。不要同時刪 description、改 day 又破壞圖片路徑。三種錯一起出現時,只知道「build 壞了」,卻無法證明每一條 schema 規則分別做了什麼。
合法 day: 12 能 build;只改成 "十二" 時,Content Layer 在頁面 render 前拒絕 entry;還原後再次 build 成功。內容列表、搜尋、RSS 和 JSON 都只會讀到通過 schema 的 parsed data。
schema 是內容進入網站前必須通過的契約。required、optional、default 與 coerce 定義「哪些錯該被擋、哪些缺省可以接受」;規則寫清楚後,Content Layer sync 會在資料進入頁面前指出欄位錯誤。
schema 能檢查單一 entry 的欄位;Day 13:作者與分類關聯接著檢查跨 entry 的關係:文章、作者與分類除了型別正確,也要確認引用目標真的存在。
本日程式碼:step-12|只看這天的改動:step-11...step-12