👩‍⚕️

女医が教える本当に気持ちのいい Markdown 変換処理【Next.js編】

2023/02/14に公開

謝罪

タイトルの「女医」の部分だけ嘘です。
すみません。

はじめに

エンジニアの友達が少ないので、Twitterで仲良くしてくれると嬉しいです…

今回紹介する技術を使用したプロダクト

ガクブル村というサイトを作成しました。

https://gakuburu-mura.yoshii.pro/

ひたすら怖い動画を紹介し続けるサイトです。
このサイトの記事詳細や利用規約のマークダウン変換処理に、今回の記事で紹介する手法を使用しています。
よかったら見てね。

この記事でやること

Next.js プロジェクトで最高に気持ちのいい Markdown -> HTML の変換処理を紹介します。
unified を使います。
サンプルのブログを作りながら進めていくので、 Next.js で静的なマークダウンファイルからブログを作っていみたいという方は是非手を動かしながら一緒に読み進めてみてください。
unified だの remark だの rehype だのをなんとなく使ってる人も理解が深まると思います。
結構長いですが、暇な時に一緒に手を動かしてみてください。

GitHub リポジトリ

今回作成するサンプルサイトはこちらのリポジトリに全て公開しています。
クローンして動かしてみてください。
https://github.com/yoshiishunichi/sample-markdown-blog-nextjs

実行環境

  • OS
    • MacOS Monterey
  • node
    • v16.19.0
  • パッケージマネージャー
    • yarn
  • Next.js
    • v13.1.6
  • Lint 関連

作成の前に

実際に手を動かす前に、前提となる認識などを確認しておきましょう。

なぜマークダウンでブログを作るのか

なぜ HTML を書かず、 Markdown -> HTML の変換処理を書いてまでマークダウンで記事を書くのか、ということについてお話しします。
これは、「マークダウン メリット」とかでググれば出てくると思いますが、個人的には書式が統一しやすいところだと思っています。
例えば、不要な div が入ったりしにくいみたいなことです。
他にも、普通に文字数が少なくなったり、知識ゼロの人が生データを見ても何をやってるのか分かりやすいというメリットがあります。

unified を理解する

これが重要です。
今回の記事では unified という文字列を構造化データとして処理するパッケージの集合体を使って変換処理を書いていきます。
unified についてはこちらの記事が死ぬほどわかりやすいです。
https://qiita.com/sankentou/items/f8eadb5722f3b39bbbf8

手を動かす過程でも説明していきますが、重要な点として以下がなんとなく頭の中に入っていれば問題ないです。

  • unified は AST(抽象構文木) というプログラムで処理しやすいデータを経由して Markdown や HTML を好きなように書き換えます。
  • Markdown 文字列を AST に変換したものが mdast
  • HTML 文字列を AST に変換したものが hast
  • mdast を処理するのが remark
    • remark-〇〇 みたいな名前のプラグインは大体 mdast の世界で何らかの処理をする
  • hast を処理するのが rehype
    • rehype-〇〇 みたいな名前のプラグインは大体 hast の世界で何らかの処理をする

シンプルなマークダウンブログ作成

ここから手を動かしていきます。
とりあえず最初は別に気持ちよくない普通の変換処理を作っていきます。

こちらの Next.js 公式サンプルとやってることはほぼ同じです。
https://github.com/vercel/next.js/tree/deprecated-main/examples/blog-starter

気持ちいい部分だけ読みたい人は こちらまで読み飛ばしてください。

プロジェクトの初期化

まずは Next.js プロジェクトを初期化しましょう。

筆者は、いつも以下のリポジトリを clone して初期化しています。
Lint 設定とかめんどいからね…
https://github.com/yoshiishunichi/next-lint-template

お前の Lint 設定なんか使いたくないという人は普通に yarn create next-app とかしてください。
プロジェクト名は sample-markdown-blog-nextjs とします。

ディレクトリ構成

以下のようにしていく予定です。

- sample-markdown-blog-nextjs/
	- contents/: マークダウンファイルを保存
	- src/
		- components/: コンポーネント
		- libs/: ライブラリ固有の処理のラッパー
		- pages/: Next.js のルーティング用ディレクトリ
		- styles/: CSS (筆者は SCSS)
	- package.json

マークダウンファイルの作成

適当に contents/ ディレクトリにマークダウンファイルを入れましょう。

とりあえず、以下の2つのファイルを作成しました。
確認したい挙動が一通り確認できるようにしています。

https://github.com/yoshiishunichi/sample-markdown-blog-nextjs/blob/main/contents/blog-1.md?plain=1

https://github.com/yoshiishunichi/sample-markdown-blog-nextjs/blob/main/contents/blog-2.md?plain=1

スタイルを作成

CSS module で丁寧にやろうかと思いましたが、めんどいので、 src/styles/globals.scss に全部記述します。

src/styles/globals.scss
html,
body,
* {
  box-sizing: border-box;
  padding: 0;
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
    "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
  font-size: 15px;
  font-weight: normal;
}

.container {
  max-width: 750px;
  padding: 12px 12px 0;
  margin: 0 auto;

  h1 {
    padding-bottom: 4px;
    margin-bottom: 16px;
    font-size: 24px;
    font-weight: bold;
    border-bottom: solid 2px gray;
  }

  h2 {
    padding-bottom: 4px;
    margin: 16px 0;
    font-size: 21px;
    font-weight: bold;
    border-bottom: solid 1px gray;
  }

  h3 {
    margin: 16px 0;
    font-size: 18px;
    font-weight: bold;
  }

  p {
    margin: 6px 0;
  }

  a {
    color: #0af;
    transition: opacity 0.1s linear;
  }

  a:hover {
    opacity: 0.5;
  }

  ul {
    display: flex;
    flex-direction: column;
    gap: 4px;
    margin-left: 12px;
    list-style-position: inside;
  }
}

マークダウンファイルの一覧の取得処理作成

src/libs/get-all-slug.ts を作成します。
マークダウンファイルのタイトルを slug と呼ぶことにしましょう。
引数で受け取ったディレクトリ内から slug の配列を返します。

src/libs/get-all-slug.ts
import fs from "fs";

export const getAllSlug = (dirPath: string) => {
  return fs.readdirSync(dirPath).map((fileName) => {
    return fileName.replace(/\.md$/, "");
  });
};

マークダウンの内容の読み込み処理作成

以下のコマンドでライブラリを追加してください。

yarn add gray-matter

gray-matter のリポジトリ
https://github.com/jonschlinkert/gray-matter

src/libs/get-markdown.ts を作成します。
fs でマークダウンファイルの内容を取得して、 gray-matter でマークダウンの情報に変換します。

src/libs/get-markdown.ts
import fs from "fs";

import matter from "gray-matter";

export const getMarkdown = (filePath: string) => {
  const fileContents = fs.readFileSync(filePath, "utf8");

  return matter(fileContents);
};

マークダウン文字列の変換処理作成

以下のコマンドでライブラリを追加してください。

yarn add unified remark-parse remark-rehype rehype-stringify

各ライブラリのリポジトリ
https://github.com/unifiedjs/unified
https://github.com/remarkjs/remark/tree/main/packages/remark-parse
https://github.com/remarkjs/remark-rehype
https://github.com/rehypejs/rehype/tree/main/packages/rehype-stringify

src/libs/markdown-to-html.ts を作成します。

src/libs/markdown-to-html.ts
import rehypeStringify from "rehype-stringify";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";

export const markdownToHtml = async (markdownContent: string) => {
  const result = await unified()
    .use(remarkParse)
    .use(remarkRehype)
    .use(rehypeStringify)
    .process(markdownContent);
  return result.toString();
};

やってることは、以下です。

  1. 引数で Markdown 文字列を受け取る
  2. remarkParse で Markdown 文字列から mdast に変換
  3. remarkRehype で mdast から hast に変換
  4. rehypeStringify で hast から HTML 文字列に変換

なんとなくわかりますか?

ちなみに、Next.js 公式サンプルの方では、以下のようにしています。
この記事ではこっちに合わせて進めます。
こっちに合わせる場合は、上記のライブラリはいらないので以下を追加してください。

yarn add remark remark-html

各ライブラリのリポジトリ
https://github.com/remarkjs/remark/tree/main/packages/remark
https://github.com/remarkjs/remark-html

src/libs/markdown-to-html.ts
import { remark } from "remark";
import remarkHtml from "remark-html";

export const markdownToHtml = async (markdownContent: string) => {
  const result = await remark().use(remarkHtml).process(markdownContent);
  return result.toString();
};

やってることは、以下です。

  1. remark で mdast に変換
  2. remarkHtml で mdast から HTML 文字列まで一気に変換

こっちの方が短くて良いので、これで進めていきます。

記事一覧を作成する

src/pages/index.tsx を編集します。

src/pages/index.tsx
import Link from "next/link";

import { getAllSlug } from "libs/get-all-slug";

import type { GetStaticProps, NextPage } from "next";

type HomeProps = {
  slugs: string[];
};

const Home: NextPage<HomeProps> = ({ slugs }) => {
  return (
    <div className="container">
      <h1>記事一覧</h1>
      <ul>
        {slugs.map((slug, i) => {
          return (
            <li key={i}>
              <Link href={`/blogs/${slug}`}>{slug}</Link>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export const getStaticProps: GetStaticProps<HomeProps> = () => {
  return {
    props: {
      slugs: getAllSlug("contents"),
    },
  };
};

export default Home;

この状態で yarn dev して http://localhost:3000/ にアクセスすると記事一覧が表示されます。
getStaticProps で slug 一覧を props に渡します。

記事詳細を作成

src/pages/blogs/[slug].tsx を作成します。

src/pages/blogs/[slug].tsx
import { GetStaticPaths, GetStaticProps, NextPage } from "next";

import { getAllSlug } from "libs/get-all-slug";
import { getMarkdown } from "libs/get-markdown";
import { markdownToHtml } from "libs/markdown-to-html";

type BlogDetailPageProps = {
  htmlContent: string;
};

const BlogDetailPage: NextPage<BlogDetailPageProps> = ({ htmlContent }) => {
  return <div className="container" dangerouslySetInnerHTML={{ __html: htmlContent }} />;
};

export const getStaticPaths: GetStaticPaths = () => {
  return {
    fallback: false,
    paths: getAllSlug("contents").map((slug) => {
      return {
        params: {
          slug,
        },
      };
    }),
  };
};

export const getStaticProps: GetStaticProps<BlogDetailPageProps> = async ({ params }) => {
  const slug = params?.slug;

  return {
    props: {
      htmlContent: await markdownToHtml(getMarkdown(`contents/${slug}.md`).content),
    },
  };
};

export default BlogDetailPage;

やってることは以下です。

  1. getStaticPaths で存在する slug に対応するページのみレンダリング
  2. getStaticProps で Markdown 文字列から HTML 文字列への変換処理を実行し、 props に渡す
  3. 受け取った HTML 文字列を dangerouslySetInnerHTML で表示

ここまでの経過

一旦ここまでで、 yarn dev して http://localhost:3000/ にアクセスしてみましょう!

ホーム http://localhost:3000/

blog1 http://localhost:3000/blogs/blog-1

blog-2 http://localhost:3000/blogs/blog-2

おお〜
これであなたも Markdown ブロガーです。

ただ、色々ツッコミどころはありますね。

例えば blog-1 の直接書いた URL がリンクになってなかったり、 blog-2 の直接書いた img タグが何も表示されてなかったりします。
それに、内部リンクは next/link にしたいし、 img タグも next/image 対応したいです。
次はこいつらを何とかしましょう。

本当に気持ちのいい Markdown 変換処理

ようやくタイトル回収です。
最終回みたいでかっこいいですね。

直接書いた URL をリンクにする(GFM 対応)

直接書いた URL をリンクにします。
GFM(github flavored markdown) という GitHub の README.md の形式のマークダウンに変換することでこれを実現します。

GFM の仕様は以下にまとまってます。
URL 直接記述がリンクになるとか、打ち消し線とかチェックリストとかに対応してます。
https://github.github.com/gfm/

以下のライブラリを追加します。

yarn add remark-gfm

remark-gfm のリポジトリ
https://github.com/remarkjs/remark-gfm

そして、 src/libs/markdown-to-html.ts を修正します。

src/libs/markdown-to-html.ts
import { remark } from "remark";
import remarkGfm from "remark-gfm";
import remarkHtml from "remark-html";

export const markdownToHtml = async (markdownContent: string) => {
  const result = await remark().use(remarkGfm).use(remarkHtml).process(markdownContent);
  return result.toString();
};

流れとしては、 remark で取得した mdast に対して remarkGfm で GFM に変換します。

これで yarn dev して http://localhost:3000/blogs/blog-1 を見てみると、 URL がリンクになってますね。

改行を br タグにする

改行を br タグにします。
以下のライブラリを追加します。

yarn add remark-breaks

remark-breaks のリポジトリ
https://github.com/remarkjs/remark-breaks

src/libs/markdown-to-html.ts を変更します。

src/libs/markdown-to-html.ts
import { remark } from "remark";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkHtml from "remark-html";

export const markdownToHtml = async (markdownContent: string) => {
  const result = await remark()
    .use(remarkGfm)
    .use(remarkBreaks)
    .use(remarkHtml)
    .process(markdownContent);
  return result.toString();
};

mdast に対して remarkBreaks で改行を br タグに変換しています。

これで yarn dev して http://localhost:3000/blogs/blog-1 を見てみると、 1行の改行も br タグになって改行されていますね。

直接書いたタグに対応

直接書いた img タグなどが表示されるようにします。
以下のライブラリを追加します。

yarn add remark-rehype rehype-raw rehype-stringify

実際に使うのは rehype-raw です。

rehype-raw のリポジトリです。
https://github.com/rehypejs/rehype-raw

rehype-〇〇 という名前なので、 hast を処理するプラグインです。
なので、 remarkHtml を分解して以下の流れにします。

  • remark-rehypemdasthast に変換
  • rehype-stringifyhast を HTML 文字列に変換

そして、これらの間のコンテンツが hast になっているタイミングで rehype-raw を使って処理しようという作戦です。

src/libs/markdown-to-html.ts を以下に変更します。

src/libs/markdown-to-html.ts
import rehypeRaw from "rehype-raw";
import rehypeStringify from "rehype-stringify";
import { remark } from "remark";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";

export const markdownToHtml = async (markdownContent: string) => {
  const result = await remark()
    .use(remarkGfm)
    .use(remarkBreaks)
    .use(remarkRehype, {
      allowDangerousHtml: true,
    })
    .use(rehypeRaw)
    .use(rehypeStringify)
    .process(markdownContent);
  return result.toString();
};

remarkRehypeallowDangerousHtml: true を指定して、 mdasthast に変換するときに直接書かれたタグを残しています。
そして、 rehypeRaw で直接書かれたタグを、そのタグの情報として表示するように処理します。
rehypeRaw を使わないとただの p タグになります。

これで yarn dev して http://localhost:3000/blogs/blog-2 を見てみると、直接書いた img タグが画像として表示されていますね。

注意点としては、マークダウンに script タグが含まれていると意図せず実行されてしまうおそれがあります。
XSS (クロスサイトスクリプティング)というやつですね。
これが問題になるパターンは、他人が投稿できるようなサービス(この Zenn とか)で発生します。
今回は Markdown ファイルが自前なので問題なしですが、不特定多数が投稿できるサービスの場合は注意しましょう。

内部リンクを next/link にします。
そして、ついでに外部リンクを別タブで開くようにします。
next/link についてのドキュメント
https://nextjs.org/docs/api-reference/next/link

ここからちょっと処理が変わります。
以下のライブラリを追加します。

yarn add rehype-react

rehype-react のリポジトリ
https://github.com/rehypejs/rehype-react
hastReactElement に変換するプラグインです。

変換処理を書く前に、コンポーネントを作成します。
src/components/CustomLink.tsx を以下の内容で作成します。

src/components/CustomLink.tsx
import Link from "next/link";
import { AnchorHTMLAttributes, FC } from "react";

const CustomLink: FC<AnchorHTMLAttributes<HTMLAnchorElement>> = ({ href, children }) => {
  return href?.startsWith("/") ? (
    <Link href={href}>{children}</Link>
  ) : (
    <a href={href} rel="noreferrer" target="_blank">
      {children}
    </a>
  );
};

export default CustomLink;

やってることは、 href を参照して / から始まる時は next/link 、それ以外は別タブで開く a タグに分岐するという内容です。

次に、markdown-to-html.tsmarkdown-to-react-element.ts にリネームし、以下のように変更します。

src/libs/markdown-to-react-element.ts
import { Fragment, createElement, ReactElement } from "react";
import rehypeRaw from "rehype-raw";
import rehypeReact from "rehype-react";
import { remark } from "remark";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";

import CustomLink from "components/CustomLink";

export const markdownToReactElement = (markdownContent: string): ReactElement => {
  return remark()
    .use(remarkGfm)
    .use(remarkBreaks)
    .use(remarkRehype, {
      allowDangerousHtml: true,
    })
    .use(rehypeRaw)
    .use(rehypeReact, {
      Fragment,
      components: {
        a: CustomLink,
      },
      createElement,
    })
    .processSync(markdownContent).result;
};

やっていることは以下です。

  • rehypeReacthast を受け取り、ReactElement に変換します。
    • この時、 a タグを CustomLink コンポーネントに変換します。
    • Fragment オプションは指定しないとコンテンツ全体が div で囲まれてしまいます。
    • createElement オプションは ReactElement を作る関数を教えてあげています。
  • processSync(markdownContent) で同期的に変換
  • processSync(markdownContent).result で ReactElement を返却

そして、記事詳細ページ src/pages/blogs/[slug].tsx を以下の内容に修正します。

src/pages/blogs/[slug].tsx
import { GetStaticPaths, GetStaticProps, NextPage } from "next";

import { getAllSlug } from "libs/get-all-slug";
import { getMarkdown } from "libs/get-markdown";
import { markdownToReactElement } from "libs/markdown-to-react-element";

type BlogDetailPageProps = {
  markdownContent: string;
};

const BlogDetailPage: NextPage<BlogDetailPageProps> = ({ markdownContent }) => {
  return <div className="container">{markdownToReactElement(markdownContent)}</div>;
};

export const getStaticPaths: GetStaticPaths = () => {
  return {
    fallback: false,
    paths: getAllSlug("contents").map((slug) => {
      return {
        params: {
          slug,
        },
      };
    }),
  };
};

export const getStaticProps: GetStaticProps<BlogDetailPageProps> = ({ params }) => {
  const slug = params?.slug;

  return {
    props: {
      markdownContent: getMarkdown(`contents/${slug}.md`).content,
    },
  };
};

export default BlogDetailPage;

getStaticProps でマークダウンファイルの内容を文字列で受け取り、コンポーネント側で変換した ReactElement を表示しています。

yarn dev して http://localhost:3000/blogs/blog-1 にアクセスして、リンクをクリックしてみると、内部リンクは同一タブ、外部リンクは別タブで開かれるようになっていると思います。

画像を next/image に対応

画像を next/image に変換します。
next/image の公式ドキュメントはこちらです。
https://nextjs.org/docs/api-reference/next/image

やること自体はさっきのリンク対応と同じです。
src/components/CustomImage.tsx を以下の内容で作成します。

src/components/CustomImage.tsx
import Image from "next/image";
import { FC, ImgHTMLAttributes } from "react";

const CustomImage: FC<ImgHTMLAttributes<HTMLImageElement>> = ({ src, alt, width, height }) => {
  if (!src) return <span>src が指定されていません。</span>;

  return width && height ? (
    <Image alt={alt ?? "alt なし"} height={Number(height)} src={src} width={Number(width)} />
  ) : (
    <img alt={alt ?? "alt なし"} height={height} src={src} width={width} />
  );
};

export default CustomImage;

以下を条件分岐させて表示しています。

  • src が指定されてない時は src がない旨を表示
  • width・height がある時は next/image で表示
    • next/image は props に width と height が必要なので
  • それ以外は img タグで表示

そして、 src/libs/markdown-to-react-element.ts を以下の内容に変更します。

src/libs/markdown-to-react-element.ts
import { Fragment, createElement, ReactElement } from "react";
import rehypeRaw from "rehype-raw";
import rehypeReact from "rehype-react";
import { remark } from "remark";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";

import CustomImage from "components/CustomImage";
import CustomLink from "components/CustomLink";

export const markdownToReactElement = (markdownContent: string): ReactElement => {
  return remark()
    .use(remarkGfm)
    .use(remarkBreaks)
    .use(remarkRehype, {
      allowDangerousHtml: true,
    })
    .use(rehypeRaw)
    .use(rehypeReact, {
      Fragment,
      components: {
        a: CustomLink,
        img: CustomImage,
      },
      createElement,
    })
    .processSync(markdownContent).result;
};

これで hastimg タグを CustomImage コンポーネントに変換します。

yarn dev して http://localhost:3000/blogs/blog-2 にアクセスしてみると、 width と height が指定されている方は next/image に、それ以外は img タグになっています。

Chrome デベロッパーツールズで確認できます。

width と height を指定しているので next/image

width と height がないので普通の img タグ

完成

これで完成です!
yarn dev して http://localhost:3000/ にアクセスして、満足するまで眺めましょう。

最後に

以上になります。
ここまで、読んでいただいてありがとうございました。
unified には今回紹介した他にも色々便利なプラグインがあります。

見出しをつける rehype-slug とか
https://github.com/rehypejs/rehype-slug

heading をリンクにする rehype-autolink-headings とか
https://github.com/rehypejs/rehype-autolink-headings

ここまで読んでくれたあなたなら、どんなブログでも作れるはずです。
自信を持って最高のブログを作ってみてください。
もし、コメントや Twitter のリプで「こんなの作りました」って教えてくれたら見に行きます!

では、また〜

Discussion