mythfinder

📝

ブログをNext.js 13に移行した

(2023/10/05 追記)Astro で書き直した

はじめに

🎉 Next.js 13.2 がリリースされ、App Router (beta) & React Server Component(以下 RSC)が実用できそうな感じになってきたため、このサイトも移行した。Metadata API、Route Handler 等も同時に導入した。

やったこと

(一般的な設定についてはドキュメント等を参照)

next.config.js

next.config.mjs
/** @type {import('next').NextConfig} */
let nextConfig = {
  reactStrictMode: true,
  experimental: {
    serverComponentsExternalPackages: [
      "playwright",
      "svgo",
      "plaiceholder",
      "@plaiceholder/next",
      "fetch-site-metadata",
    ],
    scrollRestoration: true,
    appDir: true,
  },
  images: {
    formats: ["image/avif", "image/webp"],
    domains: ["asciinema.org", "raw.githubusercontent.com"],
  },
};

experimental.serverComponentsExternalPackagesという項目がけっこう重要。Markdown の処理に node 依存のライブラリを使用している場合、該当ライブラリをここに列挙する必要がある。

あと関係ないが next.config.js はドシドシ ESM(.mjs)で書こう。

Markdown / MDX の処理

公式ブログを見る限り、@next/mdxnext-mdx-remotecontentlayerの三者は現時点(2023/03/03)で RSC で利用できる。

next-mdx-remote/rscを使ったところ、入出力の仕様が微妙に変更されていた。compileMDX で JSX を直接吐かせるか、<MDXRemote>コンポーネントに Markdown / MDX を食わせるか選べる(おそらくどちらでも出力に差はない)。

そのほか、frontmatter に型を付けられるようになった。

lib/compiler.ts
import { compileMDX } from "next-mdx-remote/rsc";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeKatex from "rehype-katex";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeRaw from "rehype-raw";
import rehypeSlug from "rehype-slug";
import remarkGemoji from "remark-gemoji";
import remarkGfm from "remark-gfm";
import remarkJaruby from "remark-jaruby";
import remarkMath from "remark-math";
import remarkUnwrapImages from "remark-unwrap-images";
 
import MDXComponent from "components/MDXComponent";
import rehypeImageOpt from "lib/rehype-image-opt";
import { remarkLinkCard, extLinkHandler } from "lib/remark-link-card";
import remarkMermaid from "lib/remark-mermaid";
 
import type { Options } from "rehype-pretty-code";
 
const rpcOptions: Partial<Options> = {
  theme: {
    light: "poimandres",
  },
  keepBackground: true,
  onVisitLine(node) {
    if (node.children.length === 0) {
      node.children = [{ type: "text", value: " " }];
    }
  },
  onVisitHighlightedLine(node) {
    node.properties.className.push("highlighted");
  },
  onVisitHighlightedWord(node) {
    node.properties.className = ["word"];
  },
};
 
const compiler = async (source: string) => {
  const result: Promise<{
    content: JSX.Element;
    frontmatter: {
      slug: string;
      title: string;
      date: string;
      description: string;
      tags: string[];
    };
  }> = compileMDX({
    source,
    components: MDXComponent,
    options: {
      mdxOptions: {
        remarkPlugins: [
          remarkGfm,
          remarkGemoji,
          remarkMath,
          remarkJaruby,
          remarkLinkCard,
          remarkUnwrapImages,
          [
            remarkMermaid,
            {
              wrap: true,
              className: ["mermaid"],
            },
          ],
        ],
        rehypePlugins: [
          rehypeSlug,
          [rehypeAutolinkHeadings, { behavior: "wrap" }],
          rehypeKatex,
          [rehypePrettyCode, rpcOptions],
          rehypeImageOpt,
          rehypeRaw,
        ],
        remarkRehypeOptions: {
          handlers: {
            extlink: extLinkHandler,
          },
        },
        format: "md",
      },
      parseFrontmatter: true,
    },
  });
  return result;
};
 
export default compiler;

Route Handler による OG 画像生成

Next.js 13.2 で API Routes を代替する Route Handler が登場したため、ついにOG 画像生成で使っていた pages ディレクトリを完全に廃止1できるようになった。

app/api/ogp/route.tsx
import type { NextRequest } from "next/server";
 
import { ImageResponse } from "@vercel/og";
 
export const runtime = "edge";
 
export async function GET(req: NextRequest) {
  try {
    const { searchParams } = new URL(req.url);
    const titleQ = searchParams.has("title")
      ? searchParams.get("title")?.slice(0, 80)
      : "";
    const dateQ = searchParams.has("date")
      ? `📅 ― ${searchParams.get("date")?.slice(0, 8)}`
      : "";
 
    // sanitize title & date
    const title = titleQ?.endsWith(".png") ? titleQ.slice(0, -4) : titleQ;
    const date = dateQ?.endsWith(".png") ? dateQ.slice(0, -4) : dateQ;
 
    // CJK font is so large that if placed locally it easily exceeds the 1MB Edge Function limit >_<
    const notoFontData = await fetch(
      "https://rawcdn.githack.com/haxibami/Noto-Sans-CJK-JP/master/fonts/NotoSansCJKjp-Bold.woff",
    ).then((res) => res.arrayBuffer());
 
    const robotoFontData = await fetch(
      new URL("../../../assets/RobotoMono-Medium.woff", import.meta.url),
    ).then((res) => res.arrayBuffer());
 
    const iconBuffer = await fetch(
      new URL("../../../assets/kripcat.jpg", import.meta.url),
    ).then((res) => res.arrayBuffer());
 
    const icon = Buffer.from(
      String.fromCharCode(...new Uint8Array(iconBuffer)),
      "binary",
    ).toString("base64");
 
    return new ImageResponse(
      (
        <div
          style={{
            height: "100%",
            width: "100%",
            display: "flex",
            flexDirection: "column",
            alignItems: "center",
            justifyContent: "center",
            padding: "30px",
            fontFamily: "Noto Sans CJK JP",
            backgroundColor: "#120e12",
            color: "#f2f0e6",
          }}
        >
          <div tw="flex flex-col p-12 w-full h-full border-solid border-4 border-white rounded-xl">
            <div tw="flex flex-1 max-w-full items-center max-h-full">
              <h1 tw="text-6xl leading-tight max-w-full">
                <p tw="w-full justify-center">{title}</p>
              </h1>
            </div>
            <div tw="flex flex-row justify-between items-center w-full">
              <div tw="flex items-center">
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={`data:image/png;base64,${icon}`}
                  alt="haxicon"
                  width={100}
                  height={100}
                  tw="rounded-full mr-5"
                />
                <h2 tw="text-4xl mr-5">
                  <p
                    style={{
                      fontFamily: "Roboto Mono",
                    }}
                  >
                    haxibami.net
                  </p>
                </h2>
              </div>
              <div tw="flex">
                <h2 tw="text-4xl">
                  <p>{date}</p>
                </h2>
              </div>
            </div>
          </div>
        </div>
      ),
      {
        fonts: [
          {
            name: "Noto Sans CJK JP",
            data: notoFontData,
            weight: 700,
            style: "normal",
          },
          {
            name: "Roboto Mono",
            data: robotoFontData,
            weight: 500,
            style: "normal",
          },
        ],
      },
    );
  } catch (e) {
    console.log(`${e}`);
    return new Response(`Failed to generate the image`, {
      status: 500,
    });
  }
}

Metadata API

同じく Next.js 13.2 で登場。ドキュメント通りにやるだけ。

サイトマップは、ビルド前にキャッシュしておいた記事のインデックスに基づいて生成するようにしてある。

hooks/scripts/indexer.mts
import fs from "fs";
 
import prettier from "prettier";
 
import { getPostsData, getTags } from "./lib/fs.js";
 
import type { PostData } from "./lib/interface.js";
 
const articleIndexer = async () => {
  const blogs = await getPostsData("articles/blog");
  const blogIndex = blogs.map((item) => {
    const indexitem: PostData = {
      preview: item.preview,
      data: {
        slug: `${item.data?.slug}`,
        title: `${item.data?.title}`,
        date: item.data?.date,
        description: `${item.data?.description}`,
        tags: item.data?.tags,
      },
    };
    return indexitem;
  });
 
  const blogTags = await getTags("articles/blog");
 
  const index = {
    articles: {
      blog: blogIndex,
    },
    tags: {
      blog: blogTags,
    },
  };
 
  const formatted = (json: string) => prettier.format(json, { parser: "json" });
 
  fs.writeFileSync("src/share/index.json", formatted(JSON.stringify(index)));
};
src/app/sitemap.ts
import type { MetadataRoute } from "next";
 
import { globby } from "globby";
 
import { dateConverter } from "lib/build";
import { HOST } from "lib/constant";
// Article index file
import postIndex from "share/index.json";
 
import type { PostData } from "lib/interface";
 
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const constPaths = await globby(["src/app/**/page.tsx", "src/app/page.tsx"], {
    ignore: ["src/app/api/*.tsx", "src/app/grad_essay/**", "src/app/**/[*/**"],
  });
 
  const constPageEntries = constPaths.map((filePath) => {
    const constPageEntry = {
      relpath: filePath.replace("src/app/", "").replace("page.tsx", ""),
      lastmod: "",
    };
    return constPageEntry;
  });
 
  const blogposts = postIndex.articles.blog;
 
  const blogTags = postIndex.tags.blog;
 
  const blogEntries = blogposts.map((post: PostData) => {
    const blogEntry = {
      relpath: `blog/posts/${post.data?.slug}`,
      lastmod: dateConverter(post.data?.date),
    };
    return blogEntry;
  });
 
  const blogTagEntries = blogTags.map((tag: string) => {
    const blogTagEntry = {
      relpath: `blog/tag/${tag}`,
      lastmod: "",
    };
    return blogTagEntry;
  });
 
  const sitemapEntries = constPageEntries.concat(blogEntries, blogTagEntries);
  return sitemapEntries.map((entry) =>
    entry.lastmod !== ""
      ? {
          url: `https://${HOST}/${entry.relpath}`,
          lastModified: entry.lastmod,
        }
      : {
          url: `https://${HOST}/${entry.relpath}`,
        },
  );
}

所感

完全な静的サイトゆえ、実のところそれほど変化はない。バンドルサイズは多少小さくなったかも。

ビルド結果
ビルド結果

ちなみに Lighthouse はこんな感じ:

トップ
トップ
プロフィール
プロフィール
この記事
この記事

脚注

  1. 厳密には 404.js がまだ残っているが、こちらで書かなくても処理されるのでディレクトリ自体は削除可能