iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
Modern Web

用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)系列 第 12

frontmatter 打錯一個欄位,怎麼讓 Astro 在上線前就擋下來?

  • 分享至 

  • xImage
  •  

用 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。

schema 是 build 前的內容契約

實作專案的 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。錯誤訊息明確指向 dayz.number();同一次 build 中,custom loader 已成功回報 astro@7.1.1 is unchanged,可排除網路載入錯誤。圖片路徑與其他 frontmatter 都沒有變動。

錯誤訊息用終端文字呈現,方便搜尋與複製,不必再對照相同內容的截圖。讀錯誤時依序確認四層資訊,比盯著整段 stack trace 快:

  1. 錯誤類型是 InvalidContentEntryDataError,代表 entry data 不符合 collection schema。
  2. blog → day-12-schema-zod-validation 指出 collection 與 entry。
  3. 出錯欄位是 day
  4. 原因是 schema 預期 number,raw frontmatter 卻是 string。

這次 Location 的行列是 0:0,只能幫忙找到檔案,不能精確指出 YAML 行號。可用的線索是 entry ID、欄位與原因。Astro 的 InvalidContentEntryDataError 官方說明也把排查方向放在必填欄位與欄位型別。

錯誤為什麼在頁面 render 前就被攔下來?

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:文章列表與搜尋拿不到錯型別的 dayDay 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、coerce 各自放行什麼?

四種策略的輸入與輸出如下:

策略 欄位缺少 值的型別錯誤 驗證後輸出
required 失敗 失敗 保持驗證後型別
optional() 通過 仍失敗 欄位可能不存在
default(value) 通過 仍失敗 缺省時補上預設值
coerce 視輸入能否轉換 無法轉換才失敗 轉成目標型別

例如:

  • draft 不填會由 .default(false) 補成 false,但 draft: "false" 仍是字串,不會被當成 boolean。
  • updatedDate 是 optional;不填可以,有填卻不是有效日期仍會失敗。
  • pubDatez.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 失敗,不會拖到上線後才變成讀者看到的頁面文字。

自己重現一次

要確認專案真的有這道閘門,只需要一個可還原的小實驗:

  1. 先選 schema 已定義的單一欄位,記下合法值。
  2. 執行一次 baseline build,確認原本可成功。
  3. 只把該欄改成一個確定不合法的型別。
  4. 再跑 npm run build,記錄 error class、entry、field 與 reason。
  5. 立即還原原值,再跑一次成功 build。

不要同時刪 description、改 day 又破壞圖片路徑。三種錯一起出現時,只知道「build 壞了」,卻無法證明每一條 schema 規則分別做了什麼。

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


上一篇
Astro Content Loader:本地 Markdown、JSON、外部資料怎麼進入內容層?
下一篇
文章、作者與分類怎麼建立關聯?
系列文
用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言