初歩からの無職

Gatsby + Contentfulで年月別アーカイブを実装した

  • GatsbyJS
  • Contentful
  • JavaScript

ブログでよくある年月別アーカイブをGatsbyで実装しました。以下のよくある構成です。

  • /archives/: 年月別アーカイブ一覧
  • /archives/YYYY/: 年別記事一覧
  • /archives/YYYY/MM/: 月別記事一覧
  • それぞれの階層でのページネーション

年月別記事一覧の作成

/archives/YYYY//archives/YYYY/MMで該当する年、月ごとの記事一覧ページをgatsby-node.jsで生成する必要があります。これは以下を参考にしました。

参考: Gatsby.jsで年ごと、月ごとで記事一覧を表示したい - Qiita

Setオブジェクトでまとめることで重複なしの年月一覧リストが作れるところがミソっぽいです。

参考: Set - JavaScript | MDN

階層ごとのページネーション

記事ごとの前後リンクは既に実装しているので、アーカイブでも2020年2月のページから2020年1月や2020年3月に移動できたり、2018年から2017年や2019年に時系列的な前後関係に移動できると便利そうです。このブログは空白の期間だらけなので、単純に翌月前月ではなく記事が存在する最も近い月を情報として持っておく必要があるので、gatsby-node.jsでページを生成するときにcontextで渡してあげる必要があります。

yearsyearMonthsというSetオブジェクトに記事が存在する年、年月が入っています。Setは挿入順に反復することができるので、通常の配列と同じように次の要素も引っ張ってこれそうな雰囲気があったのですが、あくまでも順序を持たないため、ループ処理中に次の値を引っ張ってくる方法がどうもないっぽいのです。というわけで、Array.from()メソッドで配列を作る必要があります。配列であれば各要素にindexでアクセスできるので、ループ中に次の要素にアクセスできるので、これをcontextで渡すようにします。

gatsby-node.js
// 年別ページ
const yearList = Array.from(years)
let prevYear;
yearList.forEach((year, index)  => {
  const nextYear = yearList[index + 1];
  createPage({
    path: `/archives/${year}/`,
    component: archiveTemplate,
    context: {
      thisYear: year,
      prevPage: prevYear,
      nextPage: nextYear,
      periodStartDate: `${year}-01-01T00:00:00.000Z`,
      periodEndDate: `${year}-12-31T23:59:59.999Z`
    }
  })
  prevYear = year
})

月別も同じようにします。

gatsby-node.js
// 月別ページ
const yearMonthList = Array.from(yearMonths);
let prevYearMonth;
yearMonthList.forEach((yearMonth, index) => {
  const [year, month] = yearMonth.split('/')
  const nextYearMonth = yearMonthList[index + 1];
  const startDate = `${year}-${month}-01T00:00:00.000Z`;
  const newStartDate = new Date(startDate);
  // 月末日を取得
  const endDate = new Date(new Date(newStartDate.setMonth(newStartDate.getMonth() + 1)).getTime() - 1).toISOString();
  createPage({
    path: `/archives/${year}/${month}/`,
    component: archiveTemplate,
    context: {
      thisYear: year,
      thisMonth: month,
      prevPage: prevYearMonth,
      nextPage: nextYearMonth,
      periodStartDate: startDate,
      periodEndDate: endDate
    }
  })
  prevYearMonth = yearMonth;
})

ページ生成に使うテンプレートは年ごと月ごとでも同一のものを使いました。prevPagenextPageに入るのがYYYYYYYY/MMか、thisMonthがnullでないかで年別か年月別かで表示を切り替えます。

src/templates/archive-template.js
import React from "react";
import { graphql} from "gatsby";
import get from "lodash/get";
import Layout from "../components/layout";
import SEO from "../components/seo";
import ArticlePreview from "../components/article-preview";
import ArchivePagenation from "../components/archive-pagenation"

class ArchiveTemplate extends React.Component {
  render() {
    const posts = get(this, "props.data.allContentfulBlogPost.edges");
    const totalCount = get(this, "props.data.allContentfulBlogPost.totalCount");
    const {thisYear, thisMonth, prevPage, nextPage} = this.props.pageContext;

    return (
      <Layout location={this.props.location}>
        <SEO
          title={thisMonth ? `${thisYear}${thisMonth}月の記事一覧` : `${thisYear}年の記事一覧`}
          desc={thisMonth ? `${thisYear}${thisMonth}月の記事一覧` : `${thisYear}年の記事一覧`}
          noindex
        />
        <div style={{ background: "#fff", textAlign: 'center' }}>
          <div className="wrapper">
            <h2 className="section-headline">{thisMonth ? `${thisYear}${thisMonth}月の記事一覧` : `${thisYear}年の記事一覧`} ({totalCount}件)</h2>
            <ul className="article-list">
              {posts.map(({ node }) => {
                return (
                  <li key={node.slug}>
                    <ArticlePreview article={node} />
                  </li>
                );
              })}
            </ul>
            <ArchivePagenation prevPage={prevPage}  nextPage={nextPage} thisMonth={thisMonth} thisYear={thisYear} />
          </div>
        </div>
      </Layout>
    );
  }
}

export default ArchiveTemplate;

export const archivePageQuery = graphql`
  query BlogPostsByDate($periodStartDate: Date!, $periodEndDate: Date!) {
    allContentfulBlogPost(filter: {publishDate: {gte: $periodStartDate, lte: $periodEndDate}}) {
        edges {
          node {
            title
            slug
            publishDate(formatString: "YYYY年MM月DD日")
            tags {
                title
                slug
            }
            heroImage {
                fluid(maxWidth: 350, maxHeight: 196, resizingBehavior: THUMB) {
                    ...GatsbyContentfulFluid_withWebp
                }
            }
            description {
                childMarkdownRemark {
                  excerpt
                }
            }
          }
        }
        totalCount
      }
}
`

アーカイブ用のページネーションコンポーネントに現在の年月情報と前後のページ情報を渡しています。上のディレクトリへのリンクもあること以外は基本的に記事のページネーションと同じなので、そのうち統合するかも。

src/components/archive-pagenation.js
import React from 'react'
import styled from '@emotion/styled'
import { Link } from 'gatsby'

const Wrapper = styled.div`
    margin: 2em 0 0 0;
    padding: 0 1.5em 2em;
`

const Box = styled.div`
  display: flex;
  justify-content: space-between;
  margin: 0 auto;
  width: 100%;
  a {
    padding: 1em;
    border-radius: 2px;
    text-decoration: none;
    transition: 0.2s;
    &:hover {
        color: #fff;
        background: #333;
      }
  }
`

const ArchivePagenation = ({ prevPage, nextPage, thisMonth, thisYear }) => {
  return (
    <Wrapper>
        <Box>
            {nextPage && (
                <Link to={`/archives/${nextPage}/`} style={{marginRight: 'auto', order: 1}}>&#8592; {nextPage}</Link>
            )}
            {thisMonth ?
              <Link to={`/archives/${thisYear}/`} style={{order: 2}}>{thisYear}年一覧へ</Link> :
              <Link to="/archives/" style={{order: 2}}>アーカイブ一覧へ</Link>}
            {prevPage && (
                <Link to={`/archives/${prevPage}/`} style={{marginLeft: 'auto', order: 3}}>{prevPage} &#8594;</Link>
            )}
        </Box>
    </Wrapper>
  )
}

export default ArchivePagenation

こんな感じになりました。

archive-screenshot1

archivesページ

年の一つ上の/archives/では各年月のアーカイブページへのリンクがまとめられてると便利そうです。気づきやハマりポイントは以下の通りです。

  • tagsページのときと同様、ContentfulをsourceにするときにPageQueryで総数をストレートに取得するのが難しそうなので、全記事取得後計算した
  • JSX中でmapでリストを返してる部分、Setも挿入順でforEachでいけるんじゃね?と思いましたが、forEachはundefinedを返すのでうまくいかないので、やはりリストに変換後にmapするのが正しいっぽい(参考: reactjs - React foreach in JSX - Stack Overflow)
  • 反復でHTML返すときに要素が多いとkey振るの面倒

見栄えは上から下に向けて時系列順で見えるようにしました。

src/pages/archives.js
import React from "react";
import { graphql, Link } from "gatsby";
import get from "lodash/get";
import styled from '@emotion/styled'
import Layout from "../components/layout";
import SEO from "../components/seo";

const Box = styled.div`
  display: flex;
  flex-wrap: wrap;
  justify-content: space-evenly;
  border: 1px solid #ccc;
  margin: 1em;
  vertical-align: middle;
  align-items: center;
  h3 {
    display: block;
    flex: 2;
    margin: 0;
    padding: 0;
    a {
      text-decoration: none;
    }
  }
  ul {
    margin: 0;
    padding: 0;
    flex: 2;
    li {
      display: block;
      background-color: #eee;
      border-radius: 10px;
      margin: .25em;
      a {
        display: block;
        padding: .5em;
        border-radius: 10px;
        width: 100%;
        height: 100%;
        &:hover {
          background-color: #ccc;
        }
      }
    }
  }
`

class ArchivesIndex extends React.Component {
  render() {
    const posts = get(this, "props.data.allContentfulBlogPost.edges");
    const totalCounts = {};
    const years = new Set();
    const yearMonths = new Set();

    posts.forEach(post => {
      const { year, yearMonth } = post.node;

      // 年別、月別のtotalCountを追加
      if (!totalCounts.hasOwnProperty(year)) totalCounts[year] = 0;
      if (!totalCounts.hasOwnProperty(yearMonth)) totalCounts[yearMonth] = 0;
      totalCounts[year] += 1;
      totalCounts[yearMonth] += 1;

      // years, yearMonths Set作成
      years.add(year);
      yearMonths.add(yearMonth);

    })

    const yearList = Array.from(years);
    const yearMonthList = Array.from(yearMonths);

    return (
      <Layout location={this.props.location}>
        <SEO title="Archives" desc="年月別アーカイブページ" noindex />
        <div style={{ background: "#fff", textAlign: "center" }}>
          <div className="wrapper">
            <h2 className="section-headline">Archives</h2>
            {yearList.map(year => {
              return (
                <Box key={`box${year}`}>
                  <h3 key={`header${year}`}><Link to={`/archives/${year}/`}>{year}年 ({totalCounts[year]})</Link></h3>
                  <ul key={`list${year}`}>
                    {yearMonthList.map(yearMonth => {
                      if (year === yearMonth.split('/')[0]) {
                        return (
                          <li key={yearMonth}>
                            <Link to={`/archives/${yearMonth}/`}>{yearMonth} ({totalCounts[yearMonth]})</Link>
                          </li>
                        )
                      }
                    })}
                  </ul>
                </Box>
              )
            })}
            <Link to={`../`}>HOME</Link>
          </div>
        </div>
      </Layout>
    );
  }
}

export default ArchivesIndex;

export const pageQuery = graphql`
  query ArchiveIndexQuery {
    allContentfulBlogPost(sort: {fields: publishDate, order: DESC}) {
      edges {
        node {
          year: publishDate(formatString: "YYYY")
          yearMonth: publishDate(formatString: "YYYY/MM")
        }
      }
    }
  }
`;

結果

archive-screenshot2

とりあえず年月別アーカイブが実装できました。aboutページ、関連記事、著者ページ、emotionへの完全に置き換え、Material UI導入など色々やりたいことも頭にありますが、適当にやっていこうと思います。