saltcandy123
2020年9月21日

Next.js を使った個人用ブログの開発メモ

Next.js で個人用ブログを作ってみました。 この記事は、クックブック的なメモです。

基礎

Next.js の公式チュートリアル (ソースコード) では、まさにブログを題材にしています。 これを見ればとりあえず完成します。

デプロイもチュートリアルどおり Vercel を使いました。 Free プランで問題なさそうです。 独自ドメインも使えます。

あとは、 TypeScript 対応も簡単にできます。

Markdown → HTML 変換のカスタマイズ

unified を使って Markdown を HTML に変換していきます。 このライブラリは、文字列や木構造の小さな変換を組み合わせて繋いでいくことで、柔軟な変換処理を提供するものです。

今回必要な変換は、 Markdown から HTML への変換のような気がしますが、本当に必要なのは、 Markdown から React コンポーネントへの変換です。 これを実現するために、次のプラグインを使います。

rehype-react はオプションとして、 HTML 要素の代替となる React コンポーネントを指定できます。 これを使えば、例えば、自分好みの CSS を適切に指定することができます。

import React from "react";
import unified from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeReact from "rehype-react";

export const MyHeading1: React.FunctionComponent<React.ComponentPropsWithoutRef<
  "h1"
>> = ({ children, ...props }) => {
  return (
    <h1 {...props}>
      {children}
      <style jsx>{`
        h1 {
          margin: 0 0 1rem;
          padding: 0.5rem 0;
          font-size: 2rem;
          font-weight: 700;
        }
      `}</style>
    </h1>
  );
};

const processor = unified()
  .use(remarkParse)
  .use(remarkRehype)
  .use(rehypeReact, {
    createElement: React.createElement,
    components: {
      h1: MyHeading1 as any
    },
  })
  .freeze();

const Markdown: React.FunctionComponent<{ markdown: string }> = (
  props
) => {
  return processor.processSync(props.markdown).result as React.ReactElement;
};

記事中のコードのハイライト

highlight.js と、その unified プラグインである rehype-highlight を使えば、コードのシンタックスハイライトが可能です。

このプラグインは、 <pre><code></code></pre> で囲まれた部分を探し出し、ハイライトを行うのですが、プログラミング言語が明示的に指定されていないときには、言語を自動推定するようになっています。 そのため、 Markdown でコードブロック (``` で囲まれた部分) を記述するときに常に言語を指定しないと、不適切なハイライトが行われることがあります。 これを回避するためには、 Markdown 構文木に対して、言語が指定されていないコードブロックに対して、 text にフォールバックする、という独自プラグインを与えればよいです。

import React from "react";
import { Code } from "mdast";
import unified, { Plugin } from "unified";
import visitParents from "unist-util-visit-parents";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeReact from "rehype-react";
import rehypeHighlight from "rehype-highlight";
// 本当は _app.tsx でインポートする
import "highlight.js/styles/a11y-dark.css";

const defaultCodeLangSetter: Plugin = () => {
  return (tree) => {
    visitParents(tree, "code", (node: Code) => {
      if (!node.lang) node.lang = "text";
    });
  };
};

const processor = unified()
  .use(remarkParse)
  .use(defaultCodeLangSetter)
  .use(remarkRehype)
  .use(rehypeHighlight)
  .use(rehypeReact, {
    createElement: React.createElement
  })
  .freeze();

...

TeX 記法による数式表示

TeX 記法を使って数式をきれいに表示することもできます。 例えば、 $x = \cfrac{-b \pm \sqrt{b^2 - 4ac}}{2a}$ と書けば、 x=b±b24ac2ax = \cfrac{-b \pm \sqrt{b^2 - 4ac}}{2a} と表示されるようになります。

表示させるには、 remark-mathrehype-katex だけでなく、 CSS のインポートのために katex も必要です。

import React from "react";
import unified from "unified";
import remarkParse from "remark-parse";
import remarkMath from "remark-math";
import remarkRehype from "remark-rehype";
import rehypeKatex from "rehype-katex";
import rehypeReact from "rehype-react";
// 本当は _app.tsx でインポートする
import "katex/dist/katex.css";

const processor = unified()
  .use(remarkParse)
  .use(remarkMath)
  .use(remarkRehype)
  .use(rehypeKatex)
  .use(rehypeReact, {
    createElement: React.createElement
  })
  .freeze();

...

Open Graph Protocol 対応

Open Graph Protocol のメタデータを設定すると、 SNS で記事リンクを投稿したときに、かっこいいカードが表示されるようになります。

import React from "react";
import Head from "next/head";

function getFullUrlFromPath(path: string) {
  return `https://example.com${path}`;
}

interface LayoutProps {
  title: string;
  description: string;
  imagePath: string;
  canonicalPath: string;
}

const Layout: React.FunctionComponent<LayoutProps> = (props) => {
  return (
    <div>
      <Head>
        <title>{props.title}</title>
        <meta property="og:title" content={props.title} />
        <meta property="og:type" content="website" />
        <meta property="og:image" content={getFullUrlFromPath(props.imagePath)} />
        <meta
          property="og:url"
          content={getFullUrlFromPath(props.canonicalPath)}
        />
        <meta property="og:description" content={props.description} />
      </Head>

      <main>{props.children}</main>
    </div>
  );
};

さて、記事から自動的に description を作ることができたら嬉しいですね。 たぶん、記事中の最初のパラグラフをテキストとして抽出できれば良さそうです。 これも、 unified と remark-parse で Markdown を木構造に変換してから、その部分を抽出すれば終わりです。

import unified, { Plugin } from "unified";
import remarkParse from "remark-parse";
import visitParents from "unist-util-visit-parents";
import { Heading, Paragraph, Text } from "mdast";

const descriptionExtractor: Plugin = function () {
  this.Compiler = (tree) => {
    const textComponents: string[] = [];
    visitParents(tree, "paragraph", (paragraph: Paragraph) => {
      if (textComponents.length > 0) return;
      visitParents(paragraph, "text", (node: Text) => {
        if (node.value.length > 0) textComponents.push(node.value);
      });
    });
    return textComponents.join("");
  };
}

const descriptionExtractionProcessor = unified()
  .use(remarkParse)
  .use(descriptionExtractor)
  .freeze();

function extractDescription(markdown: string): string {
  return descriptionExtractionProcessor.processSync(markdown).toString();
}

URL について

og:imageog:url で指定する URL は、ホスト部分を含んだフルバージョンである必要があるようです。 つまり、 /blog/slug などではうまく動かず、 https://example.com/blog/slug のようにする必要があります。

Next.js で Static Generation を使っている (getStaticProps() を使っている) 場合、ビルド時にデータを作ってしまうので、ページがどのホストでサーブされるのかを知る術がありません。 そのため、上の例では getFullUrlFromPath() で残念な処理を行っています。

一方で、 Server-side Rendering を使う (getServerSideProps() を使う) 場合、たぶんリクエスト情報からホスト名を得ることができるので、もう少しきれいにできると思います。

Markdown からタイトルを抽出する

チュートリアルでは、 Markdown に Front Matter ブロックを入れることでタイトルや日付のデータを与えています。 タイトルは、 Markdown の h1 として与えた方が自然な気がするので、 Markdown から抽出してみます。

やはり unified と remark を使えばいい感じにできます。

import unified, { Plugin } from "unified";
import remarkParse from "remark-parse";
import visitParents from "unist-util-visit-parents";
import { Heading } from "mdast";

const h1Extractor: Plugin = function () {
  this.Compiler = (tree) => {
    const h1Texts: string[] = [];
    visitParents(tree, "heading", (node: Heading) => {
      if (node.depth !== 1) return;
      if (node.children.length !== 1 || node.children[0].type !== "text") {
        throw new Error();
      }
      h1Texts.push(node.children[0].value);
    });
    return h1Texts.join(" / ");
  };
};

const titleExtractionProcessor = unified()
  .use(remarkParse)
  .use(h1Extractor)
  .freeze();

function extractTitle(markdown: string): string {
  return titleExtractionProcessor.processSync(markdown).toString();
}