はなゐろぐ

主に技術関係の覚え書きです。

Astro Contents Collectionへ移行する

2024年9月30日

このブログはローカルのMarkdownファイルを取得してビルドしています。はじめにAstroへ移行した時はimport.meta.glob()で取得したのですが、AstroにContents Collectionが実装されたのでそちらへ移行することにしました。

ディレクトリ構造の変更

移行前はsrc/postsにMarkdownファイルを格納していたのですが、Contents Collectionではsrc/contentに格納する決まりなので、まずファイルを移します。移行後のディレクトリ構造は下記の通りです。

年月ディレクトリに格納するとslugがyear/month/entryになるのですが、ここは変更可能です。

src/
  content/
    blog/
      2024/
        09/
          entry.md
      draft.md
    config.ts

また、今回から年月ディレクトリに入っていない、blogディレクトリ直下の下書きファイルを.gitignoreで無視するようにしました。記事が完成したら、公開年月のディレクトリに移動してコミットします。

.gitignore
# draft
src/content/blog/*.md

型の設定

Contents Collectionでは、Zodを使ってFrontmatterをバリデーションできます。これがそのまま型になるわけですね。これを現行の運用に合わせてconfig.tsに指定していきます。

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

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    date: z.date(),
    tags: z.array(z.string()),
    draft: z.boolean().optional(),
  }),
})

export const collections = {
  blog,
}

記事の取得周り

ここはほぼ全て書き換えました。ついでに、並べ替えをオプションにしました。

src/libs/posts.ts
import type { CollectionEntry } from 'astro:content'
import { getCollection } from 'astro:content'

// 記事を取得
export const getPosts = async ({
  orderby,
  order,
}: {
  orderby?: 'date'
  order?: 'ASC' | 'DESC'
} = {}): CollectionEntry<'blog'> => {
  const posts = await getCollection('blog', ({ data }) => {
    return import.meta.env.PROD ? data.draft !== true : true
  })

  if (orderby && orderby === 'date') {
    const sortedPosts = posts.sort(
      (a, b) => Date.parse(b.data.date) - Date.parse(a.data.date)
    )

    if (order === 'ASC') {
      return sortedPosts.reverse()
    }

    return sortedPosts
  }

  return posts
}

getCollection()は第2引数にfilterコールバックを指定できるため、frontmatterなどでフィルタリングできます。今回は本番環境で下書きを除外するようにしました。

コンテンツコレクション
コンテンツコレクションは、Markdownを整理し、フロントマターをスキーマで型チェックするのに役立ちます。
コンテンツコレクション favicon https://docs.astro.build/ja/guides/content-collections/#コレクションクエリのフィルタリング
コンテンツコレクション

詳細ページ

こちらもCollectionを使って書き換えます。各記事のオブジェクトに格納されているrender()を使ってMarkdownからHTMLに置換された本文コンポーネントを受け取れます。また、frontmatterで指定した値はpost.dataに格納されているため、変数を書き換えます。

src/pages/[...slug].astro
---
import type { CollectionEntry } from 'astro:content'

import { getPosts, getBlogParams } from '@/libs/posts'

type Props = {
  post: CollectionEntry<'blog'>
}

export async function getStaticPaths() {
  const posts = await getPosts()

  return posts.map((post) => ({
    params: getBlogParams(post),
    props: {
      post,
    },
  }))
}

const { slug } = Astro.params
const { post } = Astro.props
const { Content } = await post.render()
---

<h1>{post.data.title}</h1>
<Content/>

パーマリンク

slugを/{slug}にするため、記事のファイルパスからパラメータを生成します。逆に、src/pages/[year]/[month][...slug].astroを格納し、frontmatterの日付から年月を取得してpathに設定することで年月パーマリンクにもできます。

Date-based URLs with Astro
Astro does not provide an out-of-the-box configuration for a date-based URL hierarchy, but with a few small tweaks, this can be achieved.
Date-based URLs with Astro favicon https://www.tomspencer.dev/blog/2023/12/05/date-based-urls-with-astro/
src/libs/posts.ts
// URLパラメータを取得
export const getBlogParams = (post: CollectionEntry<'blog'>) => {
  // const pubDate = post.data.date
  // const year = String(pubDate.getFullYear()).padStart(4, '0')
  // const month = String(pubDate.getMonth() + 1).padStart(2, '0')

  const slug = (post.slug.match(/\d{4}\/\d{2}\/(.+)/) || [])[1] || post.slug

  // const path = `${year}/${month}/${slug}`

  return {
    // year,
    // month,
    // path,
    slug,
  }
}