目次13

書いたルールは、忘れる。

Aulvem を 1 人で回していて最初に潰したかったのは、書き手(=自分)が書いた運用ルールを書き手(=自分)が破るパターンだ。category: reviews の記事に affiliate: true を入れ忘れて広告ディスクロージャーが表示されないまま公開、みたいな事故は、README に書くだけでは止まらなかった。

なので Aulvem では、ルールのうち機械で判定できるものは Astro Content CollectionsZod schema に寄せ、違反したらビルドが落ちる構造にした。ここで書くのは「何を schema で縛り、何を諦めたか」の境界線です。

Aulvem ブログの作り で予告した派生記事の 1 本目にあたる。

なぜ README だけでは守れないのか

README にチェックリストを書いただけでは、書き手が守らないことが起きた。

  • 編集者が 1 人なので、書き手と読み手と pull request レビュアーが同じ人になる
  • 数週間後に同じ手順を踏むとき、当時の README を律儀に読み返さない
  • 過去の自分は信用していい一方で、未来の自分は素で忘れる

最初は記事追加チェックリストを docs/ 配下に置けば十分だと考えていたけれど、3 本目の記事を書いたあたりで「またあれをやり忘れた」が起きた。レビュアーが居ない状態でルールを守らせるには、運用フローの外側(= ビルド)にチェック装置を置くしかない、というのが Aulvem が schema 強制を選んでいる理由です。

Aulvem の schema 全体像

Content Collections の schema は src/content.config.ts に集約していて、blogservices の 2 コレクションを 1 ファイルで宣言している。collection ごとに別ファイルに割らないのは、コレクション間で共有したい型(pubDate, updatedDate, heroImage あたり)が出てきたときに 1 ファイルだと書き換えが完結するからだ。

骨格はこういう形になっている(記事用の blog 抜粋、一部省略)。

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(),
      summary: z.string().optional(),
      pubDate: z.coerce.date(),
      updatedDate: z.coerce.date().optional(),
      category: z.enum(["build", "reviews"]),
      tags: z.array(z.string()).default([]),
      draft: z.boolean().default(false),
      affiliate: z.boolean().default(false),
      heroImage: z.string().optional(),
      heroAlt: z.string().optional(),
      howto: z.object({ /* ... */ }).optional(),
      faq: z.array(z.object({
        question: z.string(),
        answer: z.string(),
      })).optional(),
    })
    .refine((data) => (data.category === "reviews") === data.affiliate, {
      message: "affiliate must be true iff category is 'reviews'",
      path: ["affiliate"],
    }),
});

z.enum でカテゴリを 2 値に固定し、z.coerce.date で日付の文字列表記揺れを吸収し、.optional().default() で必須/任意の境界を明示する。素朴な道具で 9 割の運用は閉じる。残りの 1 割が .refine() の出番で、これが次節の主役になります。

reviews ⇔ affiliate を .refine() で同期させる

category: reviews の記事は ASP 規約上「広告であること」を本文冒頭に明示する必要がある。Aulvem では affiliate: true の記事に対して、ディスクロージャーバナーを冒頭に自動挿入する rehype プラグインを走らせている。rel="sponsored noopener noreferrer" の自動付与も同じく affiliate: true をフラグにしている。

つまり「カテゴリは reviews だけど affiliate を false にし忘れた」記事が公開されると、ディスクロージャーも rel="sponsored" も付かないまま広告リンクを掲載することになる。これは ASP 規約違反であり、Google からは広告とユーザーコンテンツの区別がつかない記事として扱われる。

この事故を機械的に止める一行が次のコード:

.refine((data) => (data.category === "reviews") === data.affiliate, {
  message: "affiliate must be true iff category is 'reviews'",
  path: ["affiliate"],
})

両辺を === で比較しているので、どちらか一方だけが書き換わるとビルドが落ちる。XOR 条件を書く代わりに「等しいことを強制する」と書くと意図が読める形になる、と思って今の書き方に落ち着いた。

README に「reviews 記事には affiliate: true を必ず付ける」とだけ書いていた頃は、scripts/new-blog.mjs の雛形が affiliate を入れ忘れる → 書き手が気づかず公開 → 数日後に検索結果のサムネで違和感に気づく、というルートで事故が起きていた。今は雛形側にも自動挿入を入れているけれど、雛形を経由せず手で .mdx を新規作成された場合に効くのは schema 側の縛りのほうです。

howto / faq を frontmatter で型付けする

構造化データ(JSON-LD)の HowToFAQPage は、Google の検索結果でステップや質問が直接展開される枠を取れる。Aulvem ではこの 2 つを frontmatter から自動生成する経路を選んだ。

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(),

代案として「本文 MDX をパースして見出しから抽出する」もあった。最初の数記事はこれで進めようとしたけれど、見出しの構造を変えると JSON-LD が壊れるリスクがあったので捨てた。frontmatter に明示するほうが、構造化データを意識した書き方を強制できる効果もある。

frontmatter で型付けされている限り、ステップ数 0 の howto やキーの違うオブジェクトは Zod が弾く。JSON-LD 側のジェネレータは frontmatter を信用して読めるので、後工程が薄くなる。

schema では検出できない — frontmatter と本文の文字列一致

ここから先は Zod の守備範囲を超える。

Google の検索品質ガイドラインは、「JSON-LD だけ豪華で本文に書かれていない」構造化データを mismatch とみなして、リッチリザルトの表示資格を剥奪する。frontmatter に書いた FAQ の question と answer が本文に存在しない記事は、たとえスキーマ検証を通っても SEO 上は地雷になる。

Zod は本文を見ない。なので別レイヤーで検出する必要がある。Aulvem は validate-schema-match.mjs という grep ベースのバリデータを書いた。

if (Array.isArray(data?.faq)) {
  for (const [i, item] of data.faq.entries()) {
    if (!item?.question) continue;
    if (!bodyNorm.includes(normalise(item.question))) {
      mismatches.push(
        `faq[${i}].question not found in body: "${item.question}"`,
      );
    }
  }
}

frontmatter の faq[].questionhowto.steps[].name の文字列が本文の中に出現するかを正規化して比較する。出現しなければ exit code 1 を返してビルド前に止まる構造にしています。

ただしこの検出は文字列の存在だけで、意味の一致は見ない。同じ質問文の下で答えが入れ替わっていてもこのレイヤーでは通る。

schema 化しないと決めたもの

schema にも grep にも乗らないものは、自動化を諦めて別経路に逃がした。

  • FAQ の回答が事実として正しいか
  • ディスクロージャー文の強度(ASP ごとに微妙に違う)
  • 文末が AI 臭くないか
  • 結論部の主張が記事全体と整合しているか

ここは書き手が読み直さないと判断できないし、schema 化のコストが運用コストを上回る。代わりに公開前チェックリストに残し、lint-banned-phrases.mjs で機械的に拾える AI 臭フレーズだけは別途縛っている。

「全部を強制で縛れたら理想」ではなくて、ビルドが落ちる体験で何を守りたいかの優先順位を決めるほうが運用は安定すると思う。1 人で回している以上、自動化に投じられる時間にも上限があるので、効果と工数のバランスを取る箇所はある。

schema / lint / レビューの 3 層分担

ルールを 3 つのレイヤーに分けて、それぞれが何を担保するかを表にすると整理しやすい。

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

上のレイヤーで落とせるものは下に降ろさない、というのを判断基準にしている。schema で書ける関係を lint に置くと、ビルド時に落ちず lint を走らせ忘れる経路が増える。grep で十分なものを schema に積むと型定義が読みにくくなる。それぞれの層でできることに留めるのが、運用上は最短ルートだと思います。

まとめ

機械が判定できる構造制約は schema に寄せる。文字列の存在は lint に寄せる。意味の妥当性はレビューに残す。この 3 層分担を一度決めてしまえば、新しい運用ルールが出てきたときに「これはどの層で守らせるか」だけ考えればよくて、毎回 README を更新する運用にはなりません。

Aulvem サイトのスタック全体像は別記事に書いたので、合わせて読むと位置づけが分かりやすいかもしれない → このブログの作り — Astro 5 と Content Collections で組む Aulvem の構成

frontmatter を「単一情報源」として扱う設計は sitemap の lastmod にも展開していて、こちらは別記事にまとめました → sitemap の lastmod を MDX frontmatter から自分で差し込む

よくある質問

Zod の .refine.superRefine はどう使い分ければいいか?

値同士の関係を 1 つだけ縛るなら .refine() で十分です。1 つのオブジェクトに対して複数の独立した制約をかけたり、エラーメッセージを場所ごとに変えたい場合は .superRefine() を使います。Aulvem は今のところ category と affiliate の連動だけなので .refine() 1 段で済んでいます。

howto / faq を別ファイルに分けない理由は?

frontmatter にまとめて型付けできるからです。別ファイルにすると、本文・JSON-LD・frontmatter の三者で出典が分かれ、同期コストが上がります。frontmatter が単一情報源で、JSON-LD は自動生成、本文には同じ文字列を埋める運用が一番事故が少ないと判断しています。

schema を後から変更したら既存記事のビルドは落ちるか?

落ちます。たとえば必須フィールドを追加すると、既存記事の全 frontmatter にそれを足さないとビルドが通りません。これは意図された挙動で、ルール変更の波及範囲を全件で確認させる仕組みになります。

本文と JSON-LD の整合チェックを Astro の build に組み込まないのはなぜか?

build 時間を伸ばさないためです。整合チェックは grep ベースのスクリプトに切り出し、pre-commit と CI で別途走らせています。ビルド本体に組み込むと、毎回の astro dev 起動が遅くなって執筆体験を損ねます。