Content Collectionsの型でfrontmatterの事故を防ぐ
このサイトの記事はMarkdownで書いていて、各記事の冒頭にfrontmatterでタイトルや日付、slugなどを書く形にしています。記事が数本のうちは目視の確認で足りますが、数が増えてくると、小さな抜けやブレがそのまま表示の事故になります。slugが無ければURLはファイル名由来になって、思った形にならない。日付の書き方が記事ごとに違えば、一覧の並び順が崩れる。タグの表記が揺れれば、同じ意味のタグページが2つに割れる。
どれも本文の内容とは関係のない、メタ情報側の事故です。だからこそ、書く人の注意力ではなく仕組みで止めたい。AstroのContent Collectionsには、frontmatterをZodのスキーマで検証する仕組みがあるので、このサイトではそれを使っています。この記事では、実際に使っているスキーマと、事故パターンごとに「型でどこまで防げるか」を整理します。なお、サイト自体をAstroへ移した経緯は、GatsbyからAstroへ移行した理由:判断軸の整理 に書きました。
スキーマの実例
定義は src/content/config.ts に書きます。コレクションは type: 'content' で定義し、スキーマは image ヘルパーを受け取る関数形にしています(理由は後述の cover です)。
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: ({ image }) =>
z.object({
title: z.string(),
date: z.coerce.date(),
description: z.string(),
slug: z.string().optional(),
category: z.string(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
template: z.string().optional(),
cover: image().optional(),
}),
});
export const collections = { blog };
ポイントは2つあります。ひとつは、スキーマに合わないファイルがあると、開発サーバーやビルドの段階でエラーとして知らせてくれること。公開してから気づく事故が、手元の検証に前倒しになります。もうひとつは、スキーマからTypeScriptの型も生成されること。テンプレート側で post.data.title のように書くとき、補完と型チェックが効くようになります。
事故パターンごとに見る
代表的な3パターンを、起きることと型での防ぎ方で並べるとこうなります。
| パターン | 起きること | 型での防ぎ方 |
|---|---|---|
| slugの欠落と形式ブレ | スキーマ上は任意項目だが、URLはルーティング側が normalizePostSlug(post.data.slug, post.slug) で決める。frontmatterのslugがあればそれが優先され、無ければファイル由来のslugにフォールバックする。/posts/ の接頭辞と末尾スラッシュはその関数が正規化する。つまり「書かなければ壊れる」ではなく「書かないと意図しないURLになる」という事故 | 型では縛っていない。その代わり、全記事に必ずslugを入れる運用ルールにして、形式のブレは正規化関数が吸収する。型で防ぐのではなく、運用+正規化で担保する項目 |
| 日付の形式ブレ | クォートなしで 2026-02-03 と書いたり、文字列で書いたりが混ざりがちな項目で、YAMLの解釈次第で文字列になったり日付になったりする。そのままだと一覧の並び替えや表示のフォーマットが「文字列かDateか」を気にしないといけない | z.coerce.date() にして、どちらの書き方で来ても Date オブジェクトに揃える |
| タグの表記ゆれ | Astro と astro と Astro は、z.array(z.string()) の検証を全部素通りする。文字列であることしか見ていないので、同じ意味のタグページが分裂する | 今のスキーマでは防げていない、が正直なところ。z.array(z.string()).default([]) が保証するのは「文字列の配列であること」と未指定時の空配列だけ。仕組みで止めるなら transform で正規化を足す手があり(下のコード参照)、現状は既存タグを見ながら書く運用で受けている |
slugについては、正直に書くと「型で防いでいる」とは言えません。スキーマでは任意項目で、正規表現のような形式チェックも入れていません。URLは後から変えるとリダイレクトの手間がついて回る場所なので、本来は入り口で厳しくしたいところです。ただ、このサイトでは代わりに2つの仕組みで担保しています。1つは、全記事にslugを必ず書くという運用ルール。もう1つは、ルーティング側の normalizePostSlug が接頭辞と末尾スラッシュのブレを吸収することです。型で防げるものと、運用と正規化で担保するものを切り分けること自体が、この記事の学びだと思っています。
タグの表記ゆれを仕組みで止めるなら、正規化を足す形になります。具体的にはこの形です。
tags: z
.array(z.string())
.transform((tags) => [...new Set(tags.map((t) => t.trim().toLowerCase()))])
.default([]),
表にしきれなかった、型の担当範囲外の話も2つあります。
- slugの重複は防げない: スキーマは1ファイルずつの検証なので、2本の記事に同じslugを書いても、型の上ではどちらも正しくなります。ここは型の担当範囲外と割り切って、ファイル名とslugを揃える運用でカバーしています
- 日付の「見た目の形式」までは縛っていない:
coerceは変換できる文字列を広く受け入れます。形式そのものをYYYY-MM-DDに固定したいなら正規表現で縛る手もありますが、並び順と表示が目的ならDateに揃うことの方が重要で、見た目の形式はファイル名と揃える運用で足りる、というのが今の判断です - タグの同義語ゆれは寄せきれない: 日本語タグの同義語ゆれ(「テスト」と「Testing」を同じにするか、など)までは機械では寄せきれません。そこは割り切って、既存タグの一覧を見ながら書く運用にしています
ついでに、date が Date に揃っていることは、RSSの pubDate のように「日付として渡す」場面でも効きます。その応用は AstroでRSSとサイトマップを正しく出す にまとめました。
デフォルト値とoptionalの使い分け
全部の項目を必須にすると、記事を書くたびの手間が増えて続きません。線引きの基準は「無くて事故になるか」です。
| 区分 | 項目 | 考え方・運用 |
|---|---|---|
| 必須 | title、date、description、category | 欠けると表示か一覧が壊れるものは、入り口で落とす |
| デフォルト値 | draft(false)、tags(空配列) | draftは、書き忘れたら「公開される側」に倒れるようにしている。下書きにしたいときだけ draft: true を明示する運用。tagsは空配列で困らない |
| optional | slug、template、cover | slugはスキーマ上は任意だが、全記事に必ず入れる運用にしている(理由は前節のとおり)。templateは過去の設定を受ける任意項目で、現在の記事ルーティングでは使用していない。coverは無い記事の方が多いので、無いことを前提にテンプレート側で出し分ける |
なお、coverは文字列ではなく image() ヘルパーで受けています。スキーマを関数形にしているのはこのためで、Astro管理下の画像としてパスの実在チェックまで型の仕組みに寄せられます。
この線引きをしておくと、frontmatterのテンプレートは短く保てますし、検証エラーが出たときに「これは直さないと表に出るやつだ」とすぐ分かります。
astro checkの位置づけ
スキーマによるfrontmatterの検証は、開発サーバーやビルドのタイミングで走ります。それとは別に astro check があります。これはプロジェクト全体に診断をかけて、.astro ファイルの中の型の食い違いなどを拾うコマンドで、公式ドキュメントでもCIワークフローでの利用が想定されています。エラーがあれば終了コード1で終わるので、CIの合否にそのまま使えます。
役割分担としては、frontmatterの形はスキーマが受け持ち、テンプレート側で
dataの使い方を間違えていないかはastro checkが受け持つ、という整理です。
たとえばスキーマから項目を消したときに、まだその項目を参照しているテンプレートが残っていれば、astro check 側で気づけます。両方をCIに置いておくと、記事の追加やスキーマの変更でサイトが壊れる経路はだいぶ狭くなります。
まとめ
frontmatterの事故は、注意力ではなく検証の前倒しで減らすのが合っています。スキーマで形を縛るところは縛り、防げないもの(slugの重複やタグの同義語ゆれ)は「型の担当範囲外」とはっきりさせて運用で受ける。その線引きまで含めて決めておくことが、個人ブログくらいの規模でも効くやり方だと思います。
参考
- Content Collections | Astro Docs: https://docs.astro.build/en/guides/content-collections/
- CLI Commands | Astro Docs: https://docs.astro.build/en/reference/cli-reference/