連載「Astro × microCMS でブログを作る」第5回

Astro × microCMS でブログに連載機能を作る(第N回の自動採番・目次・前後リンク)

この回でやること

連載「Astro × microCMS でブログを作る」の第5回です。複数回に分けて書く記事のために、ブログに「連載」の機能を作ります。ちょうどこの記事が表示されている形のものです。

  • microCMS に series(連載)API を作り、記事から「連載」と「連載の回」を指定できるようにする
  • 記事詳細のタイトルの上に「連載「〇〇」第N回」のラベルを出す
  • 記事の末尾に、連載の目次と前後の回へのリンクを出す
  • /blog/series/<連載ID>/ に連載の目次ページを作る

前回(第4回:OGP・サイトマップ・画像最適化)までで、記事一覧・記事詳細・コードのハイライト・メタタグがそろっています。この回のコードは、ブランチ 05-series の時点のものです。

設計:「第N回」の番号は入力しない

最初に決めたのは、「第N回」の番号を microCMS に直接入力しない、ということです。

記事には seriesOrder(連載の回)という数字のフィールドを用意しますが、これは並べ替えのためだけに使います。表示する「第N回」の番号は、並べた順番から自動で振ります。

  • seriesOrder の小さい順に、第1回、第2回…と並ぶ
  • seriesOrder が空の記事は後ろに回し、公開日の順に並べる

こうしておくと、あとから途中に1本差し込んでも、ほかの記事の番号を振り直す必要がありません。たとえば seriesOrder を 10、20、30 のように間を空けて入れておけば、15 の記事を足すだけで、それ以降の番号は自動でずれます。

microCMS の API 設定

連載そのものの情報を持つ series API を、リスト形式で作ります。

フィールドID

表示名

種類

必須

name

連載名

テキストフィールド

✓

description

説明

テキストエリア

記事の blogs API には、次の2つを追加します。

フィールドID

表示名

種類

備考

series

連載

コンテンツ参照(series)

seriesOrder

連載の回

数字

小さい順に第1回、第2回…。空なら公開日順で末尾に並ぶ

連載の目次ページの URL は /blog/series/<連載ID>/ になります。記事の URL は /blog/<記事ID>/ なので、記事のコンテンツIDに series を使うと紛らわしくなります。2ページ目以降の一覧(/blog/page/<n>/)と同じ理由で、記事のコンテンツIDには page と series を使わないことにしました。

記事を連載に入れる手順

管理画面での操作は次のとおりです。

  1. series API に、連載を1件作る。連載名と説明を入れる
  2. 連載のコンテンツIDを決める。これが目次ページの URL(/blog/series/<連載ID>/)になるので、astro-microcms-blog のような英小文字・数字・ハイフンにしておくと読みやすい
  3. 連載に入れたい記事を開き、「連載」でその連載を選んで、「連載の回」に数字を入れる

記事の側から連載を選ぶ形なので、連載の側で記事の一覧を管理する必要はありません。連載から記事を外したいときは、記事の「連載」を空にするだけです。

型と取得するフィールドを追加する

src/lib/microcms.ts
export type Series = MicroCMSListContent & {
	name: string;
	description?: string;
};

export type Blog = MicroCMSListContent & {
	// …省略(title・content など)
	/** 連載の記事なら、その連載 */
	series?: Series | null;
	/** 連載内の並び順(小さい順に第1回、第2回…) */
	seriesOrder?: number;
};

series はコンテンツ参照なので、API からは参照先の連載のデータ(id・name・description など)が入ったオブジェクトで返ってきます。記事を取得するだけで連載名も分かるので、連載ごとに別のリクエストを送る必要はありません。

記事一覧用に本文を除いて取得するフィールドのリスト(第2回)にも、2つを足しておきます。

src/lib/microcms.ts
const SUMMARY_FIELDS = [
	'id',
	'title',
	'description',
	'eyecatch',
	'category',
	'pr',
	'series',
	'seriesOrder',
	// …省略(createdAt などの日時)
];

記事を連載ごとにまとめる

記事詳細ページでも目次ページでも、「この連載にはどの記事が、どの順番で入っているか」が必要です。そこで、記事の配列を連載ごとにまとめる関数を1つ用意しました。

src/lib/series.ts
import type { BlogSummary, Series } from './microcms';

export type SeriesEntry<T extends BlogSummary = BlogSummary> = {
	series: Series;
	/** 第1回から順に並んだ記事 */
	posts: T[];
};

/** 記事を連載ごとにまとめる(連載に属さない記事は含まない) */
export function groupBySeries<T extends BlogSummary>(posts: T[]): SeriesEntry<T>[] {
	const entries = new Map<string, SeriesEntry<T>>();
	for (const post of posts) {
		if (!post.series) continue;
		const entry = entries.get(post.series.id) ?? { series: post.series, posts: [] };
		entry.posts.push(post);
		entries.set(post.series.id, entry);
	}
	for (const { posts } of entries.values()) posts.sort(compareSeriesOrder);
	return [...entries.values()];
}

// seriesOrder の小さい順。未入力の記事は後ろに回し、公開日順に並べる
function compareSeriesOrder(a: BlogSummary, b: BlogSummary): number {
	return (a.seriesOrder ?? Infinity) - (b.seriesOrder ?? Infinity) || publishedTime(a) - publishedTime(b);
}

function publishedTime(post: BlogSummary): number {
	return Date.parse(post.publishedAt ?? post.createdAt);
}

型引数 T を使っているのは、本文ありの記事(Blog)と本文なしの記事(BlogSummary)のどちらを渡しても、同じ型のまま返すためです。記事詳細ページでは本文ありの記事を、目次ページでは本文なしの記事を渡します。

並べ替えの比較関数の動き

compareSeriesOrder は1行ですが、次のように動きます。

a の seriesOrder

b の seriesOrder

前半の計算

結果

1

2

1 - 2 = -1

a が先

1

空

1 - Infinity = -Infinity

a が先(空は後ろ)

空

空

Infinity - Infinity = NaN

NaN は falsy なので || の右に進み、公開日で比べる

3

3

0

0 も falsy なので、公開日で比べる

seriesOrder が空の記事を Infinity とみなすことで、「空は後ろ」と「空どうしは公開日順」を1行で書けています。

記事詳細に「第N回」のラベルを出す

記事詳細ページの getStaticPaths で全記事を連載ごとにまとめ、各記事のページに、その記事が属する連載のまとまり(seriesEntry)を渡します。

src/pages/blog/[id].astro
export const getStaticPaths = (async () => {
	const blogs = await getAllBlogs();
	const seriesById = new Map(groupBySeries(blogs).map((entry) => [entry.series.id, entry]));
	return blogs.map((blog) => ({
		params: { id: blog.id },
		props: { blog, seriesEntry: blog.series ? seriesById.get(blog.series.id) : undefined },
	}));
}) satisfies GetStaticPaths;

const { blog, seriesEntry } = Astro.props;
const seriesNumber = seriesEntry ? seriesEntry.posts.findIndex((post) => post.id === blog.id) + 1 : 0;

連載ごとのまとめは、ビルド中に1回だけ作ります。各ページはそれを受け取るだけなので、記事が増えても、ページごとに全記事を数え直すことはありません。

番号は「連載の中で何番目か」に1を足したものです。タイトルの上に、連載の目次ページへのリンクとして出します。

src/pages/blog/[id].astro
{
	seriesEntry && (
		<a class="series-label" href={`/blog/series/${seriesEntry.series.id}/`}>
			連載「{seriesEntry.series.name}」第{seriesNumber}回
		</a>
	)
}

記事の末尾に目次と前後のリンクを出す

本文の後ろには、連載の目次と「前の回」「次の回」のリンクを出します。部品にしておきました。

src/components/SeriesNav.astro
---
import type { SeriesEntry } from '../lib/series';

interface Props {
	entry: SeriesEntry;
	currentId: string;
}

const { entry, currentId } = Astro.props;
const { series, posts } = entry;
const index = posts.findIndex((post) => post.id === currentId);
const prev = posts[index - 1];
const next = posts[index + 1];
---

<nav class="series-nav" aria-label="連載の目次">
	<p class="series-nav-heading">
		連載「<a href={`/blog/series/${series.id}/`}>{series.name}</a>」
	</p>
	<ol class="series-parts">
		{
			posts.map((post) => (
				<li>
					{post.id === currentId ? (
						<span aria-current="page">{post.title}</span>
					) : (
						<a href={`/blog/${post.id}/`}>{post.title}</a>
					)}
				</li>
			))
		}
	</ol>
	<div class="series-nav-pager">
		{
			prev ? (
				<a href={`/blog/${prev.id}/`} rel="prev">
					← 前の回
				</a>
			) : (
				<span />
			)
		}
		{
			next ? (
				<a href={`/blog/${next.id}/`} rel="next">
					次の回 →
				</a>
			) : (
				<span />
			)
		}
	</div>
</nav>
  • 目次の中の今読んでいる回はリンクにせず、aria-current="page" を付けて太字にする
  • 前後の回のリンクには rel="prev"・rel="next" を付ける
  • 第1回には「前の回」がないので、代わりに空の <span> を置く。左右に振り分けるレイアウト(justify-content: space-between)で、「次の回 →」が右端に残るようにするため

記事詳細ページでは、本文の後ろに置くだけです。

src/pages/blog/[id].astro
<div class="prose" set:html={content} />

{seriesEntry && <SeriesNav entry={seriesEntry} currentId={blog.id} />}

「第1回」「第2回」は CSS で振る

目次の各行の頭に付く「第1回」「第2回」は、HTML には書かず、CSS のカウンターで表示しています。

src/styles/global.css
/* 「第1回」「第2回」…を自動で振る */
.series-parts {
	margin: 0;
	padding: 0;
	list-style: none;
	counter-reset: part;
}

.series-parts li {
	counter-increment: part;
}

.series-parts li::before {
	content: '第' counter(part) '回';
	margin-right: 0.75em;
	color: var(--color-muted);
	font-size: 0.85em;
	white-space: nowrap;
}

HTML としては普通の番号付きリスト(<ol>)なので、見た目だけ「1.」を「第1回」に置き換えている形です。

連載の目次ページを作る

/blog/series/<連載ID>/ に、連載の説明と全回の一覧を出すページを作ります。一覧には本文が要らないので、本文なしの getBlogSummaries を使います。

src/pages/blog/series/[id].astro
---
import type { GetStaticPaths } from 'astro';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import { formatDate } from '../../../lib/date';
import { getBlogSummaries } from '../../../lib/microcms';
import { groupBySeries } from '../../../lib/series';

export const getStaticPaths = (async () => {
	const posts = await getBlogSummaries();
	return groupBySeries(posts).map((entry) => ({
		params: { id: entry.series.id },
		props: entry,
	}));
}) satisfies GetStaticPaths;

const { series, posts } = Astro.props;
---

<BaseLayout title={`連載「${series.name}」`} description={series.description}>
	<div class="series-page">
		<h1 class="page-title">連載「{series.name}」</h1>
		{series.description && <p class="series-description">{series.description}</p>}
		<ol class="series-parts series-parts-large">
			{
				posts.map((post) => {
					const publishedAt = post.publishedAt ?? post.createdAt;
					return (
						<li>
							<a href={`/blog/${post.id}/`}>{post.title}</a>
							<time datetime={publishedAt}>{formatDate(publishedAt)}</time>
						</li>
					);
				})
			}
		</ol>
	</div>
</BaseLayout>

ページの一覧は、series API ではなく記事のほうから作っています。そのため、記事が1本も公開されていない連載のページは作られません。microCMS に連載だけ先に登録しておいても、中身のない目次ページが公開されることはありません。

連載の説明(description)は、そのまま目次ページの meta description にも使います。

注意点

公開した記事だけで番号が振られる

サイトに出すのは、公開済みの記事だけです。番号は「公開されている記事の中で何番目か」で決まるので、順番を飛ばして公開すると、番号が一時的にずれます。

なお、microCMS の APIキーに「下書きの取得」の権限を付けていると、一度も公開していない下書きも一覧APIで返ってきます。このサイトでも実際にそうなっていたため、あとから publishedAt の無い記事を除外する処理を足しました。APIキーの権限は、ビルドに必要な分だけにしておくのが安全です。

たとえば seriesOrder が 1・2・3 の3本のうち、1 と 3 だけを先に公開すると、3 の記事は「第2回」と表示されます。2 の記事を公開すると「第3回」に変わります。連載は、なるべく第1回から順に公開するのが安全です。

連載名を変えたら再ビルドが必要

このサイトは静的サイトなので、microCMS で連載名や説明を変えても、再ビルドするまでサイトには反映されません。記事(blogs)だけでなく、series API にも再ビルドの Webhook を設定しておく必要があります。設定方法は、連載「Cloudflare Pages で公開する」の第3回で書いています。

まとめ

  • 「第N回」の番号は入力せず、seriesOrder の順に並べて自動で振る。空の記事は後ろに回して公開日順にする
  • 記事を連載ごとにまとめる関数を1つ作り、記事詳細と目次ページの両方で使う
  • まとめはビルド中に1回だけ作り、getStaticPaths の props で各ページに渡す
  • 記事の末尾には、目次と前後の回へのリンクを出す。「第N回」の表示は CSS のカウンターに任せる
  • 番号は公開済みの記事だけで振られるので、連載は順番に公開する

次回は、トップページをサイトの「顔」にして、ブログを /blog/ の1セクションとして整理し、ツール一覧のセクションを足します(第6回)。