はきくま!制作日記: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 のままにする。


この記事の学び

  • 取得処理・判定・表示を一か所ずつにまとめておくと、中身を入れ替えても使う側を触らずに済む
  • 静的生成でも、画像の配信元までは静的にならない。転送量の上限があるなら画像もビルド時に取り込む