rakuishi.com

静的サイトジェネレーターを Hugo から Next.js に乗り換えた

職場のプロジェクトに Next.js を導入する事例が増え、流石に勉強するかと Next.js のチュートリアルを触りました。以前、チュートリアルを遠目に眺めた感じでは Next.js は Server Side Rendering フレームワークという印象でしたが、v9.3.0 以降 Static Site Generation(以降、SSG)機能に注力しているように見えます。

特に、チュートリアルが Markdown 管理されているブログを作る内容となっており、「お前のブログを作り直せ」というメッセージをひしひしと感じ、今回 Hugo から Next.js に乗り換えました。

この記事では Hugo から Next.js に乗り換えた際、実装で躓いた部分などを書いていきます。また、ブログのソースコードは rakuishi/rakuishi.com に置いています。

静的 HTML 出力する

まずは Next.js のチュートリアル を参考にプロジェクトを作った後、これを静的 HTML 出力するには、まず package.jsonscripts に以下の記述を追加します:

{
  "scripts": {
    "export": "npm run build && npm run export"
  }
}

そして npm run export を叩けば out フォルダに HTML ファイルが出力されます。後は、out フォルダに移動し php -S localhost:8000 でローカルサーバーを立ち上げれば動作確認まで行えます。

自分の場合、ホスティングに Firebase を利用しているため、firebase.jsonpublicout に変更し:

{
  "hosting": {
    "public": "out"
  }
}

firebase deploy を叩けばデプロイされました。

Trailing Slash(フォルダ + index.html)を出力する

デフォルト設定では pages/about.js/about と見做され /about/ と書いても /about にリダイレクトされます。また 静的 HTML 出力の際、/about.html に置かれます。

Hugo では /about/index.html として出力し /about/ を指していたため、この動作に揃えることにし、Next.js に Trailing Slash の設定を追加します。

next.config.js ファイルを作成し、trailingSlash を追加します:

module.exports = {
  trailingSlash: true,
};

設定後、/about/ にアクセスできるようになり、静的 HTML 出力の際、/about/index.html に置かれます。

サイトマップとフィード対応

Next.js 公式のサンプル集には sitemap.xml は next-sitemap プラグインを利用した方法、next.config.js に記述し isServer 時にスクリプトを走らせる方法があります。

サイトマップもフィードも同じように扱おうとすると、後者が都合良いと思ったのですが、本体のブログ記事一覧を出力する処理は import を利用し、新しく追加する sitemap.xml 出力用のスクリプトは require を利用しています。

すると似たようなコードを同プロジェクト上で 2 回書く必要があり、エレガントさ不足を感じました。このあたりは自分の JavaScript の知識不足もあると思います。

最終的には、野性味あふれるエレガントな方法を採用しました。全ブログ記事を出力する /pages/archives/index.jsgetStaticProps 時に、サイトマップとフィードを出力するようにしています。

import { generateFeed, generateSitemap } from "utils/generator";

/* 省略 */

export async function getStaticProps() {
  const archives = getArchives();

  generateFeed(archives);
  generateSitemap(archives);

  return {
    props: {
      archives,
    },
  };
}

getStaticPropsDate オブジェクトを扱えない

YAML front matter を *.md に記述してブログ記事を管理しているのですが、この YAML 部分を処理する gray-matter を経由した getStaticProps が次のエラーを吐くことがありました。

Error: Error serializing `.post.date` returned from `getStaticProps` in "/archives/[slug]".
Reason: `object` ("[object Date]") cannot be serialized as JSON. Please only return JSON serializable data types.

これは *.md の先頭部分の日付がダブルクオーテーションなしだと gray-matterDate オブジェクトと見做すのが間接的な原因でした。

---
date: 2020-11-07T09:49:48+09:00
---

特に Date オブジェクトに拘っていないため、ダブルクオーテーションで囲ってあげました。日付の処理は UI 表示時に date-fnsparseISO を利用して行っています。

---
date: "2020-11-07T09:49:48+09:00"
---

カスタムフォントの定義場所

Next.js のチュートリアルにあるようにグローバルな CSS は、/pages/_app.jsimport "styles/global.css" として読み込む方法があります。

これを利用して /styles/global.css にカスタムフォントの @font-face を定義していたのですが、この実装方法だと静的 HTML 出力した際、初回表示時はカスタムフォントがあたるのに、画面遷移後にカスタムフォントがあたらない現象が発生しました。

少し調べたものの原因が分からないため、最終的には /pages/_app.js 内にカスタムフォントの定義を埋め込みました。これだと綺麗に動作しました。

import "styles/global.css";

export default function App({ Component, pageProps }) {
  return (
    <>
      <Component {...pageProps} />
      <style jsx global>{`
        @font-face {
          font-family: "NotoSans";
          font-style: normal;
          font-weight: 400;
          font-display: optional;
          src: local("NotoSansCJKjp-Regular.otf"), local(
              "NotoSansJP-Regular.otf"
            ), url("/assets/fonts/NotoSans-Regular.woff2") format("woff2");
        }
        @font-face {
          font-family: "NotoSans";
          font-style: normal;
          font-weight: 700;
          font-display: optional;
          src: local("NotoSansCJKjp-Bold.otf"), local("NotoSansJP-Bold.otf"),
            url("/assets/fonts/NotoSans-Bold.woff2") format("woff2");
        }
      `}</style>
    </>
  );
}

ダークモードを実装する方法

Hugo を利用していた時は、LocalStorage + 属性セレクタによるダークモードを実装していました。LocalStorage にダークモードの設定情報を保存し、それを <head> 内の <script> で処理することにより、最初の画面レンダリングから違和感なくダークモードが反映された状態になります。

Next.js に則った実装をするならば、pages/_app.js 時にダークモード判定させることになるのでしょうが、評価タイミングが遅く、一瞬白い画面が表示された後にダークモードが反映されます。恐らく Next.js の動作に必要な JavaScript ファイル群は静的 HTML が画面描画された後に、評価されるからだと思います。

仕方なく従来の挙動に倣い pages/_document.js<Head> 内に、以下のようにダークモードの設定情報を読み込む JavaScript を埋め込みました。

<script
  dangerouslySetInnerHTML={{
    __html: `
(function () {
  let scheme = "light";

  if (localStorage.getItem("prefers-color-scheme")) {
    scheme = localStorage.getItem("prefers-color-scheme");
  } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
    scheme = "dark";
  }

  document.documentElement.setAttribute("data-prefers-color-scheme", scheme);
}());`,
  }}
/>

ダークモードを切り替えるコードは LocalStorage を利用するため React.useCallback 内に書きます:

<button
  onClick={React.useCallback(() => {
    const isDarkmode = localStorage.getItem("prefers-color-scheme") === "dark";
    const scheme = isDarkmode ? "light" : "dark";
    localStorage.setItem("prefers-color-scheme", scheme);
    document.documentElement.setAttribute("data-prefers-color-scheme", scheme);
  })}
/>

Markdown のスタイルの反映方法

Markdown を remark を使って HTML に変換した後、それにスタイルを反映するには、CSS Modules という機能を利用します。まず、dangerouslySetInnerHTML プロパティを利用して HTML を出力し、className={styles.markdown} という命名規則でそれらにスタイルを反映します。

import styles from "styles/markdown.module.css";

export default function PostLayout({ post }) {
  return (
    <>
      <div
        className={styles.markdown}
        dangerouslySetInnerHTML={{ __html: post.contentHtml }}
      />
    </>
  );
}

styles/markdown.module.css には次のようにスタイルが書かれています。

.markdown h2 {
  position: relative;
  width: fit-content;
  margin: 72px 0 24px;
  padding-bottom: 12px;
  font-size: 24px;
  color: var(--primary-text-color);
}

Hugo Shortcode の移行

このブログでは画像を loading="lazy" 付きで表示したり、Amazon のアフィリエイトリンクを付与する処理を Hugo Shortcode を利用して展開していました。

この Shortcode は便利なので使い続けることにし、Markdown を remark を使って HTML に変換する前に置換する処理をだらだらと書きました:

content = content.replace(
  /{{<img alt="(.*?)" src="(.+?)" width="(\d*?)" height="(\d*?)">}}/g,
  '<p><img alt="$1" src="$2" width="$3" height="$4" loading="lazy"></p>'
);