はきくま!制作日記:microCMS 編①
- 投稿日
小説やイラスト、日記のデータを microCMS で管理し、Astro のビルド時に取ってきてページにするまでの記録。
その1:API の設計
無料プランは API が5つまで。結局、5つとも使い切った。
| API | 主なフィールド |
|---|---|
novels(作品) |
title, format, genre(複数選択), isR18, sort, visual(画像), description(テキストエリア) |
chapters(各話) |
title, body(テキストエリア), sort, novel(参照), coupling(参照・任意), isR18, materials(繰り返し) |
couplings(カップリング) |
name, short(タブ用の略称), sort, iconSeme, iconUke |
illusts(イラスト) |
image(画像), caption(テキスト・任意), tags(セレクト・複数・任意), novel(参照・任意) |
diaries(日記) |
title, body(テキストエリア), tags(セレクト・複数・任意), byAi |
小説まわりの3つ(novels / chapters / couplings)で考えたこと:
- 作品の一覧用と、本文用で API を分けた。各話から
novelの参照で作品に紐づける - カップリングは、一話ごとに
couplingの参照で持たせる。長編の第一部と第二部でカップリングが変わる作品も、短編集も同じ形で扱える。参照を任意にしておけば、カップリングのない話も作れる - 略称やアイコンを持たせたいので、カップリングはセレクトフィールドではなく独立した API にした
- タグはセレクトフィールド(複数選択)。テキストを改行で区切る方式だと表記ゆれが防げない
- スラッグ(コンテンツID)はケバブケースで手入力。各話は自動のIDのまま
- 並び順は
sortの数値フィールドで自分で決める。10、20、30と間を空けて振ると、後から途中に差し込める
特別枠(設定資料・小噺)
設定資料も目次のタブに並び、作品とカップリングに紐づくので、別 API にせず chapters に同居させた。カップリングの sort が 10000 以上なら特別枠、と決めて、判定は関数一つにまとめた。
// src/lib/isExtra.ts
export function isExtra(sort?: number) {
return (sort ?? 0) >= 10000;
}
設定資料は「繰り返し+カスタムフィールド」
リッチエディタの中にカスタムフィールドは埋め込めない。代わりに、繰り返しフィールド materials に二種類のカスタムフィールドを登録して、好きな順番で積み重ねる。
note:リッチエディタ一つ(文章や画像)character:name, reading, image, description(人物紹介)
表示側は fieldId で出し分けるだけ。
{materials.map((block) =>
block.fieldId === "note" ? <RichText html={block.body ?? ""} />
: block.fieldId === "character" ? <CharacterCard character={block} />
: null
)}
その2:データ取得を一か所にまとめる
最初は、トップ・作品トップ・各話ページがそれぞれ getList を書いていた。問題は二つ。
getListはlimit未指定だと10件、指定しても最大100件- ページごとに書いているので、件数の指定も並び順もばらつく
microcms-js-sdk の getAllContents で全件を取り、Promise をモジュールに保持して、ビルド中の呼び出しを1回にまとめた。
// src/lib/getNovelData.ts
let cache: Promise<NovelData[]> | null = null;
export function getNovelData() {
cache ??= (async () => {
const [novels, chapters] = await Promise.all([
client.getAllContents<Novel>({ endpoint: "novels", queries: { orders: "sort" } }),
client.getAllContents<Chapter>({ endpoint: "chapters", queries: { orders: "sort" } }),
]);
return novels.map((novel) => ({
novel,
chapters: chapters.filter((ch) => ch.novel?.id === novel.id),
}));
})();
return cache;
}
作品トップは「作品ごと」なので map、各話ページは「作品ごと × 話ごと」なので flatMap の中に map。
export async function getStaticPaths() {
const data = await getNovelData();
return data.flatMap(({ novel, chapters }) =>
chapters.map((chapter) => ({
params: { id: novel.id, chapterId: chapter.id },
props: { novel, chapters, chapter },
})),
);
}
ヘッダーの作品一覧も getNovelData() から取り出すだけにしたので、取得の仕組みを変えても使う側は触らずに済む。
最終更新日と投稿履歴
- 作品の
updatedAtは作品情報を編集した日。話を追加しても変わらないので、「最終更新」は各話の日付から計算する - 投稿履歴は
updatedAtではなくpublishedAtで並べる。誤字を直しただけで履歴の先頭に来るのを防げる
その3:画像はビルド時に取り込む
無料プランは、データ転送量が月20GBを超えると API が止まる。HTML は静的に生成しているので閲覧で API は叩かれないが、画像の URL をそのまま <img> に書くと、画像だけは閲覧のたびに microCMS から配信される。
astro:assets の Image / getImage を使い、ビルド時に画像をダウンロード・最適化して dist に含め、Cloudflare から配信する。
// astro.config.mjs
export default defineConfig({
image: { domains: ["images.microcms-assets.io"] },
});
リッチエディタの HTML に埋め込まれた画像は、<img> を探して getImage の結果に書き換える。
const urls = [...new Set([...html.matchAll(/<img[^>]+src="([^"]+)"/g)].map((m) => m[1]))];
for (const url of urls) {
if (!url.startsWith("https://images.microcms-assets.io/")) continue;
const img = await getImage({ src: url, inferSize: true });
html = html.replaceAll(
`src="${url}"`,
`src="${img.src}" width="${img.attributes.width}" height="${img.attributes.height}" loading="lazy"`,
);
}
ドット絵のような画像は、WebP の非可逆圧縮でにじむので PNG のままにする。
この記事の学び
- 取得処理・判定・表示を一か所ずつにまとめておくと、中身を入れ替えても使う側を触らずに済む
- 静的生成でも、画像の配信元までは静的にならない。転送量の上限があるなら画像もビルド時に取り込む