Counts the Clouds

Next.jsでブログを構築する (3)

Next.jsでブログを構築する〜ページネーション・テスト・サイトマップ・フィード
2021.11.18

Next.js

florian-klauer--K6JMRMj4x4-unsplash.jpg

ページネーション

件数が多い場合のページネーションを追加。

ページ数をカウントするAPIと、ページを指定して記事を取得するAPIを追加。記事を取得するAPIは順番がおかしいのであとでなおす。

// lib/api.js
export function getPostsTotalPage() {
  const allTitles = getAllPosts(["title"]);
  return Math.ceil(allTitles.length / 20);
}

export function getPostsByPage(page = 1, per = 20, fields = []) {
  const slugs = getPostSlugs();
  const posts = slugs
    .map((slug) => getPostBySlug(slug, fields))
    .slice(per * (page - 1), per * page)
    // sort posts by date in descending order
    .sort((post1, post2) => (post1.date > post2.date ? -1 : 1));
  return posts;
}

トップページを最初の20件だけ表示するように変更。

// index.js
// ...
export default function Home({ posts, allTags, allYears, allMonths, totalPage }) {
  return (
    <Layout tags={allTags} years={allYears} months={allMonths}>
      <Head>
        <title>110chang.com</title>
      </Head>
      {posts.length > 0 && posts.map((post) => <div key={post.date}>{/* ... */}</div>)}
    </Layout>
  );
}

export async function getStaticProps() {
  const posts = getPostsByPage(1, 20, [
    // ...
  ]);

  return {
    props: {
      posts,
      // ...
      totalPage: getPostsTotalPage(),
    },
  };
}

2ページ目以降を表示するため、/pages/[page].jsを追加。内容はindex.jsとほぼ同じ。

パスの定義が必要。ページ数の分だけカウントを追加する。

// /pages/pages/[page].js
export async function getStaticPaths() {
  const totalPage = getPostsTotalPage();
  return {
    paths: new Array(totalPage).fill(1).map((e, i) => {
      return {
        params: {
          page: `${i + 1}`,
        },
      };
    }),
    fallback: false,
  };
}

ページネーションの順番

月が1桁だと順番がおかしくなるので、まず、dayjsを導入して日付をISO 8601の拡張形式にする。

// lib/api.js
export function getPostByFileName(fileName, fields = []) {
  // ...
  if (items.date) {
    items.date = dayjs(items.date).format("YYYY-MM-DDTHH:mm:ss");
  }
  // ...
}

ソートしてからページを取り出すようにした。これは日付を比較しているのではなく文字列として比較している。

export function getPostsByPage(page = 1, per = 20, fields = []) {
  const slugs = getPostSlugs();
  const posts = slugs
    .map((slug) => getPostBySlug(slug, fields))
    // sort posts by date in descending order
    .sort((post1, post2) => (post1.date > post2.date ? -1 : 1))
    .slice(per * (page - 1), per * page);
  return posts;
}

年別アーカイブのページネーション

ここではじめて、ネストされたダイナミックルーティングが登場。

パスは/[year]/pages/[page].js。paramsが2つあり、すべてのパターンを網羅する必要がある。

存在するすべての年数と、各年数で記事が何ページあるかをAPIから取得する必要がある。

export async function getStaticPaths() {
  return {
    paths: getAllYears()
      .map((year) => {
        const totalPage = getPostsTotalPageByYear(year.id);
        return new Array(totalPage).fill(1).map((e, i) => {
          return {
            params: {
              page: `${i + 1}`,
              year: year.id,
            },
          };
        });
      })
      .flat(),
    fallback: false,
  };
}

テスト

あとから追加する場合、マニュアルセットアップ。

% npm install --save-dev jest babel-jest @testing-library/react @testing-library/jest-dom identity-obj-proxy react-test-renderer

jest.config.jsを追加。next.config.jsでエイリアス~を設定しているので、こちらにも設定。

// jest.config.js
module.exports = {
  collectCoverageFrom: ["**/*.{js,jsx,ts,tsx}", "!**/*.d.ts", "!**/node_modules/**"],
  moduleNameMapper: {
    /* Handle CSS imports (with CSS modules)
    https://jestjs.io/docs/webpack#mocking-css-modules */
    "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy",

    // Handle CSS imports (without CSS modules)
    "^.+\\.(css|sass|scss)$": "<rootDir>/__mocks__/styleMock.js",

    /* Handle image imports
    https://jestjs.io/docs/webpack#handling-static-assets */
    "^.+\\.(jpg|jpeg|png|gif|webp|avif|svg)$": "<rootDir>/__mocks__/fileMock.js",

    /* resolve alias */
    "^~/(.*)$": "<rootDir>/$1",
  },
  testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/.next/"],
  testEnvironment: "jsdom",
  transform: {
    /* Use babel-jest to transpile tests with the next/babel preset
    https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object */
    "^.+\\.(js|jsx|ts|tsx)$": ["babel-jest", { presets: ["next/babel"] }],
  },
  transformIgnorePatterns: ["/node_modules/", "^.+\\.module\\.(css|sass|scss)$"],
};

fileMock.jsとstyleMock.jsを追加。

// /__mocks__/fileMock.js
module.exports = "test-file-stub";
// /__mocks__/styleMock.js
module.exports = {};

package.jsonにコマンドを追加

{
  "scripts": {
    "test": "jest",
  }
}
% yarn test
% yarn test -- --watch

ChunkLoadError: Loading chunk node_modules_next_dist_client_dev_noop_js failed

キャッシュの影響?

Module not found: Can’t resolve ‘fs’

サーバー側で実行する処理がComponent側にも混ざってたのが原因だった。

ワークアラウンドを追加。

// next.config.js
module.exports = {
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.resolve.fallback.fs = false;
    }
    // ...
  },
};

あとで削除してみたら問題なかった。これもキャッシュの影響だったのかもしれない。

syntax highlighting

syntax highlightingを導入したい。Gatsbyでも使ったremark-prismを導入。

% yarn add remark-prism

単純にremark-prismをremarkのメソッドチェーンに入れてみる。

import { remark } from "remark";
import html from "remark-html";
import prism from "remark-prism";

export default async function markdownToHtml(markdown) {
  const result = await remark().use(prism).use(html, { sanitize: false }).process(markdown);
  return result.toString();
}

もともとはRougeというGemのThankfulEyesというテーマを使っていた。

prismをインストール。

% yarn add prism

スタイルを追加。

import "~/styles/globals.css";
import "../node_modules/prismjs/themes/prism-twilight.css";

function MyApp({ Component, pageProps }) {
  return <Component {...pageProps} />;
}

export default MyApp;

ビルドすると30秒以上かかった。remark-prismは遅いらしい。デプロイサービスとかでタイムアウトしないか気になる。

サイトマップ

サイトマップはちょうどいいパッケージがある。

% yarn add next-sitemap

設定。もともとに合わせてlocだけにしている。

// next-sitemap.js
module.exports = {
  siteUrl: process.env.SITE_URL || "https://110chang.com",
  transform: async (config, path) => {
    return {
      loc: path,
    };
  },
};

npmスクリプトに追加。

// package.json
{
  // ...
  "scripts": {
    // ...
    "build:sitemap": "next-sitemap"
  },
  // ...
}

実行すると、public/sitemap.xmlが生成される。これをデプロイスクリプトに組み込めばOK。

フィード

フィードについては、特筆すべきパッケージは見つからなかった。

いろいろ調べてみたが、feedを使ってgetStaticPropsで生成する方法が多いみたい。

% yarn add feed

記事を参考にスクリプトをかく。

import fs from "fs";
import dayjs from "dayjs";
import { Feed } from "feed";
import { queryPosts } from "~/lib/api";
import markdownToDigest from "./markdownToDigest";

async function generateFeed() {
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://110chang.com/";

  const author = {
    name: "Yuji Ito",
    email: "...",
    link: baseUrl,
  };

  const feed = new Feed({
    title: "110chang.com",
    description: "description",
    id: baseUrl,
    link: baseUrl,
    language: "ja",
    updated: dayjs().toDate(),
    feedLinks: {
      rss2: `${baseUrl}/rss/feed.xml`,
      json: `${baseUrl}/rss/feed.json`,
      atom: `${baseUrl}/rss/atom.xml`,
    },
    author: author,
  });

  const posts = queryPosts({ fields: ["title", "date", "slug", "tags", "content", "categories"] });

  await Promise.all(
    posts.map((post) => {
      return new Promise(async (resolve) => {
        const content = await markdownToDigest(post.content || "");
        const url = `${baseUrl}/posts/${post.category}/${post.slug}`;
        feed.addItem({
          title: post.title,
          description: "This is my personal feed!",
          id: url,
          link: url,
          content,
          date: dayjs(post.date).toDate(),
        });
        resolve();
      });
    }),
  );

  fs.writeFileSync("./public/feed.xml", feed.rss2());
  fs.writeFileSync("./public/atom.xml", feed.atom1());
  fs.writeFileSync("./public/feed.json", feed.json1());
}

export default generateFeed;

index.jsのgetStaticPropsで書き出す。

// pages/index.js
// ...
export async function getStaticProps() {
  await generateFeed();
  // ...
}

しかし、書き出すのはビルドのときだけにしたい。いろいろ調べたが、いい方法がみつからず、条件分岐でビルドタイムだけ書き出すようにした。

// pages/index.js
// ...
export async function getStaticProps() {
  if (process.env.NODE_ENV === "production") {
    await generateFeed();
  }
  // ...
}