🪄

astroで投稿一覧ページを動的に生成する

2023/11/21に公開

何が作れるの?

astroを使って、markdownブログの投稿一覧ページを動的に作ってみました。

動画

markdownは自動でリスト化され、自動で適切な記事数でページ分割されます。

単にリスト表示するだけではなく、記事一覧ページ自体のページ送りができます。
チュートリアルではページ送りが実装されていないため、これを補完した形になります。
https://docs.astro.build/ja/tutorial/5-astro-api/1/

TR,DR

ファイル名([pageName].astro)がポイントです。細かいCSSの読み込みやレイアウト部分のコードは割愛します。

[pageName].astro
---
const { pageindex, pageTitle } = Astro.props;

// 1ページに表示する個別投稿へのリンクの数
export const linkscount = 3

// 型定義
export interface staticPath {
    params: {
        pageName: string,
    },
    props: {
        pageindex: number,
        pageTitle: string, 
    }
}

export const allPosts = await Astro.glob('./posts/*.md');
// ポストを新しい順に並び替え
const sortedPosts = allPosts.sort((a, b) => {
  const aDate = new Date(a.frontmatter.pubDate);
  const bDate = new Date(b.frontmatter.pubDate);
  return b.frontmatter.pubDate.getTime() - a.frontmatter.pubDate.getTime();
});

export const postName_prefix = "postlist_"
// markdownの総数を1ページに表示するリンクの数で割って
// 必要な生成ページ数を定義
export async function getStaticPaths() {
    let staticPaths: staticPath[] = [];
    for (let i = 0; i < pagescount; i++){
        staticPaths.push({
            props: {
                pageindex: i,
                pageTitle: "Post [" + String(i + 1).padStart(2, '0') + "]",
            },
            params: {
                pageName: postName_prefix + String(i + 1).padStart(2, '0'),
            }    
        })
    }
    return staticPaths;
}

const currentindex = pageindex * linkscount;
const lastindex = currentindex + linkscount;

export const pagescount = Math.ceil(allPosts.length / linkscount);

const show_newer = pageindex !== 0
const url_newer = "/" + postName_prefix + String(pageindex).padStart(2, '0');

const url = "/" + postName_prefix + String(pageindex + 1).padStart(2, '0');

const show_paster = pageindex !== pagescount - 1
const url_paster = "/" + postName_prefix + String(pageindex + 2).padStart(2, '0');

const pageindex_formatted = String(pageindex + 1).padStart(2, '0');
const pagescount_formatted =  String(pagescount).padStart(2, '0');

import BaseLayout from '../layouts/BlogLayout.astro';

import '../styles/pagelist.css'
---
<BaseLayout pageTitle={pageTitle}>

    <div class="postlist">
        <ul>
            {sortedPosts.slice(currentindex, lastindex).map((post) => 
                <li>
                    <div class="postlist-item">
                        {post.url !== undefined && 
                            <div class="postlist-image">
                                <img src={card_dir + post.url.split('/').at(-1) + ".png"} />
                            </div>}
                            <div class="postlist-text">
                                <div class="postlist-link">
                                    <a href={post.url}>{post.frontmatter.title}</a>
                                </div>
                                <div class="postlist-date">
                                    Public-date: {post.frontmatter.pubDate.slice(0,10)}
                                </div>
                                <ul class="tags">
                                    {post.frontmatter.tags.map((item:string) => <li>{item}</li>)}
                                </ul>
                            </div>
                    </div>
                </li>
            )}
        </ul>
    </div>
    <div class="pagelist-buttons">
        <div class="left">{show_newer && <a href={url_newer}>次へ</a>}</div>
        <div class="center">{pageindex_formatted}/{pagescount_formatted}</div>
        <div class="right">{show_paster && <a href={url_paster}>前へ</a>}</div>
    </div>
</BaseLayout>

前提知識

こういうのがあるんだよーぐらいで大丈夫ですが、どういうことができるのかを把握した方が理解が進むと思います。
チュートリアルを貼っておくので、理解のおともにどうぞ。

動的ルーティングについて

空気を読んで複数のページを作成する機能です。
チュートリアルはこちら。
https://docs.astro.build/ja/core-concepts/routing/

astroは静的サイトジェネレート、Static Site Generation(SSG)が可能で、作成する定義さえ書いてあげれば良い感じに複数のページを作る機能があります。

Astro.globについて

指定したファイルを全て変数に格納してしまう関数です。
チュートリアルはこちら。
https://docs.astro.build/ja/guides/imports/#astroglob

今回読みこまんとする「記事」の実体はmdファイルなので、これを取得したり、表示するためにこの関数を利用します。

型定義について

Typescriptでは型定義が重要なので、getStaticPaths()が出力する型を定義してあげます。型というのは、プログラミングでは一般的な用語で、変数を定義するために使われる概念です。

今回開発したコードでは、getStaticPaths()の返り値にstaticPathという型を採用しています。

paramsが生成されるページのファイル名に使われる値、propsが生成されるページの内部で使われる値です。似ていますが、挙動は全く別物です。

export interface staticPath {
    params: {
        pageName: string,
    },
    props: {
        pageindex: number,
        pageTitle: string, 
    }
}

記事一覧ページを動的生成する

中核部分を解説します。この辺のコードです。

export const linkscount = 3

// 型定義
export interface staticPath {
    params: {
        pageName: string,
    },
    props: {
        pageindex: number,
        pageTitle: string, 
    }
}

export const allPosts = await Astro.glob('./posts/*.md');
...
export const postName_prefix = "postlist_"
// markdownの総数を1ページに表示するリンクの数で割って
// 必要な生成ページ数を定義
export async function getStaticPaths() {
    let staticPaths: staticPath[] = [];
    for (let i = 0; i < pagescount; i++){
        staticPaths.push({
            props: {
                pageindex: i,
                pageTitle: "Post [" + String(i + 1).padStart(2, '0') + "]",
            },
            params: {
                pageName: postName_prefix + String(i + 1).padStart(2, '0'),
            }    
        })
    }
    return staticPaths;
}

...
export const pagescount = Math.ceil(allPosts.length / linkscount);

記事を記事一覧にプロットする

記事一覧ページ生成について考えます。

article01~05の5本の記事があると仮定します。これを1ページあたり3本のリンクが掲載されている記事一覧としてまとめるには、記事一覧のページは2ページ必要です。

上記から、総記事数 ÷ 1ページに載せたいリンク数 を切り上げた値が必要な記事一覧のページ数になることがわかります。

これをコード化すると以下のようになります。

export const linkscount = 3
...
export const allPosts = await Astro.glob('./posts/*.md');
...

export const pagescount = Math.ceil(allPosts.length / linkscount);

allPosts変数はAstro.glob関数により、./posts/ディレクトリ配下の.mdファイルを取得してきます。allPosts.lengthallPostsが取得してきた記事の本数が格納されており、これを1ページに載せたいリンク数を定義した変数linkscountで除算します。

ここまでがallPosts.length / linkscountで、最後にMath.ceil()関数により小数点以下切り上げた結果が、pagescount変数に代入されています。

export const postName_prefix = "postlist_"
// markdownの総数を1ページに表示するリンクの数で割って
// 必要な生成ページ数を定義
export async function getStaticPaths() {
    let staticPaths: staticPath[] = [];
    for (let i = 0; i < pagescount; i++){
        staticPaths.push({
            props: {
                pageindex: i,
                pageTitle: "Post [" + String(i + 1).padStart(2, '0') + "]",
            },
            params: {
                pageName: postName_prefix + String(i + 1).padStart(2, '0'),
            }    
        })
    }
    return staticPaths;
}

このpagescountの数だけ繰り返し処理for (let i = 0; i < pagescount; i++)し、getStaticPaths()の戻り値 staticPathsを生成しています。

pageNameはこのプログラムのファイル名[pageName].astroが指すように、ジェネレートされるページのファイル名になります。

記事が4~6本の場合は、pagescount = 2なので、forループは2回(i=0, i=1のパターン)行われるので、staticPathspageName変数は2つ分代入されることになります。

padStart(2, '0')関数はゼロ埋めをしてくれる関数です。i=0であれば00に、i=1であれば01を出力してくれます。

以上により、getStaticPaths()関数はpostlist_01postlist_02ページを生成します。

記事一覧をレンダリングする

このあたりのコードの話になります。ほぼHTMLであることがわかると思います。

---
...
export const allPosts = await Astro.glob('./posts/*.md');
// ポストを新しい順に並び替え
const sortedPosts = allPosts.sort((a, b) => {
  const aDate = new Date(a.frontmatter.pubDate);
  const bDate = new Date(b.frontmatter.pubDate);
  return b.frontmatter.pubDate.getTime() - a.frontmatter.pubDate.getTime();
});
...
const currentindex = pageindex * linkscount;
const lastindex = currentindex + linkscount;
---
...
    <div class="postlist">
        <ul>
            {sortedPosts.slice(currentindex, lastindex).map((post) => 
                <li>
                    <div class="postlist-item">
                        {post.url !== undefined && 
                            <div class="postlist-image">
                                <img src={card_dir + post.url.split('/').at(-1) + ".png"} />
                            </div>}
                            <div class="postlist-text">
                                <div class="postlist-link">
                                    <a href={post.url}>{post.frontmatter.title}</a>
                                </div>
                                <div class="postlist-date">
                                    Public-date: {post.frontmatter.pubDate.slice(0,10)}
                                </div>
                                <ul class="tags">
                                    {post.frontmatter.tags.map((item:string) => <li>{item}</li>)}
                                </ul>
                            </div>
                    </div>
                </li>
            )}
        </ul>
    </div>

注目すべきはこのあたりです。

            {sortedPosts.slice(currentindex, lastindex).map((post) => 

これは、本ファイルのコードスペース部分で定義したsortedPostsを本文に使うことを示しています。sortedPosts変数はallPosts変数に格納された記事一覧を日付順に並び変えた変数です。

さらに、slice()関数により部分的に変数の中身を切り出しています。

生成されたページによって切り出す場所を変えるために、currentindexlastindexを定義しており、生成されるページによって適切な値が代入されるように計算式を記載している、というわけです。

// 生成されたページの番号 × 1ページのリンク数 
//  = 生成ページの一番最初に載せるべき記事の番号
const currentindex = pageindex * linkscount;
// 生成ページの最初の記事の番号 + 記事一覧ページのリンクの数 
//  = 生成ページに載せる記事の境界値
const lastindex = currentindex + linkscount;

上記の値を使い、切り出されたsortedPostsの変数をpost変数にマッピングし、その後のコードをレンダリング(HTMLを生成すること)しています。

<li>
    <div class="postlist-item">
        {post.url !== undefined && 
            <div class="postlist-image">
                <img src={card_dir + post.url.split('/').at(-1) + ".png"} />
            </div>}
            <div class="postlist-text">
                <div class="postlist-link">
                    <a href={post.url}>{post.frontmatter.title}</a>
                </div>
                <div class="postlist-date">
                    Public-date: {post.frontmatter.pubDate.slice(0,10)}
                </div>
                <ul class="tags">
                    {post.frontmatter.tags.map((item:string) => <li>{item}</li>)}
                </ul>
            </div>
    </div>
</li>

細かい所に細工はいろいろありますが、{post.url}とすることで記事のurlを得ることができ、これを<ul><li>タグで一覧化している、という流れです。

記事一覧にページ送りを実装する

最後に、これら生成したページを自由に行き来できるよう、リンクを追加してあげます。

---
const currentindex = pageindex * linkscount;
const lastindex = currentindex + linkscount;

export const pagescount = Math.ceil(allPosts.length / linkscount);

const show_newer = pageindex !== 0
const url_newer = "/" + postName_prefix + String(pageindex).padStart(2, '0');

const url = "/" + postName_prefix + String(pageindex + 1).padStart(2, '0');

const show_paster = pageindex !== pagescount - 1
const url_paster = "/" + postName_prefix + String(pageindex + 2).padStart(2, '0');

const pageindex_formatted = String(pageindex + 1).padStart(2, '0');
const pagescount_formatted =  String(pagescount).padStart(2, '0');
---
...
    <div class="pagelist-buttons">
        <div class="left">{show_newer && <a href={url_newer}>次へ</a>}</div>
        <div class="center">{pageindex_formatted}/{pagescount_formatted}</div>
        <div class="right">{show_paster && <a href={url_paster}>前へ</a>}</div>
    </div>

pageindex0ではない場合(最初のページではない場合)に次へを表示しない、pageindexpagescount - 1ではない場合(最後のページではない場合)に前へを表示しないというのをプログラムしているだけです。

このような書き方をすれば、ページ数が以下のように増えた場合も対応できます。

表示についてはHTMLで描かれていて、{show_newer && と書かれている部分がプログラムです。

show_newerpageindex !== 0という式が成立する場合はTrue成立しない場合はFalseが代入される変数で、{show_newer && <a href={url_newer}>次へ</a>}show_newerTrueの場合のみレンダリングされる、という意味です。

const show_newer = pageindex !== 0
const show_paster = pageindex !== pagescount - 1
---
    <div class="pagelist-buttons">
        <div class="left">{show_newer && <a href={url_newer}>次へ</a>}</div>
        <div class="center">{pageindex_formatted}/{pagescount_formatted}</div> 
        <div class="right">{show_paster && <a href={url_paster}>前へ</a>}</div>
    </div>

また、次へ前へのリンクの生成ですが、そもそもページ名をgetStaticPaths()の返り値としてpageName: postName_prefix + String(i + 1).padStart(2, '0')と定義しています。

postName_prefix = "postlist_"なので、生成されるページはpostlist_01``postlist_02...と続くので、開いているページ番号から-1すれば前のページを、+1すれば後のページのリンクを予測することが可能です。

以下の部分が該当箇所になります。

// 次へページのページ名を予測する
const url_newer = "/" + postName_prefix + String(pageindex).padStart(2, '0');

// 現在のページのURLは以下のように定義されている
const url = "/" + postName_prefix + String(pageindex + 1).padStart(2, '0');

// 前へページのページ名を予測する
const url_paster = "/" + postName_prefix + String(pageindex + 2).padStart(2, '0');

備考

記事一覧チックなページを動的に生成する方法の一例をまとめてみました。別解として、コンテンツコレクションの機能を使う方法でも記事一覧を生成することができます。
https://docs.astro.build/ja/guides/content-collections/

コンテンツコレクションはAstro.globで取得してくるよりずっと複雑ですが、より本格的な実装を行うことができます。
Astroの学習に役立てば幸いです。

Discussion