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

Astro のブログを「トップページ+セクション」構成に作り替える(ツール一覧も追加)

この回でやること

連載「Astro × microCMS でブログを作る」の第6回で、この連載の最終回です。ここまではトップページ(/)がそのまま記事一覧でしたが、ブログ専用ではなく、Webツールなどほかのコンテンツも載せるサイトにする方針にしたので、構成を作り替えます。

  • / をサイトの紹介と各セクションの新着を並べるトップページにする
  • ブログを /blog/ 以下の1セクションとして整理し、部品を src/components/blog/ にまとめる
  • ヘッダーのナビを定数から生成し、表示中のセクションを強調する
  • 記事カードの見出しレベルを、置く場所に合わせて切り替えられるようにする
  • 2つ目のセクションとして、microCMS で管理するツール一覧(/tools/)を作る

前回(第5回:連載機能)までで、ブログとしての機能はそろっています。この回のコードは、ブランチ 07-homepage と、その後のツール一覧のコミットの時点のものです(間のブランチ 06-deploy はデプロイの設定なので、連載「Cloudflare Pages で公開する」で扱います)。

作り替えの前後で、URL の役割は次のように変わりました。

URL

変更前

変更後

/

新着記事(6件)

サイトの紹介、ブログの新着記事(3件)、ツール(3件)

/blog/

記事一覧

ブログのトップ(タイトル・説明文つきの記事一覧)

/tools/

なし

ツール一覧

説明文を「サイト」と「ブログ」に分ける

それまでの SITE_DESCRIPTION は、ブログの説明文でした。サイト全体の説明文を新しく書き、ブログの説明文は BLOG_DESCRIPTION として分けます。

src/consts.ts
export const SITE_NAME = 'cyber-blow';
export const SITE_DESCRIPTION =
	'実際に手を動かして確かめたIT技術の情報と、Webツールを公開しているサイトです。';
export const BLOG_DESCRIPTION =
	'エラー解決・検証・比較・AIツール活用術など、実際に手を動かして確かめたIT技術情報を発信するブログ。';

SITE_DESCRIPTION は、共通レイアウトで description を指定しなかったページ(トップページなど)の meta description に使われます。ブログの一覧ページでは BLOG_DESCRIPTION を渡すようにします。

トップページを作る

トップページは、サイト名と紹介文のあとに、セクションごとのブロックを並べる形にしました。

src/pages/index.astro
---
import ArticleList from '../components/blog/ArticleList.astro';
import { SITE_DESCRIPTION, SITE_NAME } from '../consts';
import BaseLayout from '../layouts/BaseLayout.astro';
import { getBlogSummaries } from '../lib/microcms';

const LATEST_POST_COUNT = 3;
const posts = (await getBlogSummaries()).slice(0, LATEST_POST_COUNT);
---

<BaseLayout>
	<section class="hero">
		<h1 class="hero-title">{SITE_NAME}</h1>
		<p class="hero-lead">{SITE_DESCRIPTION}</p>
	</section>

	<!-- セクション(/tools など)を増やしたら、ここに同じ形で並べる -->
	<section class="home-section">
		<div class="home-section-header">
			<h2>ブログ</h2>
			<a href="/blog/">すべての記事 →</a>
		</div>
		<ArticleList posts={posts} headingLevel={3} />
	</section>
</BaseLayout>

各セクションのブロックは「見出し(h2)」「すべて見るリンク」「新着の一覧」の3点セットです。セクションを増やすときは、この <section> を同じ形で足していきます。headingLevel={3} については後で説明します。

/blog/ をブログのトップにする

/blog/ は、ページタイトルを「記事一覧」から「ブログ」に変え、ブログの説明文を出すようにしました。

src/pages/blog/index.astro
<BaseLayout title="ブログ" description={BLOG_DESCRIPTION}>
	<h1 class="page-title">ブログ</h1>
	<p class="page-lead">{BLOG_DESCRIPTION}</p>
	<ArticleList posts={posts.slice(0, POSTS_PER_PAGE)} />
	<Pagination currentPage={1} totalPages={totalPages} />
</BaseLayout>

2ページ目以降(/blog/page/<n>/)も、タイトルを「ブログ(2ページ目)」のようにそろえ、同じ説明文を渡しています。

部品をセクションごとのフォルダに分ける

ブログでしか使わない部品は、src/components/blog/ に移しました。ほかのセクションを足したときに、どの部品がどこで使われるのかが分かりやすくなります。

src/components/
├── blog/
│   ├── ArticleCard.astro
│   ├── ArticleList.astro
│   ├── Pagination.astro
│   └── SeriesNav.astro
└── tools/          ← 後でツール一覧を作るときに追加
    ├── ToolCard.astro
    └── ToolList.astro

ページのほうも、src/pages/blog/ と src/pages/tools/ のようにセクションごとに分かれています。新しいセクションを足すときに触る場所は、次の3つです。

  1. src/pages/<セクション>/ にページを作る(専用の部品は src/components/<セクション>/ に置く)
  2. src/consts.ts の NAV_ITEMS にリンクを追加する(ヘッダーに表示される)
  3. src/pages/index.astro にセクションのブロックを追加する

ヘッダーのナビを定数から作る

ヘッダーのナビは、それまで「記事一覧」へのリンクを1つ直接書いていました。これを定数の配列から作るようにします。

src/consts.ts
/** ヘッダーのナビゲーション。/tools などのセクションを増やしたらここに追加する */
export const NAV_ITEMS = [{ href: '/blog/', label: 'ブログ' }];
src/layouts/BaseLayout.astro
<nav class="site-nav">
	{
		NAV_ITEMS.map((item) => (
			<a
				href={item.href}
				aria-current={Astro.url.pathname.startsWith(item.href) ? 'true' : undefined}
			>
				{item.label}
			</a>
		))
	}
</nav>

表示中のページの URL が、ナビのリンク先で始まっていれば aria-current を付けます。/blog/ だけでなく、記事詳細(/blog/<id>/)や連載の目次(/blog/series/<id>/)を開いているときも「ブログ」が強調されます。

リンク先そのもののページを開いているとは限らないので、値は「ページの集合の中の現在のページ」を表す page ではなく、汎用の true にしています。見た目は CSS で太字にしました。

src/styles/global.css
.site-nav a[aria-current] {
	font-weight: 700;
}

参考:aria-current - ARIA | MDN

記事カードの見出しレベルを切り替える

記事カードのタイトルは、もともと h2 でした。記事一覧ページでは「h1 ブログ → h2 記事タイトル」で問題ありませんが、トップページでは「h1 サイト名 → h2 ブログ → 記事タイトル」となるので、カードのタイトルは h3 にしたいところです。

そこで、見出しのレベルを Props で受け取れるようにしました。

src/components/blog/ArticleCard.astro
interface Props {
	post: BlogSummary;
	/** 記事タイトルの見出しレベル。ページ内の見出し構造に合わせる */
	headingLevel?: 2 | 3;
}

const { post, headingLevel = 2 } = Astro.props;
const Heading = `h${headingLevel}` as const;
src/components/blog/ArticleCard.astro
<Heading class="card-title">{post.title}</Heading>

Astro では、タグ名の文字列を変数に入れて、その変数をタグとして書けます(動的タグ)。このとき変数名は大文字で始める必要があります。heading のように小文字にすると、Astro は <heading> という名前の HTML タグとして出力してしまいます。

一覧の部品(ArticleList)は受け取った headingLevel をそのままカードに渡すだけなので、トップページでは headingLevel={3}、記事一覧ページでは指定なし(h2)になります。

参考:Template expressions reference | Astro Docs

ツール一覧のセクションを作る

構成を作り替えたので、2つ目のセクションとしてツール一覧を作ります。これまでに作った Webツールはそれぞれサブドメインで動いていて、/tools/ はそれらへのリンク集です。一覧は microCMS で管理します。

microCMS の tools API

フィールドID

表示名

種類

必須

備考

name

ツール名

テキストフィールド

✓

description

説明

テキストエリア

✓

一覧のカードに表示

url

URL

テキストフィールド

✓

ツール本体のURL(サブドメインなど)

thumbnail

サムネイル

画像

一覧のカード画像

relatedBlogs

関連記事

複数コンテンツ参照(blogs)

使い方などの記事。カードの下にリンクが出る

ツールの使い方を書いた記事を relatedBlogs で紐づけておくと、ツールのカードから記事へ案内できます。

取得:参照先のフィールドも絞り込む

src/lib/microcms.ts
export type Tool = MicroCMSListContent & {
	name: string;
	/** 一覧に出す説明 */
	description: string;
	/** ツール本体のURL */
	url: string;
	thumbnail?: MicroCMSImage;
	/** 使い方などの関連記事 */
	relatedBlogs?: Pick<Blog, 'id' | 'title'>[];
};

// 関連記事は本文まで返ってくると重いので、id と title だけに絞る
const TOOL_FIELDS = ['id', 'name', 'description', 'url', 'thumbnail', 'relatedBlogs.id', 'relatedBlogs.title'];

/** 全ツール。orders を指定しないので、microCMS 管理画面での並び順になる */
export function getTools(): Promise<Tool[]> {
	return client.getAllContents<Tool>({
		endpoint: 'tools',
		queries: { fields: TOOL_FIELDS },
	});
}

ポイントは2つです。

  • relatedBlogs は記事を参照するフィールドなので、そのまま取得すると、参照先の記事の本文まで返ってきます。fields に relatedBlogs.id のようにドットでつないで書くと、参照先のフィールドも絞り込めます。
  • 並び順の指定(orders)は付けていません。microCMS は orders を省略すると、管理画面でのコンテンツの表示順で返します。管理画面で並べ替えた順番が、そのまま一覧の順番になります。

参考:一覧取得API | microCMS ドキュメント

ツールのカード:リンクの中にリンクを入れない

記事のカードは、カード全体を1つの <a> で包んでいました。ツールのカードも同じようにしたいのですが、カードの下に関連記事へのリンクを出すので、そのまま包むと <a> の中に <a> が入ってしまいます。HTML では、<a> の中に別の <a> やボタンなどの対話型の要素を入れることはできません。

そこで、カード本体(画像・ツール名・説明)だけを <a> で包み、関連記事のリンクはその外に置きました。

src/components/tools/ToolCard.astro
<article class="card">
	<a href={tool.url}>
		{/* …省略(サムネイル) */}
		<div class="card-body">
			<Heading class="card-title">{tool.name}</Heading>
			<p class="tool-description">{tool.description}</p>
		</div>
	</a>
	{
		relatedBlogs.length > 0 && (
			<div class="tool-related">
				<p class="tool-related-heading">関連記事</p>
				<ul>
					{relatedBlogs.map((blog) => (
						<li>
							<a href={`/blog/${blog.id}/`}>{blog.title}</a>
						</li>
					))}
				</ul>
			</div>
		)
	}
</article>

それまでカードの枠線や影は、カードの中の <a> に付けていました。これを外側の .card に移し、<a> の外にある関連記事も枠の中に収まるようにしています。

src/styles/global.css
.card {
	overflow: hidden;
	border: 1px solid var(--color-border);
	border-radius: var(--radius);
	transition: box-shadow 0.15s;
}

.card:hover {
	box-shadow: 0 4px 16px rgb(0 0 0 / 0.08);
}

.card > a {
	display: block;
	color: inherit;
	text-decoration: none;
}

参考:<a>: アンカー要素 - HTML | MDN

ページとナビへの追加

/tools/ のページは、ブログのトップと同じ形です。

src/pages/tools/index.astro
---
import ToolList from '../../components/tools/ToolList.astro';
import { TOOLS_DESCRIPTION } from '../../consts';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getTools } from '../../lib/microcms';

const tools = await getTools();
---

<BaseLayout title="ツール" description={TOOLS_DESCRIPTION}>
	<h1 class="page-title">ツール</h1>
	<p class="page-lead">{TOOLS_DESCRIPTION}</p>
	<ToolList tools={tools} />
</BaseLayout>

ナビには、配列に1行足すだけです。

src/consts.ts
/** ヘッダーのナビゲーション。セクションを増やしたらここに追加する */
export const NAV_ITEMS = [
	{ href: '/blog/', label: 'ブログ' },
	{ href: '/tools/', label: 'ツール' },
];

トップページには、ツールの先頭3件を出すブロックを足しました。ツールが0件のときは、ブロックごと出しません。

src/pages/index.astro
{
	tools.length > 0 && (
		<section class="home-section">
			<div class="home-section-header">
				<h2>ツール</h2>
				<a href="/tools/">すべてのツール →</a>
			</div>
			<ToolList tools={tools} headingLevel={3} />
		</section>
	)
}

一方、/tools/ のページでは、0件のときに「ツールは準備中です。」と表示します。トップページでは空のセクションを見せず、一覧ページでは何もない理由を伝える、という使い分けです。

寄り道:iCloud の競合コピーがコミットに混ざった

この回の作り替えのコミットに、意図していない .node-version 2 と public/robots 2.txt というファイルが入っていました。

プロジェクトを iCloud Drive で同期しているデスクトップのフォルダに置いていたため、同期の競合で「〜 2」という名前のコピーができていたのが原因です。次のコミットで削除しました。

public/ に置いたファイルは、ビルドでそのまま出力先にコピーされます。気づかずにデプロイしていれば、robots 2.txt も公開サイトに置かれていたことになります。

デスクトップや書類フォルダを iCloud で同期している Mac では、次のどちらかをおすすめします。

  • 開発用のフォルダを、iCloud で同期しない場所に置く
  • コミットの前に git status で、身に覚えのないファイルが増えていないか確認する

まとめ

  • / はサイトの紹介とセクションごとのブロックを並べるトップページにし、ブログは /blog/ の1セクションにした
  • 部品とページをセクションごとのフォルダに分け、セクションを足すときに触る場所を3つに絞った
  • ナビは定数の配列から作り、URL の前方一致で表示中のセクションに aria-current を付ける
  • 見出しレベルは Astro の動的タグで切り替え、ページ内の見出しの階層を崩さない
  • ツール一覧は microCMS で管理し、参照先のフィールドは fields のドット記法で絞る。カード内のリンクは入れ子にしない

連載「Astro × microCMS でブログを作る」はこの回で終わりです。作ったサイトを Cloudflare Pages で公開する手順と、そこでハマったことは、連載「Cloudflare Pages で公開する」の第1回から書いています。