個人ブログ(aulvem.com)を Astro 5 + MDX + Content Collections で組みました。スタックそのものは典型構成ですが、「運用ルールをビルド時に強制する」設計に振っているので、その部分の実装をまとめます。
スタック
| パッケージ | 用途 |
|---|---|
astro ^5 |
SSG コア(静的出力) |
@astrojs/mdx ^4 |
MDX |
@astrojs/sitemap ^3 |
sitemap 生成 |
@astrojs/rss ^4 |
RSS(全文配信) |
@astrojs/tailwind ^6 |
Tailwind 統合 |
rehype-mermaid ^3 |
図のビルド時 SVG 化 |
本番依存 8 個、dev 依存 5 個。React / Vue / Vite プラグインは入れていません。
ルールは README に書かず、schema に落とす
Content Collections の schema は defineCollection + Zod で書きます。型バリデーションだけでなく .refine() を使うと、業務ルールも縛れます。
たとえば「category: reviews の記事は必ずアフィリエイト記事(affiliate: true)として扱う」というルールを、運用の気合いではなく schema に落とします。
// src/content/config.ts
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
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"],
},
),
});
export const collections = { blog };
これで category: reviews なのに affiliate: true を書き忘れた MDX があると、astro build が落ちます。ASP 規約上「広告であること」を明示する必要があるカテゴリなので、書き忘れを README に書いておくのではなくビルドで止めたかった、というのが理由です。
affiliate: true の記事には、本文冒頭にディスクロージャー文を自動で挿入し、外部リンクには rel="sponsored nofollow" を rehype プラグインで自動付与しています。schema レイヤーで「広告記事である」と確定すれば、表示レイヤーは frontmatter を信じて分岐するだけで済みます。
構造化データと本文を一致させる
frontmatter で howto / faq を書いて JSON-LD を自動生成する場合、本文中にも同じ Q&A を必ず書く必要があります。Google の構造化データ品質ガイドラインで「JSON-LD だけ豪華にして本文に書かない」と検出されると、リッチリザルトの表示資格を失います。
書く側のルールとしては単純で、「frontmatter と本文の両側に同じ内容を書く」を約束にしておきます。片側だけ更新すると、しばらく気づかないタイプの事故になりやすいので、frontmatter の faq / howto を編集したら必ず本文も同じ表現に揃えるのを習慣にしておくと安全です。
sitemap の lastmod は MDX frontmatter から自前で読む
@astrojs/sitemap は MDX frontmatter の updatedDate を読みません。なので astro.config.mjs で全 MDX を舐めて自前で lastmod を差し込みます:
// astro.config.mjs(抜粋)
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
async function buildBlogLastmodMap() {
const map = new Map();
for (const lang of ["en", "ja"]) {
const dir = join(process.cwd(), "src", "content", "blog", lang);
const files = await readdir(dir).catch(() => []);
for (const file of files) {
if (!file.endsWith(".mdx")) continue;
const slug = file.replace(/\.mdx$/, "");
const raw = await readFile(join(dir, file), "utf8");
const fm = /^---\n([\s\S]*?)\n---/.exec(raw);
if (!fm) continue;
if (/^draft:\s*true/m.test(fm[1])) continue;
const updated = /^updatedDate:\s*(\S+)/m.exec(fm[1]);
const pub = /^pubDate:\s*(\S+)/m.exec(fm[1]);
const dateStr = (updated && updated[1]) || (pub && pub[1]);
if (!dateStr) continue;
const path = lang === "ja" ? `/ja/blog/${slug}/` : `/blog/${slug}/`;
map.set(path, new Date(dateStr).toISOString());
}
}
return map;
}
const blogLastmod = await buildBlogLastmodMap();
serialize で item.lastmod = blogLastmod.get(url.pathname) を差し込みます。
これを書かないと lastmod がビルド時刻に固定され、「全記事が毎ビルド更新されている」というノイズシグナルを検索エンジンに送ることになります。AI 検索の鮮度判定にも lastmod は使われるので、ここは正しく差し込みたい部分です。
ハマりどころ
Shiki と rehype-mermaid が衝突する
mermaid のコードブロックを Shiki がトークナイズすると、rehype-mermaid 側がマッチしなくなって SVG 化されません。astro.config.mjs で Shiki から mermaid を除外します:
markdown: {
shikiConfig: {
excludeLangs: ["mermaid"],
},
// ...
}
rehype-mermaid の inline-svg strategy は Playwright 依存
strategy: "inline-svg" を使うと SVG が HTML に直接埋まり、クライアント JS ゼロで読めますが、ビルド時に Playwright + Chromium が必要で初回ビルドが 30〜60 秒長くなります。
JS-free・AI 検索・RSS フィードに優しい構成を取るならこのトレードオフは飲む価値があります(RSS リーダーや AI クローラーは JS を実行しないので、SVG が img タグ越しだと図の中のテキストが読まれません)。
まとめ
「人間が忘れる種類のルールはビルド時に強制する」を 3 箇所(schema.refine / 構造化データの一致 / sitemap lastmod)で押さえると、書き手の記憶に頼らずビルド側で止められる構造になります。Astro 5 の Content Collections と Zod の .refine() は、このスタイルにかなり素直に乗ります。
設計時に却下した他案、retire(記事削除)や内部リンク掃除も含めた運用フロー全体は Aulvem 本家にまとめています。
→ このブログの作り — Astro 5 と Content Collections で組む Aulvem の構成 | Aulvem