本サイトのブログ記事が増えてきたので、タグのインデックスページを追加しました。

ブログのインデックスページの下部にあるタグ一覧や、各ブログ記事のタイトル上のタグからアクセスできます。

タグ一覧のスクリーンショット
ブログのインデックスページにタグ一覧を追加

本サイトのフレームワークには Astro を使用していますが、タグページの実装にあたり、Astro のチュートリアルページと、エビスコムさんによる電子書籍『Astro v2とTinaCMSでシンプルに作るブログサイト』を参考にしました。

まず、Astro のチュートリアルページですが、こちらではすべての記事からタグを取得し、ユニークな配列に変換して、その配列をもとにタグページを生成する方法が紹介されています。

しかし、この方法ではタグ名がそのまま URL に使われるため、スペースや日本語を使用したときに、パーセントエンコーディング(percent-encoding)がおこなわれます1

このように、自由にスラッグを指定できないことに加えて、タグページごとに見出しのテキストラベルといった独自のデータを持たせたかったので、この方法は見送りました。

一方の『Astro v2とTinaCMSでシンプルに作るブログサイト』では、「2.7 カテゴリーインデックスページの作成」のセクションで、外部ファイルでタグを管理する方法が紹介されています。この方法であれば自由にデータを持たせられるので、今回はこちらをベースに実装することにしました。

『Astro v2とTinaCMSでシンプルに作るブログサイト』の表紙
『Astro v2とTinaCMSでシンプルに作るブログサイト』では、実際にブログサイトをつくりながら、Astro と TinaCMS の基本機能が学べる

テンプレートファイル構成

見出し「テンプレートファイル構成」

タグページを実装するにあたり、ブログページを以下のファイル構成にしました。

ブログページのファイル構成
src/pages/blog/
├── [...page].astro                 // ブログインデックス
├── [...slug].astro                 // ブログ記事
└── tags/[tagSlug]/[...page].astro  // タグページ

今回追加したのは、末尾の src/pages/blog/tags/[tagSlug]/[...page].astro です。

[tagSlug] の部分は、getStaticPaths() 関数でスラッグを指定することで、動的なルーティングを実現できます。

[...page].astro はページネーションを有効にするときのファイル名です。getStaticPaths() 関数内で paginate() 関数を返すことで指定した件数ごとのページ分割が実現できます。

以下は、タグページのテンプレートの、getStaticPaths() に関する部分を抜き出したコードです(一部簡略化)。

src/pages/blog/tags/[tag]/[...page].astro
---
import { getCollection } from 'astro:content';

// タグ一覧のデータを取得
import tagsList from '@data/blog/tags.yml';

export async function getStaticPaths({ paginate }) {
  // Content Collections からブログ記事を取得(下書きを除外)
  const posts = await getCollection('blog', ({ data }) => !data.draft);

  // ブログ記事を公開日の新しい順にソート
  const sortedPosts = posts.sort((a, b) => Date.parse(b.data.pubDate) - Date.parse(a.data.pubDate));

  // タグがいずれかの記事に含まれているかをチェック(空の表示を回避)
  const filteredTags = tagsList.filter((tag) => {
    return posts.some((post) => post.data.tags.includes(tag.tagName));
  });

  return filteredTags.map((tag) => {
    const { tagName, tagSlug, titleJa, titleEn } = tag;

    // タグを含むブログ記事に絞り込み
    const taggedPosts = sortedPosts.filter((post) => post.data.tags.includes(tagName));

    // `paginate()` 関数を返す
    return paginate(taggedPosts, {
      params: { tagSlug },
      props: { tagName, titleJa, titleEn },
      pageSize: 10
    });
  });
}

const { page, tagName, titleJa, titleEn } = Astro.props;
---

import tags from '@data/blog/tags.yml' では、タグ一覧のデータを格納しているファイルを取得していますが、そのデータファイルの中身を見ていきます。

タグ一覧を管理するデータファイルは以下のような構成です。データ管理を容易にするために、YAML 形式を採用することにしました2

src/data/blog/tags.yml
-
  tagName: アクセシビリティ
  tagSlug: accessibility
  titleJa: アクセシビリティ
  titleEn: Accessibility

-
  tagName: Astro
  tagSlug: astro
  titleJa: アストロ
  titleEn: Astro

-
  tagName: CSS
  tagSlug: css
  titleJa: シーエスエス
  titleEn: CSS

同一の値や似た値が並びますが、それぞれ以下の役割を持っています。

keyvalue
tagNameページタイトルやテキストラベル
tagSlugスラッグ
titleJaタグページの見出し(日本語表記)
titleEnタグページの見出し(英語表記)

指定した値は、タグページの URL や各テキストラベル、メタデータなどに反映されます。

タグページのスクリーンショット。`tagSlug`、`titleJa`、`titleEn`、`tagName` で指定した値がどの要素に適用されるかを示している
タグページには tags.yml で指定した値が適用される

titleEn は CSS の text-transform: uppercase で大文字表記に変換しています。

Zod を使用したバリデーション

見出し「Zod を使用したバリデーション」

タグ一覧のデータ(tags.yml)は手動で管理しているため、ブログ記事を作成するときに、存在しないタグを指定したり、タグ名の表記揺れやスペルミスが発生する可能性があります。

Astro 2.0 から導入された Content Collections では、Zod を使用して Markdown / MDX の Front Matter の型をバリデーションすることができるようになりました。

この機能を活用して、Front Matter に記述したタグが tags.yml に含まれていないときに、エラーメッセージを表示するように設定しました。

src/content/config.ts
import { z, defineCollection } from 'astro:content';

// タグ名だけの配列を作成
import tagsList from '@data/blog/tags.yml';
const tagNames = tagsList.map((tag: { tagName: string }) => tag.tagName);

// ブログのデータ型を定義
const blogCollection = defineCollection({
  schema: z.object({
    // ...
    // タグは文字列の配列で `tags.yml` 内のタグ名に含まれる必要がある
    tags: z.string().array().refine((arr) => arr.every((tag) => tagNames.includes(tag)), {
      message: '`@data/blog/tags.yml` に存在しないタグが含まれています。',
    }),
    // ...
  })
});

export const collections = {
  'blog': blogCollection,
}

refine メソッドを使用して、ブログ記事で指定したタグ名がタグ一覧の名前(tagName)に含まれているかをチェックし、第二引数の message でエラーメッセージを指定しています。

例えば、ブログ記事で存在しないタグを指定すると以下のようなエラー画面が表示されます。

Astro エラー画面のスクリーンショット。`message` で指定したエラーメッセージが強調されている
タグ名が一致しない場合、message で指定したエラーメッセージが表示される

ここまでのプロセスを経て、タグページを実装することができました。しかし、タグを手動で管理しているため、タグ一覧のデータとブログ記事に指定されているタグの双方向でチェックする必要があり、ロジックが複雑になってしまいました。

テンプレート内で以下のチェックをして、要素の出し分けやエラー表示を判定しています。

  • ブログ記事に指定したタグが、タグ一覧のデータに存在するかのチェック
  • タグに含まれるブログ記事が存在するかのチェック

前者は、すでに説明した Zod を使用したバリデーションで、後者は、タグページでブログ記事が空(0 件)の場合に表示しない処理や、タグ一覧にブログ記事が空のタグを表示させない処理です。

これらの処理は、ブログ記事やタグの数が増えるごとに、デプロイ先でのビルド時間に影響を及ぼすかもしれませんが、ひとまず様子を見つつ、よりよい方法が見つかれば調整していきたいです。

脚注

  1. パーセントエンコーディングとは、URL に使えない文字を変換する処理で、例えば半角スペースであれば %20 に符号化されます。

  2. Astro で YAML 形式のデータをインポートするには、プラグインをインストールする必要があります。(Installing a Vite or Rollup plugin | Astroドキュメント