0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Astro Content Collections の Zod schema で運用ルール違反をビルドで落とす

0
Posted at

Markdown ベースのブログを運用していると、frontmatter にルールを増やしたくなるタイミングが必ず来ます。「reviews カテゴリの記事には必ず広告ディスクロージャーを付ける」「特定の構造化データには本文側にも同じ内容を書く」など、書き手が忘れがちな運用ルールを、README に書くだけでは守れなくなります。

Astro Content CollectionsZod を組み合わせると、こうしたルールの大部分をビルド時に強制できます。.refine() で値同士の関係を縛り、z.object のネストで構造化データを型付けすれば、違反した frontmatter は astro build で落ちます。

この記事では、実際に運用しているブログサイトの schema 抜粋を例に、Zod の何をどう使うと運用ルールがビルドに落ちるかをコード中心で書きます。

最小構成: defineCollection + z.object

Astro 5 系の src/content.config.ts に以下のように書くと、ブログ記事の frontmatter が型付きで検証されます。

import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";

const blog = defineCollection({
  loader: glob({
    pattern: "**/[^_]*.{md,mdx}",
    base: "./src/content/blog",
  }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    category: z.enum(["build", "reviews"]),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
    affiliate: z.boolean().default(false),
  }),
});

export const collections = { blog };

ポイントは 4 つです。

  • z.enum でカテゴリを 2 値に固定する。タイポした記事はビルドが落ちる
  • z.coerce.date2026-05-23 のような文字列を Date として扱う
  • .default(false) で省略時の値を定義する。書き忘れを許容したいフィールドに使う
  • z.array(z.string()) のような複合型もそのまま書ける

ここまでは Zod の基本的な使い方で、Astro 5 公式ドキュメントにも載っています。次の .refine() から運用ルール強制の本題に入ります。

.refine() で「値同士の関係」を縛る

2 つのフィールドが必ず連動する関係を強制したい場合、.refine() を schema の末尾に置きます。たとえばブログ記事で「categoryreviews なら affiliate は必ず truebuild なら必ず false」というルールを書くと以下になります。

const blog = defineCollection({
  loader: glob({ /* ... */ }),
  schema: z
    .object({
      title: z.string(),
      category: z.enum(["build", "reviews"]),
      affiliate: z.boolean().default(false),
      // ... 他のフィールド
    })
    .refine((data) => (data.category === "reviews") === data.affiliate, {
      message: "affiliate must be true iff category is 'reviews'",
      path: ["affiliate"],
    }),
});

両辺を === で比較しているので、どちらか一方だけが書き換わるとビルドが落ちます。category: reviewsaffiliate: false のままだとアフィリエイトリンクの自動処理(rel="sponsored" の付与、ディスクロージャーの自動挿入)が走らないので、これは絶対に防ぎたい組み合わせです。

ビルドエラーの出力はこのような形になります。

[ContentEntryInvalidError] Content config error in `blog → 2026-05-...`:
affiliate must be true iff category is 'reviews'
  at affiliate

message に書いた文字列がそのまま出るので、未来の自分にエラーの直し方が伝わるよう少し丁寧めに書いておくと運用が楽です。

.refine.superRefine の使い分け

複数の独立した制約を 1 つの .object にかけたい場合や、エラーメッセージを場所ごとに変えたい場合は .superRefine() のほうが書きやすくなります。

.superRefine((data, ctx) => {
  if (data.category === "reviews" && !data.affiliate) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "reviews 記事は affiliate: true が必須です",
      path: ["affiliate"],
    });
  }
  if (data.draft && data.updatedDate) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "draft 記事には updatedDate を付けないでください",
      path: ["updatedDate"],
    });
  }
})

縛りたい関係が 1 つだけなら .refine() で十分です。

構造化データを frontmatter で型付けする

JSON-LD の HowToFAQPage を出力するとき、ステップや質問のデータを frontmatter に置く と運用が楽になります。理由は次の通りです。

  • 本文 MDX をパースして見出しから抽出する経路を作らずに済む
  • frontmatter なので Zod で型検証が効く
  • JSON-LD のジェネレータが frontmatter を信用して読める

schema 側はこのように書きます。

const blog = defineCollection({
  loader: glob({ /* ... */ }),
  schema: z.object({
    title: z.string(),
    // ...
    howto: z
      .object({
        name: z.string().optional(),
        description: z.string().optional(),
        totalTime: z.string().optional(),
        steps: z.array(
          z.object({
            name: z.string(),
            text: z.string(),
            image: z.string().optional(),
          }),
        ),
      })
      .optional(),
    faq: z
      .array(
        z.object({
          question: z.string(),
          answer: z.string(),
        }),
      )
      .optional(),
  }),
});

記事側 frontmatter は YAML でこう書きます。

---
title: "Astro Content Collections の運用 Tips"
faq:
  - question: "Zod  .refine  .superRefine はどう使い分ける?"
    answer: "値同士の関係を 1 つだけ縛るなら .refine() で十分です。..."
  - question: "schema を後から変更したら既存記事は?"
    answer: "ビルドが落ちます。必須フィールドを追加すると..."
---

steps 配列のオブジェクトに name が無い、faq に question だけで answer が無い、といった構造的な不備はビルドで落ちます。

schema で縛れない部分

Zod は frontmatter の中身しか見ません。本文 MDX の中身は検証範囲外です。

たとえば Google の検索品質ガイドラインには「JSON-LD だけ豪華で本文に書かれていない」構造化データを mismatch とみなす規定があり、frontmatter の FAQ question が本文に出てこないとリッチリザルトの表示資格が落ちます。これは Zod では検出できません。

別レイヤーとして、grep ベースの軽い validator を CI で走らせる方法が現実的です。簡略版は以下のような実装になります。

import { readFile } from "node:fs/promises";
import { parse as parseYaml } from "yaml";

const raw = await readFile(path, "utf8");
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(raw);
if (!m) process.exit(0);

const data = parseYaml(m[1]);
const body = m[2].replace(/\s+/g, " ").toLowerCase();

const mismatches = [];
if (Array.isArray(data.faq)) {
  for (const [i, q] of data.faq.entries()) {
    const needle = q.question.replace(/\s+/g, " ").toLowerCase();
    if (!body.includes(needle)) {
      mismatches.push(`faq[${i}].question not in body: "${q.question}"`);
    }
  }
}

if (mismatches.length) {
  for (const e of mismatches) console.error(e);
  process.exit(1);
}

文字列の存在チェックだけなので、答えの正しさや表現の自然さまでは見ません。そこは公開前のレビューで担保するレイヤーになります。

レイヤー分担の整理

運用ルールを 3 層に分けて、どこで何を担保するかを決めておくと迷いません。

レイヤー 落とせるタイミング 守れること
Zod schema astro build 型 / 列挙 / 必須・任意 / 値同士の関係
Lint スクリプト pre-commit, CI 禁止フレーズ / frontmatter と本文の文字列一致
レビュー 公開前チェック 意味の妥当性 / 自動化できない判断

上のレイヤーで落とせるものは下に降ろさない、という方針にしておくと、新しい運用ルールが出てきたときに「これはどの層で守らせるか」だけ考えればよくなります。


実際に運用してみると、Zod の何を選ぶかより「どこまでを schema に寄せるか」のほうが効くと感じます。Aulvem 本家のブログでは、運用フローの背景や、schema 化しないと決めた境界線(disclosure の強度・AI 臭の総合判定など)まで含めて整理しています。

より詳しくは Aulvem の本家記事に書きました → Zod schema で運用ルールを強制する — Aulvem の Content Collections 設計

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?