タグ
黒いカードにピンクの炎を伴う白のAstroロケットロゴとロゴタイプ

Astroのコンテンツコレクションだけでブログを運用する — CMSを捨て、MDXを選び、外部キーを型で置き換えた話

このブログにCMSがない理由。画像から決まったディレクトリ構成、MarkdownではなくMDXを選んだ判断、そして外部キーの代わりになったタグレジストリについて整理します。

更新

目次

はじめに

このブログに記事が1本もなかった頃、私はヘッドレスCMSの導入を検討しました。テキストを公開することだけが仕事のサイトにとって、それは普通の答えです。それでも見送りました。

理由は好みではなく構造的なものです。ここでの記事は1行のレコードではありません。 3つの翻訳と、それらが共有する_images/フォルダ、図版を描くコンポーネントを収めたディレクトリであり、私はそのすべてが1つのレビュー可能なコミットに収まってほしかったのです。CMSにそれはできません。本文がAPIの向こう側に置かれるからです。

そのため、このリポジトリにはデータベースもCMSもありません。実行時の依存は5つ、履歴はgit、そして内容が誤っていればビルドが失敗します。本記事では、この前提から導かれた判断を整理します。ディレクトリ構成は言語ではなく画像から決まりました。中身を書き始める前に、すべてのファイルを.mdxにしました。タグには外部キーが与えるはずだった同一性を持たせました。そしてフォントスタックは統合せず、言語ごとに切り替えています。

ヘッドレスCMSが要求したはずのコスト

CMSが売っているのは、Webエディタ、メディアライブラリ、ロケール別のコンテンツモデル、ワークフロー状態、そしてダッシュボードです。このリポジトリに照らすと、それぞれはすでに無償で手に入っているか、むしろ邪魔になります。

ファイルシステムがスキーマであり、gitが変更履歴です。 記事と画像、2つの翻訳、図版を描くコンポーネントは、1つのコミットと1つの差分になります。本文だけをホスティングサービスへ切り出すと、記事一式を意味するリビジョンが存在しなくなります。リポジトリには画像があり、CMSには本文があって、両者はそれぞれの都合で離れていきます。

本文がファイルのときだけ、1 つのコミットが記事全体を覆う2 つの列が並ぶ。左では破線のコミット境界が記事の 5 つの構成要素、すなわち 3 つのロケールファイルと共有画像フォルダ、図版フォルダをすべて囲む。右では同じ境界が画像と図版のフォルダだけを囲み、3 つのロケールファイルは API リンクで結ばれた別の CMS の枠の中にあるため、記事全体を含むリビジョンが 1 つも存在しない。ファイルとしての本文1 つのコミットen.mdxja.mdxzh-tw.mdx_images/_figures/本文・画像・図版が一緒に動く1 つのリビジョンが記事全体なので、翻訳を差分としてレビューできるCMS 上の本文API 経由で取得する CMSen.mdxja.mdxzh-tw.mdxAPI1 つのコミット_images/_figures/本文と素材が別々に版管理され、記事全体を含むリビジョンが存在しない
コミットの境界は左右どちらも同じものです。変わるのは、その内側に何が入るかだけです。

決め手になったのは翻訳のレビューです。翻訳とは差分です。 元になった文章と並べて読むものであり、CMSではそれがフォームになります。フォームは、先週の英語版に何が書かれていたかを知りません。

もう半分はバリデーションです。内容がファイルである以上、コードと同じ検査を通せます。

package.json
"check": "astro check && pnpm run check:i18n && pnpm run thumbnails:check"

scripts/i18n-check.tsは213行あり、スキーマだけでは扱えないものを報告します。どのロケールからも参照されていない画像、同一であるべきなのにロケール間で食い違うフロントマターの項目、ソースにあって翻訳にない図版などです。これらはファイルをまたいだ整合性の制約であり、ファイルがリポジトリにあるからCIで動きます。

検索さえこの欠落を生き延びます。pagefind --site distはビルド後に出力されたHTMLを索引化するので、サイトが持つのはクエリ層ではなく静的な索引であり、検索のために何かを起動しておく必要はありません。

手放したものは現実にあり、そして狭い範囲です。Webエディタがないのでスマートフォンからは書けませんし、技術者でない寄稿者が記事を出すこともできません。ターミナルに住んでいる著者ひとりのブログにとって、それはコストになりません。CMSが売り込んでいたはずのダッシュボードはscripts/status.ts76行です。

コーパスを数えることで文体ルールができた

もっとも遠くまで届いた帰結は、ビルドではなく文章のほうに現れました。ファイルであるコーパスは数えられます。 そのため、このブログが従う文体ルールは意見ではなく計測値です。emダッシュは私の公開済みコーパスで記事あたり中央値1に対し、エージェントが書いた最初の下書きでは17でした。太字は100語あたり1.1から2.9に対し、0.42と0.20でした。scripts/prose-check.tsはそのうち機械的に判定できる部分を終了コードに変換するので、逸脱した下書きは気分ではなくコマンドで落ちます。

CMSでもこれが不可能になるわけではありません。エクスポートは存在するからです。ただしコーパスはエクスポートの片側に、そこから導いたルールはもう片側に置かれ、別々に版管理され、別々に古びていきます。もう半分は著者がひとりであることです。複数著者のコーパスは、文体を測る前にハウススタイルへ平均化されてしまい、どの数値も著者を識別する項目がなければ意味を持ちません。

ひとつ罠がついてきます。これは当然の反論でもあります。エージェントが書いた記事は、次の計測が読むのと同じリポジトリに入ります。つまりリポジトリを測ることは、やがてエージェントを測ることになり、エージェント自身の癖へ収束していきます。上の数値は、この仕組みが存在する前に私が公開した記事から数えたもので、エージェントが書いた下書きは対照サンプルとしてのみ登場します。

ディレクトリ構成は画像が決める

Astroのi18nガイドはどれも同じ構成を示します。言語ごとに1つのディレクトリです。

src/content/blog/
en/my-post.md
ja/my-post.md
zh-tw/my-post.md

記事に図が入るまでは、これで問題ありません。図が入ると置き場所が必要になり、選択肢はどちらも良くありません。同じ場所に置けば、同一のPNGを3つ維持することになります。src/assets/blog/my-post/へ持ち上げれば、コロケーションを手放すことになります。画像が記事と一緒に動かなくなり、記事を消してもファイルが残ります。

修正は、言語を主軸として扱うのをやめることです。主体は記事であり、言語はその中のファイルが持つ属性です。

src/content/blog/
astro-content-collections-no-database-mdx-tags-cjk-fonts/
en.mdx
ja.mdx <- absent means "not translated", which is a valid state
zh-tw.mdx
_images/
pipeline.png <- one copy, all three languages

ローダーはフォルダをスラッグ、ファイル名をロケールとして扱います。

src/content.config.ts
const blog = defineCollection({
loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: './src/content/blog' }),
// ...
});

エントリIDはastro-content-collections-no-database-mdx-tags-cjk-fonts/jaのような形で出てくるので、ヘルパー1つで分解し直します。どのロケールのファイルも./_images/pipeline.pngを同じ文字列で参照し、Astroは3つすべてに対して最適化済みの派生画像を1つだけ出力します。

私はこの主張を、仮定ではなく確認するよう依頼しました。記事1本でビルドすると、形式と幅の組み合わせで12個の派生画像ができました。同じカバー画像を使う2本目を追加しても、24ではなく12のままでした。アセットパイプラインは内容で重複を排除するので、共有は無償です。

この構成のコストは、ドキュメントが示す形ではないことです。チュートリアルから写したものは、そのままでは合いません。コストはそれだけで、記事1本に画像が1枚入った時点で払う価値があります

何かを書き始める前に全ファイルをMDXへ変換した

私の記事はMarkdownでした。Astroはどちらも扱えますし、上のコレクションのglobもどちらも受け付けるので、判断を迫るものは何もありませんでした。それでもこのリポジトリの初日に、すべてを.mdxへ変換しました。

変換のコストはゼロでした。 これが論拠の前半です。MDXはMarkdownのスーパーセットなので、散文だけのファイルは拡張子を変えてもバイト単位で同一のまま、同じ挙動をします。移行作業も互換モードもありません。

後半は.mdにできないことです。文字を焼き込んだPNGの図は、どうやっても1つの言語にしかなりません。 3つのサイトのうち2つでは、それが誤った言語になります。コンポーネントなら、現在のロケールを読んでラベルを描けます。その選択肢は、コンパイラがimportを許すファイルにしか存在しません。

import Figure from '@/components/article/Figure.astro';
import Architecture from './_figures/Architecture.astro';
<Figure>
<Architecture />
<Fragment slot="caption">What the reader should take from it.</Fragment>
</Figure>

この賭けが回収できるまでには、記事30本ほどかかりました。現在このリポジトリにある35のロケールファイルのうち、20が図版コンポーネントをimportし、13は何もimportしていません。この13こそ、ルールを個別判断ではなく一律にしている理由です。図が増えたせいで記事をリネームする瞬間はありませんし、新しいファイルの拡張子をどうするか答える必要もありません。

構文上の違いが3つついてきますが、いずれも行と桁を示してビルドが失敗するので、危険ではなく煩わしさの部類です。コメントは{/* … */}です。<!-- … -->はMDXでは不正だからです。散文中の裸の{<はJSXとして解釈されるので、`{ foo: 1 }`にはバッククォートが要ります。もともと付けるべきものです。そして翻訳対象のテキストは属性ではなく子要素に置きます。<Callout>text</Callout>はさらに2言語への翻訳に耐えますが、<Callout title="text" />は翻訳者に属性の書き換えを促してしまいます。

同じタグが2つの名前で存在していた

日本語の記事では生成AI、英語ではGenerative AIとタグを付けていました。どちらも正しく、1年のあいだ目に見える問題は起きませんでした。

問題は翻訳ではなく同一性です。 タグがその言語のフロントマターに書かれた文字列そのものだとすると、/tags/生成AI/tags/generative-aiは、重なり合う記事を並べる無関係な2つのページになり、どちらもそのタグではありません。3つ目の言語を加えれば3つになります。

データベースがないことが実際に効いてくるのはここです。リレーショナルなスキーマであれば、タグは1行になり、記事とタグの関係は外部キーになり、制約はデータベースの仕事になっていたはずです。ファイルの場合、制約は自分で作るしかありません。タグには言語に依存しない同一性と、言語ごとのラベルが必要です。

src/data/tags.ts
export const TAGS = {
'generative-ai': { en: 'Generative AI', ja: '生成AI', 'zh-tw': '生成式 AI' },
'i18n': { en: 'i18n', ja: '国際化', 'zh-tw': '國際化' },
} as const satisfies Record<string, Record<Locale, string>>;

フロントマターが持つのはスラッグです。/tags/generative-ai/ja/tags/generative-aiは、同じ記事集合をローカライズされた見出しの下に並べたものになります。

自分でも重要になるとは思っていなかったのが、このレジストリからZodのenumを導く部分です。

src/content.config.ts
tags: z.array(z.enum(TAG_SLUGS)).default([]),

打ち間違えたタグは、有効な選択肢を列挙したビルドエラーになりました。 記事が0本のタグページが黙って生成される代わりにです。これは参照整合性であり、書き込み時にデータベースが担う代わりに、ビルド時に型が担っています。自由文字列のタグは静かに失敗します。これは内容の不具合としては最悪の壊れ方です。エラーも出ず、ただページがないのです。

同じ形がカバー画像も解決しました。共有サムネイルをキーで参照するレジストリにすると、20本の記事が1枚の画像を共有でき、代替テキストは20個のフロントマターに20回書く代わりに、画像の隣で言語ごとに1回書けば済みます。エントリがすでに最適化済みのImageMetadataなので、Open Graph画像に生成器は要りません。

const og = await getImage({ src: cover.src, width: 1200, height: 630 });

Satoriを見込んでいましたが、必要ありませんでした。

1つのフォントスタックは1つの言語のもの

これは考えるだけでは見つけられなかったものです。

漢字はUnicodeで統合されています。日本語と中国語は、字形が異なる文字に同じコードポイントを共有しています。直、骨、今は、同じコードポイントに日本語と繁体字中国語の別々の字形を持ちます。どちらの形になるかはフォントだけで決まり、テキストがどれだけ正しくても誤った字形は直りません。

だから、素直に1つへまとめたスタックは誤りです。

/* wrong — every CJK page renders with Japanese glyph shapes */
font-family: system-ui, 'Hiragino Sans', 'Noto Sans TC', sans-serif;

Hiragino Sansは繁体字中国語のページが必要とする文字を含むので、ブラウザはNoto Sans TCまで到達しません。ページは表示されます。欠けているものもありません。ただ字形が、わずかに、しかし一貫して誤っています。私には見えず、母語話者にはすぐ見えます。

修正は、統合ではなく言語ごとに選ぶことです。

src/styles/global.css
html[lang^='ja'] {
--font-sans: system-ui, 'Hiragino Sans', 'Noto Sans JP', sans-serif;
}
html[lang^='zh'] {
--font-sans: system-ui, 'PingFang TC', 'Noto Sans TC', sans-serif;
}

これは上のMDXの判断を支える、もっとも強い論拠でもあります。同じ罠が生成した図にも当てはまり、そちらではさらに悪いからです。私のSVGからPNGへの変換パイプラインはHiragino Sansを固定しています。素のsans-serifではcairosvgが豆腐を出すからです。それを繁体字中国語のラベルへ向けると、もっともらしく、しかし誤った字形がPNGに焼き込まれ、フォールバックも警告もありません。豆腐のほうが正直なところましでした。豆腐は一目でわかるからです。図版コンポーネントにはこの失敗の仕方がありません。 ページが選んだスタックをそのまま継承するからです。

そこで図のスキルに書き込んだルールは、まず言語に依存しないラベルを優先すること(POST /articlesvalidatequeueなら翻訳は要りません)、そしてラベルが避けられない場合は焼き込まずに描画することです。

翻訳は保持せず、生成する

3言語が公開されています。2週間後に英語の誤った一文が直され、残る2言語はそれを知らせるものが何もないまま誤ったままになります。エラーも警告も、ビルドの失敗もありません。

最初の答えは検出でした。翻訳した時点のソースのハッシュを、翻訳ファイルごとに記録します。

translation: 'machine'
sourceHash: 'a3f8c21b09e4d7f2'

検査時に再計算し、比較し、ずれを報告する。作りました。そして、難しいのはハッシュではないと気づきました。難しいのは、何を変更とみなすかを決めることでした。 それについての問いはどれも、正解のない判断でした。

  • 段落の折り返しを直したのは変更でしょうか。
  • コードブロック内のコメントを直したのは変更でしょうか。コード自体は3言語で同一ですが、変わったコメントは読者が読む文章です。
  • 画像ファイル名を変えたのは変更でしょうか。

さらに悪いことに、このルールはラチェットです。50本を1つの定義でハッシュ化し、そのあと定義を洗練させると、50本すべてが一斉に陳腐化したと報告されます。全部翻訳し直すか、ハッシュを何も見ずに押し直して、そのために作った信号を壊すかのどちらかです。

翻訳をソースの隣で維持しないこと。公開時にソースから生成すること。 公開処理は現在のソースから対象ロケールをすべて再生成し、そのあとでステータスを切り替えます。テキストがサイトへ到達する経路はそこしかないので、公開中の翻訳は必ず公開中のソースから来たものになります。両者が食い違える窓が存在しないので、検出すべきものもありません。これを隙のないものにする規則が1つあります。ソースを編集したあとの再公開は、指定した1つではなく、すでに公開されているすべてのロケールを更新することです。

代償はあります。翻訳は差分適用ではなく再生成なので、ソースが変わっていない箇所については手作業の修正を再生成が保たなければなりません。これは実在するトレードオフで、そして自分がやがて信用しなくなるとわかっている帳簿の仕組みよりは、はるかに小さいものです。残しておきたい教訓は、調整が難しい検査に出会ったら、監視するのではなく隙間そのものを閉じられないかを問うことです。

これはデータベースを持たないのと同じ勘でもあります。ずれとは、あと1コミットで保存してしまうところだった導出値でした。

2つの軸、1つではなく

記事ごとにstatus: draft | publishedが1つだけでは、3言語の記事の状態を表現できません。状態が互いに独立しているからです。英語は公開済み、日本語は翻訳済みで未公開、中国語は未執筆といった具合です。そこで項目は2つあり、それぞれ別の問いに答えます。

  • status: draft / ready / published。ロケールごとで、公開を制御します
  • translation: source / translated。記事がどの言語で書かれたかを記録します

translationにはかつて3つ目の値machineがあり、reviewedと区別して、未校閲の出力を読者に警告するために使っていました。これは削除しました。検証する手段がないからです。 手で直したファイルと機械出力は区別できないので、この項目はどの工程も更新しない自己申告になっており、19本の記事を通じて一度も設定されませんでした。

これは、記事が公開前に3言語すべてで存在しなければならないか、という問いにも決着をつけます。その必要はありません。翻訳がないことは恒久的に妥当な状態です。 そのためastro.config.mjsはi18nのfallbackを一切設定していません。存在しないロケールは別の言語を黙って出す代わりに404になり、hreflangは実際にビルドされたものだけを告知します。書いていない言語へ検索エンジンを誘導するのは、書いていないと認めるより悪いことです。

まとめ

ここに特別なものは何もありませんし、ローダーの設定を除けばAstro固有でもありません。これらの判断が共有しているのは1つの前提です。データベースがない以上、データベースが担ったはずの制約はすべてどこか別の場所で作る必要があり、いちばん安いのは型システムとビルドであるということです。

ファイルから多言語ブログを始めるなら、効いた順序は次のとおりです。

  1. コンテンツ構成は言語ではなく画像から決める。
  2. 必要になる前に.mdxへ変換する。コストはゼロで、図が翻訳される唯一の道です。
  3. タグに言語非依存の同一性を与え、無効なタグはビルドエラーにする。
  4. フォントは言語ごとに選ぶ。CJKのスタックを統合しない。
  5. ずれを監視する仕組みを作るより、公開時に翻訳を生成する。

この記事をシェア