Counts the Clouds

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

Next.jsでブログを構築する〜環境構築から表示まで
2021.11.18

Next.js

florian-klauer--K6JMRMj4x4-unsplash.jpg

背景

以前のブログをMiddlemanというRuby製のサイトジェネレータで構築しており、単純にbuildディレクトリの成果物を静的サイトとしてNetlifyに移行するつもりだった。

このブログでも便利に使っているGatsbyのブラックボックスの部分が垣間見えるかもと思い、Next.jsでのブログ構築の材料にしてみた。

環境構築

% npx create-next-app@latest
% cd 110chang-com-next
% yarn dev
yarn run v1.22.11
$ next dev
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
info  - Using webpack 5. Reason: Enabled by default https://nextjs.org/docs/messages/webpack5
event - compiled successfully

markdownファイルを読み込めるようにする

ブログと言えば、まずMarkdownファイルを読み込めないと始まらない。

参考に公式のブログスターターを見る。

To create the blog posts we use remark and remark-html to convert the Markdown files into an HTML string, and then send it down as a prop to the page. The metadata of every post is handled by gray-matter and also sent in props to the page.

lib/api.jsでmarkdownを取得している。

// lib/api.js
import fs from 'fs'
import { join } from 'path'
import matter from 'gray-matter'

const postsDirectory = join(process.cwd(), '_posts')

export function getPostSlugs() {
  return fs.readdirSync(postsDirectory)
}

export function getPostBySlug(slug, fields = []) {
  const realSlug = slug.replace(/\.md$/, '')
  const fullPath = join(postsDirectory, `${realSlug}.md`)
  const fileContents = fs.readFileSync(fullPath, 'utf8')
  const { data, content } = matter(fileContents)

  const items = {}

  // Ensure only the minimal needed data is exposed
  fields.forEach((field) => {
    if (field === 'slug') {
      items[field] = realSlug
    }
    if (field === 'content') {
      items[field] = content
    }

    if (typeof data[field] !== 'undefined') {
      items[field] = data[field]
    }
  })

  return items
}

export function getAllPosts(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))
  return posts
}

lib/markdownToHtml.jsでmarkdownをパースしている。

// lib/markdownToHtml.js
import remark from 'remark'
import html from 'remark-html'

export default async function markdownToHtml(markdown) {
  const result = await remark().use(html).process(markdown)
  return result.toString()
}

必要そうなパッケージを追加。

% yarn add remark remark-html gray-matter

postsディレクトリを追加して適当なmarkdownファイルを追加。

移行元のfrontmatterは以下のような形式になっている。とりあえず、title, dateあたりを読み込めるようにする。slugはファイル名から。contentsはfrontmatter以降の部分。

---
title: Title
date: 2021-01-02T03:45:00+09:00
tags: [Tag1, Tag2]
categories: [Category]
aliases:
  - /diary/aliases
---

最低限のトップページのビュー。

// pages/index.js
// ...
export default function Home({ allPosts }) {
  return (
    <div>
      {/* ... */}
      <main className={styles.main}>
        {allPosts.length > 0 && allPosts.map((post) => (
          <div key={post.date}>
            <h1>{post.title}</h1>
            <div dangerouslySetInnerHTML={{ __html: post.content }} />
          </div>
        ))}
      </main>
    </div>
  )
}

export async function getStaticProps() {
  const allPosts = getAllPosts([
    'title',
    'date',
    'slug',
    'content',
  ])

  return {
    props: { allPosts },
  }
}

dateはそのままだとパースできないので、いったん無理やり文字列に変えて通す。あとでday.jsの処理を入れる。

// lib/api.js
export function getPostBySlug(slug, fields = []) {
  // ...

  items.date = items.date.toLocaleString()

  return items
}

記事ページを追加

posts/[slug].jsを追加。

markdownToHtml関数を使うとエラー。

error - lib/markdownToHtml.js (5:29) @ markdownToHtml
TypeError: (0 , remark__WEBPACK_IMPORTED_MODULE_0__.default) is not a function

remarkのインポートの仕方がnamed importに変わっている。remark-htmlのほうはdefault import。

import { remark } from 'remark'
import html from 'remark-html'

画像がレンダリングされない。sanitizeオプションを指定。

// lib/markdownToHtml.js
import { remark } from 'remark'
import html from 'remark-html'

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

デザイン

もとのブログからデザインを持ってくる。SCSSとして追加したかったが、compassというRuby Gemを使っているので今回はビルド済みのCSSを直接持ってくることにした。

compassのサイトはSSL化もされていない。諸行無常。

% cp build/css/com.css ../110chang-com-next/styles

画像も持ってくる。

% cp -r source/img/ ../110chang-com-next/assets

CSS内の画像へのパスを調整。

  .sprite-icons {
-   background: transparent url(../img/sprite-icons.png) no-repeat 0 0;
+   background: transparent url(../assets/sprite-icons.png) no-repeat 0 0;
  }

ルーティング

/[category]/[slug]にしたいが、まずは/posts/[category]/[slug]を実現する。

getStaticPaths

ページが動的ルート(ドキュメント)を持ち、getStaticPropsを使用する場合、ビルド時に HTML をレンダリングするためのパス一覧を定義する必要があります。

  1. ディレクトリ構造をposts/[category]/[slug].jsとする。
  2. [slug].jsのgetStaticPathでparamscategoryslugを返す。

カテゴリーが複数型になっているのはHugoを試したときの名残。

// [slug].js
// ...
export async function getStaticPaths() {
  const posts = getAllPosts(['slug', 'categories'])
  return {
    paths: posts.map((post) => {
      const category = post.categories ? post.categories[0] : 'diary'
      return {
        params: {
          slug: post.slug,
          category,
        },
      }
    }),
    fallback: false,
  }
}

以前のパスからリダイレクトをかけておく。

// next.config.js
// ...
module.exports = {
  // ...
  async redirects() {
    return [
      {
        source: '/:category(diary|knowledge|...)/:path*',
        destination: '/posts/:category/:path*',
        permanent: true,
      },
    ]
  },
}