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 | 表示名 | 種類 | 必須 |
|---|---|---|---|
| 連載名 | テキストフィールド | ✓ |
| 説明 | テキストエリア |
記事の blogs API には、次の2つを追加します。
フィールドID | 表示名 | 種類 | 備考 |
|---|---|---|---|
| 連載 | コンテンツ参照( | |
| 連載の回 | 数字 | 小さい順に第1回、第2回…。空なら公開日順で末尾に並ぶ |
連載の目次ページの URL は /blog/series/<連載ID>/ になります。記事の URL は /blog/<記事ID>/ なので、記事のコンテンツIDに series を使うと紛らわしくなります。2ページ目以降の一覧(/blog/page/<n>/)と同じ理由で、記事のコンテンツIDには page と series を使わないことにしました。
記事を連載に入れる手順
管理画面での操作は次のとおりです。
seriesAPI に、連載を1件作る。連載名と説明を入れる- 連載のコンテンツIDを決める。これが目次ページの URL(
/blog/series/<連載ID>/)になるので、astro-microcms-blogのような英小文字・数字・ハイフンにしておくと読みやすい - 連載に入れたい記事を開き、「連載」でその連載を選んで、「連載の回」に数字を入れる
記事の側から連載を選ぶ形なので、連載の側で記事の一覧を管理する必要はありません。連載から記事を外したいときは、記事の「連載」を空にするだけです。
型と取得するフィールドを追加する
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つを足しておきます。
const SUMMARY_FIELDS = [
'id',
'title',
'description',
'eyecatch',
'category',
'pr',
'series',
'seriesOrder',
// …省略(createdAt などの日時)
];記事を連載ごとにまとめる
記事詳細ページでも目次ページでも、「この連載にはどの記事が、どの順番で入っているか」が必要です。そこで、記事の配列を連載ごとにまとめる関数を1つ用意しました。
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 |
| a が先 |
1 | 空 |
| a が先(空は後ろ) |
空 | 空 |
|
|
3 | 3 |
|
|
seriesOrder が空の記事を Infinity とみなすことで、「空は後ろ」と「空どうしは公開日順」を1行で書けています。
記事詳細に「第N回」のラベルを出す
記事詳細ページの getStaticPaths で全記事を連載ごとにまとめ、各記事のページに、その記事が属する連載のまとまり(seriesEntry)を渡します。
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を足したものです。タイトルの上に、連載の目次ページへのリンクとして出します。
{
seriesEntry && (
<a class="series-label" href={`/blog/series/${seriesEntry.series.id}/`}>
連載「{seriesEntry.series.name}」第{seriesNumber}回
</a>
)
}記事の末尾に目次と前後のリンクを出す
本文の後ろには、連載の目次と「前の回」「次の回」のリンクを出します。部品にしておきました。
---
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)で、「次の回 →」が右端に残るようにするため
記事詳細ページでは、本文の後ろに置くだけです。
<div class="prose" set:html={content} />
{seriesEntry && <SeriesNav entry={seriesEntry} currentId={blog.id} />}「第1回」「第2回」は CSS で振る
目次の各行の頭に付く「第1回」「第2回」は、HTML には書かず、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 を使います。
---
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回)。