Astroでレストパラメーターを使った動的ルーティングで日記を表示する

日記とはつまり以下のようなファイル構造を持つコンテンツのことで

- 2023
  - 01
    - 01.md
    - 02.md
    - ...
- 2022
  - ...

AstroではMarkdownファイルを単にsrc/pages以下に配置すれば次のようなURLでアクセスできる。
これはファイルベースのルーティング(静的ルーティング)による。

  • http://localhost:3000/2023/01/01
  • http://localhost:3000/2023/01/02

このままだと単純にパースされたMarkdownの内容が表示されるだけだが、以下のようにメタデータでlayoutを指定してやれば、きちんとデザインを適用できる。

---
layout: ../../../layouts/Layout.astro
---

ただ今回の趣旨としては、すべてのファイルのメタデータでlayoutを指定するのはダルいのでそれを回避したいという話で。
メタデータでlayoutを指定しない。したくない。

と言っても新しい日付のファイルを作るたびにコピペするだけなので、別にやればいいじゃんと言われればそうなのだが、実は今回は他のツールからの移行という事情があって、もともとのMarkdownにはlayoutの記述はない。

スクリプト組んですべてのファイルにlayoutを追加するアプローチも難しくはないのだが、せっかくなので日々の手間が少しでも減る方向でということで、layout指定なしで行ける方法を取ることにした。

そこで動的ルーティングである。

レストパラメーターを使った動的ルーティング

公式ドキュメントのルーティングにあるように、src/pages以下にブラケットで囲んだ動的パラメーターを含むファイルを作成することで、動的ルーティングを行うことができる。

個々のファイルやディレクトリを動的パラメーターにしてsrc/pages/[year]/[month]/[date].astroのようなディレクトリとファイルを作るやり方もできるのだが、レストパラメーターを使うと柔軟性なディレクトリ構造を持ったパスを簡単に定義できるので、今回はそれを使い、src/pages/[...diary_date].astroというファイルを作った。

そのastroファイル内でgetStaticPathsを定義して、ルーティングするすべてのパスを返す。つまり日記のすべてのパスを返す。

なおこれに先立ち日記のファイルはsrc/pagesからsrc/contentに移動している。
src/pages以下に置いておくと静的ルーティングの方が優先されてしまうので動的ルーティングで同じパスを定義しても意味がないからだ。

src/pages/[…diary_date].astro

---
import Layout from '../layouts/Layout.astro';

export async function getStaticPaths() {
  const allPosts = await Astro.glob('../content/*/*/*.md')
  const posts = allPosts.filter((post) => post.file.match(/\d{4}\/\d{2}\/\d{2}.md/))

  return posts.map((post) => {
    const path = post.file.replace(".md", "").replace("index", "").split("/").slice(-3).join("/")
    return {
      params: { diary_date: path },
      props: { post },
    };
  });
}

const { diary_date } = Astro.params
const { Content } = Astro.props.post
---
<Layout title={diary_date}>
  <div class="diary">
    <div>{diary_date}</div>
    <div><Content /></div>
  </div>
</Layout>

以下少し解説。

レイアウトの指定

Astroページコンポーネントのコンポーネントスクリプトでレイアウトをimport。

import Layout from '../layouts/Layout.astro';

これによりすべてのMarkdownファイルでlayoutを指定する必要はなくなる。

動的ルーティングの対象となるファイルの絞り込み

const allPosts = await Astro.glob('../content/*/*/*.md')
const posts = allPosts.filter((post) => post.file.match(/\d{4}\/\d{2}\/\d{2}.md/))

AstroのクエリーはAstro.globで全部取得してからfilterで絞り込むというやり方が標準的なようだ。
globの引数には文字列リテラルしか指定できないので、ここで変数を使って絞るということは残念ながらできない。

記事が増えてくるとパフォーマンスが心配になって来るが、4000件くらい記事があっても数秒でビルド(npm run build)でき、開発サーバー(npm run dev)で見る際は初回の表示以外は瞬時にレンダリングされるので個人の日記程度であれば問題はないであろう。

動的ルーティングのパス

ここではdiary_dateが動的パラメーターで、パスとして2023/01/01のような値を想定し、それをMarkdownファイルパスから取得している。

ファイルパスはAstro.globで取ってきたファイルのプロパティから得る。

return posts.map((post) => {
  const path = post.file.replace(".md", "").split("/").slice(-3).join("/")
  return {
    params: { diary_date: path },
    props: { post },
  };
});

ファイル名とソース内のパラメーターの名前(diary_date)は合わせる必要がある。
変更するときは両方変更しないといけないので注意。

年別アーカイブと月別アーカイブ

以上で目的は達成した。やったね。
毎日layout書かなくていいのはちりも積もって大きい。

同じようにして年別アーカイブや月別アーカイブも作れる。

例えば現状だと以下のようなURLにアクセスするとNot Foundになる。

  • http://localhost:3000/2023/
  • http://localhost:3000/2023/01/

これらのURLで年や月ごとにアーカイブを表示するには、index.mdを追加してsrc/pages/[...diary_month].astrosrc/pages/[...diary_year].astroを作ってやればよい。

- 2023
  - index.md
  - 01
    - index.md
    - 01.md
    - 02.md
    - ...
- 2022
  - ...

2022-02-22追記

この記事の例で日記のMarkdownファイルをsrc/contentに置いているが、Astro2.0では動的ルーティングでコンテンツコレクションを扱う特別なディレクトリとなった。なので現在はこの記事のようにsrc/contentを使うべきではない。

お行儀よくするにはsrc/content/diariesに置いて日記用のコレクションスキーマを定義するか、src/diariesのようなコンテンツコレクションとは関係ない場所に置く。

ちなみにコンテンツコレクションを使うと何がいいかと言うと、コレクションスキーマを定義できるので、必須のフロントマッターがないファイルがあったらビルド時にエラーにしたりできるとかそんなんです。
クエリーするコードを書くにもAstro.globを使うよりモデルとして把握しやすくなるかんじですかね。Astroでチーム開発するならコンテンツコレクション使う流れに乗っておいた方がよさげ。

参考: コンテンツコレクション
参考: Astro 2.0 + MDX + Recharts で Markdown ページにインタラクティブなチャートを描画する

2022-04-06追記

動的ルーティングは対象ページが大量にあるとかなり遅いと分かった。
つまり日記のように毎日ページ数が増加していくコンテンツとは相性が悪い。

4000ページ程度で20分とかそれ以上かかってしまうので毎日の更新を考えるとあきらめざるを得なかった。
400ページくらいだったら2分程度で終わっていたのだが…

なおファイルベースの静的ルーティングなら4000ページあっても20秒くらいでビルドできる。
ページ数が多い場合はファイルベースのルーティングにして大人しくすべてのファイルのlayoutを書けと言うことだ。