女医が教える本当に気持ちのいい Markdown 変換処理【Next.js編】
謝罪
タイトルの「女医」の部分だけ嘘です。
すみません。
はじめに
エンジニアの友達が少ないので、Twitterで仲良くしてくれると嬉しいです…
今回紹介する技術を使用したプロダクト
ガクブル村というサイトを作成しました。
ひたすら怖い動画を紹介し続けるサイトです。
このサイトの記事詳細や利用規約のマークダウン変換処理に、今回の記事で紹介する手法を使用しています。
よかったら見てね。
この記事でやること
Next.js プロジェクトで最高に気持ちのいい Markdown -> HTML の変換処理を紹介します。
unified
を使います。
サンプルのブログを作りながら進めていくので、 Next.js で静的なマークダウンファイルからブログを作っていみたいという方は是非手を動かしながら一緒に読み進めてみてください。
unified
だの remark
だの rehype
だのをなんとなく使ってる人も理解が深まると思います。
結構長いですが、暇な時に一緒に手を動かしてみてください。
GitHub リポジトリ
今回作成するサンプルサイトはこちらのリポジトリに全て公開しています。
クローンして動かしてみてください。
実行環境
- OS
- MacOS Monterey
- node
- v16.19.0
- パッケージマネージャー
- yarn
- Next.js
- v13.1.6
- Lint 関連
- こちらの記事の設定で行います(俺が書いて初めて1万ビューいった記事なので自慢するのが目的です)
作成の前に
実際に手を動かす前に、前提となる認識などを確認しておきましょう。
なぜマークダウンでブログを作るのか
なぜ HTML を書かず、 Markdown -> HTML の変換処理を書いてまでマークダウンで記事を書くのか、ということについてお話しします。
これは、「マークダウン メリット」とかでググれば出てくると思いますが、個人的には書式が統一しやすいところだと思っています。
例えば、不要な div
が入ったりしにくいみたいなことです。
他にも、普通に文字数が少なくなったり、知識ゼロの人が生データを見ても何をやってるのか分かりやすいというメリットがあります。
unified を理解する
これが重要です。
今回の記事では unified
という文字列を構造化データとして処理するパッケージの集合体を使って変換処理を書いていきます。
unified
についてはこちらの記事が死ぬほどわかりやすいです。
手を動かす過程でも説明していきますが、重要な点として以下がなんとなく頭の中に入っていれば問題ないです。
-
unified
は AST(抽象構文木) というプログラムで処理しやすいデータを経由して Markdown や HTML を好きなように書き換えます。 - Markdown 文字列を AST に変換したものが
mdast
- HTML 文字列を AST に変換したものが
hast
-
mdast
を処理するのがremark
-
remark-〇〇
みたいな名前のプラグインは大体mdast
の世界で何らかの処理をする
-
-
hast
を処理するのがrehype
-
rehype-〇〇
みたいな名前のプラグインは大体hast
の世界で何らかの処理をする
-
シンプルなマークダウンブログ作成
ここから手を動かしていきます。
とりあえず最初は別に気持ちよくない普通の変換処理を作っていきます。
こちらの Next.js 公式サンプルとやってることはほぼ同じです。
気持ちいい部分だけ読みたい人は こちらまで読み飛ばしてください。
プロジェクトの初期化
まずは Next.js プロジェクトを初期化しましょう。
筆者は、いつも以下のリポジトリを clone して初期化しています。
Lint 設定とかめんどいからね…
お前の 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つのファイルを作成しました。
確認したい挙動が一通り確認できるようにしています。
スタイルを作成
CSS module で丁寧にやろうかと思いましたが、めんどいので、 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
の配列を返します。
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
のリポジトリ
src/libs/get-markdown.ts
を作成します。
fs
でマークダウンファイルの内容を取得して、 gray-matter
でマークダウンの情報に変換します。
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
各ライブラリのリポジトリ
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();
};
やってることは、以下です。
- 引数で Markdown 文字列を受け取る
- remarkParse で Markdown 文字列から
mdast
に変換 - remarkRehype で
mdast
からhast
に変換 - rehypeStringify で
hast
から HTML 文字列に変換
なんとなくわかりますか?
ちなみに、Next.js 公式サンプルの方では、以下のようにしています。
この記事ではこっちに合わせて進めます。
こっちに合わせる場合は、上記のライブラリはいらないので以下を追加してください。
yarn add remark remark-html
各ライブラリのリポジトリ
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();
};
やってることは、以下です。
- remark で mdast に変換
- remarkHtml で mdast から HTML 文字列まで一気に変換
こっちの方が短くて良いので、これで進めていきます。
記事一覧を作成する
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
を作成します。
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;
やってることは以下です。
-
getStaticPaths
で存在するslug
に対応するページのみレンダリング -
getStaticProps
で Markdown 文字列から HTML 文字列への変換処理を実行し、 props に渡す - 受け取った HTML 文字列を
dangerouslySetInnerHTML
で表示
ここまでの経過
一旦ここまでで、 yarn dev
して http://localhost:3000/ にアクセスしてみましょう!
http://localhost:3000/
ホームhttp://localhost:3000/blogs/blog-1
blog1http://localhost:3000/blogs/blog-2
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 直接記述がリンクになるとか、打ち消し線とかチェックリストとかに対応してます。
以下のライブラリを追加します。
yarn add remark-gfm
remark-gfm のリポジトリ
そして、 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 のリポジトリ
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
のリポジトリです。
rehype-〇〇 という名前なので、 hast
を処理するプラグインです。
なので、 remarkHtml
を分解して以下の流れにします。
-
remark-rehype
でmdast
をhast
に変換 -
rehype-stringify
でhast
を HTML 文字列に変換
そして、これらの間のコンテンツが hast
になっているタイミングで rehype-raw
を使って処理しようという作戦です。
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();
};
remarkRehype
に allowDangerousHtml: true
を指定して、 mdast
を hast
に変換するときに直接書かれたタグを残しています。
そして、 rehypeRaw
で直接書かれたタグを、そのタグの情報として表示するように処理します。
rehypeRaw
を使わないとただの p
タグになります。
これで yarn dev
して http://localhost:3000/blogs/blog-2 を見てみると、直接書いた img
タグが画像として表示されていますね。
注意点としては、マークダウンに script
タグが含まれていると意図せず実行されてしまうおそれがあります。
XSS (クロスサイトスクリプティング)というやつですね。
これが問題になるパターンは、他人が投稿できるようなサービス(この Zenn とか)で発生します。
今回は Markdown ファイルが自前なので問題なしですが、不特定多数が投稿できるサービスの場合は注意しましょう。
内部リンクを next/link にする
内部リンクを next/link
にします。
そして、ついでに外部リンクを別タブで開くようにします。
next/link
についてのドキュメント
ここからちょっと処理が変わります。
以下のライブラリを追加します。
yarn add rehype-react
rehype-react
のリポジトリ
hast
を ReactElement
に変換するプラグインです。
変換処理を書く前に、コンポーネントを作成します。
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.ts
を 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;
};
やっていることは以下です。
-
rehypeReact
でhast
を受け取り、ReactElement に変換します。- この時、
a
タグをCustomLink
コンポーネントに変換します。 -
Fragment
オプションは指定しないとコンテンツ全体がdiv
で囲まれてしまいます。 -
createElement
オプションは ReactElement を作る関数を教えてあげています。
- この時、
-
processSync(markdownContent)
で同期的に変換 -
processSync(markdownContent).result
で ReactElement を返却
そして、記事詳細ページ 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
の公式ドキュメントはこちらです。
やること自体はさっきのリンク対応と同じです。
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
を以下の内容に変更します。
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;
};
これで hast
の img
タグを 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
とか
heading をリンクにする rehype-autolink-headings
とか
ここまで読んでくれたあなたなら、どんなブログでも作れるはずです。
自信を持って最高のブログを作ってみてください。
もし、コメントや Twitter のリプで「こんなの作りました」って教えてくれたら見に行きます!
では、また〜
Discussion