Next.jsでブログを構築する〜ページネーション・テスト・サイトマップ・フィード 2021.11.18
ページネーション
件数が多い場合のページネーションを追加。
ページ数をカウントする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。
フィード
フィードについては、特筆すべきパッケージは見つからなかった。
-
rss
を使ってgetServerSidePropsで生成する方法 -
feed
を使ってgetStaticPropsで生成する方法
いろいろ調べてみたが、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();
}
// ...
}