Markdownのサイト内リンクをNext.jsの<Link>にしたい
先日、Next.js+microCMS+VercelのJAMStack構成で、自分のブログを作成しました。
ブログ記事のデータは、microCMSからMarkdown形式で入稿するようにしているのですが、記事の中でブログ内の別の記事へのリンクを貼る時に、Next.jsの<Link>
を使えたら便利だな、と思いました。
この記事ではそれをどうすれば実現できるのか、あれこれ試行錯誤した経緯を含めてご紹介したいと思います。
まずはMDXを使ってみた
まず最初に考えたのはMDXを使う方法でした。MDXとは、簡単に説明するとJSXをそのまま埋め込めるMarkdown記法です。
↓以下は公式サイトからの例ですが、こんな感じで.mdx
拡張子のファイルに記述できます。Markdownの記法で文章を書きつつ、別ファイルからエクスポートされているReactコンポーネントをインポートし、それをそのまま使うこともできます。
import { Chart } from '../components/chart'
# Here’s a chart
The chart is rendered inside our MDX document.
<Chart />
なんという便利さ…😵
これを導入すれば、next/link
からインポートした<Link>
コンポーネントをMarkdownの中に埋め込む、なんてことは朝飯前にできそうです。
Next.jsにMDXを導入する方法に関しては、Next.jsの公式ブログでも紹介されているので、その辺りを参考にしました。
そして色々と試行錯誤の末、自分のブログにMDXを導入することに成功しました…が、
結局、MDXは使わないことにした
実装を終えて、しばらく記事の書き心地を試してみたのですが、やっぱりMDXを使うのはやめることにしました。理由は以下の通りです。
1. MDXはヘッドレスCMSでは扱いづらい
率直に言うと、サイトのコンテンツをMDXで書くなら、ヘッドレスCMSなどの外部のデータストアを使うより、プロジェクト内の任意のディレクトリ内に直接.mdx
ファイルを置いて管理する方が、一番シンプルで楽だと思います。
特にNext.jsの場合は、@next/mdx
というライブラリを導入すれば、Next.jsプロジェクト内のpages/
ディレクトリに.mdx
ファイルを置いて、それをそのままサイトのページとして扱うことができます。
有名なところだと、Tailwind CSSの公式ブログがこのスタイルで構築されています。ブログの開発方法について紹介している記事もあるので、Next.jsで.mdx
ファイルを扱いたい場合は、参考にしてみると良いと思います。
しかし、今回の私のブログの場合は、ブログのコンテンツはmicroCMSで管理すると決めていました。そして当然のことながら、サイトとコンテンツを分けて管理している場合は、MDXのメリットの1つである「他のファイルからエクスポートされているReactコンポーネントを.mdx
ファイルへインポート」ができません。
一応next-mdx-remoteというライブラリをNext.jsに導入すれば、ヘッドレスCMS側からはMDX形式で入稿して、それをNext.js側で受け取るといったこともできるのですが、その場合は予めNext.js側でnext-mdx-remote
が提供するレンダー関数に、使用するコンポーネント郡を渡して設定しておかなくてはなりません。
これだと、ヘッドレスCMS上で記事を作成する時に、なにか記事で使いたいコンポーネントがあったとしても、「えーと…このコンポーネントはインポートされてる"てい"で使えるんだっけ?、サイト側で設定してたかな…」などと、一々気にしながら執筆することになります。そのたびに、Next.jsのプロジェクトを開いて確認するのは煩わしいですし、そのうちに、「もうプロジェクト内に.mdx
ファイル置いて、直接書いた方が早い!😡」となりそうです。
一時はMDXを使うためにmicroCMSを使うのは止めようかとも考えましたが、そもそもmicroCMSを使ってみたくてブログを作った面もあったので、結局MDXの方を切り捨てることにしました。
2. 技術記事を書くのにMDXはオーバースペックな気がした
そもそも今回自分がブログを作ることにしたのは、気軽に書ける自分専用の技術ブログが欲しかったからで、技術記事を書くだけなら完全にMarkdownで事足ります。見出しやリストやリンクを、HTMLよりもシンプルに記述できるのがMarkdownのメリットですし、MDX形式でReactコンポーネントも扱えるようになると、逆にシンプル且つ気軽に記事を書くのが難しくなるような気がしました。
<Link>
を書き込むのではなく、後から置き換えればいいのでは?
MDXの可能性に目が眩んで本来の目的を忘れかけていたので、初心に戻ることにしました。
まず、やりたいのはMarkdownに記述したサイト内リンクを<Link>
にすることです。
それなら別に無理に<Link>
コンポーネントをインポートしてMarkdownに直接書き込めるようにしなくても、Markdownで記述したサイト内リンクのみを、後から<Link>
に置き換えるようにすればいいのでは?と考え始めました。
そんな時に見つけたのが、以下の記事でした。
こちらの記事で紹介されているのは、remark-react
というunified
のプラグインをNext.jsに導入して、Markdownで書かれたコンテンツをReactに変換する方法です。ついでにMarkdownのサイト内リンクを<Link>
に置き換える方法も紹介されていました。まさに自分がやりたいと思っていたことでした。
unified
とは
unified
は、(大雑把に説明すると)MarkdownやHTMLなどのフォーマットで書かれたテキストを解析したり変換したりするJavaScriptライブラリをまとめたプロジェクトです。Markdownを処理するremark
や、HTMLを処理するrehype
なども、このunified
プロジェクトに属しています。
unified
の特徴は、MarkdownやHTMLで書かれたテキストを、抽象構文木(AST)に変換できる点です。これによりMarkdownのテキストをHTMLテキストに変えたりといった、別フォーマットへの変換を柔軟に行うことができます。
さらにunified
のライブラリは用途に応じて様々なプラグインが用意されており、それらの中から必要なものを選択し、組み合わせて使用することができます。
rehype-react
でサイト内リンクを<Link>
にする
先ほどの記事の中では、remark-react
を使ってMarkdownからReactへ変換し、その際にサイト内リンクを<Link>
にする方法が紹介されていますが、remark-react
はTypeScriptに未対応である上、私の場合はrehype
のプラグインを使ってブログ記事内のコードブロックをハイライトしたかったので、remark-react
と似たプラグインであるrehype-react
を使うことにしました。
remark-react
がMarkdownからReactへ変換するのに対し、rehype-react
はHTMLからReactへ変換します。
MarkdownテキストをHTMLテキストへ変換する関数を作成する
最初に必要なライブラリをインストールします。
npm i unified remark-parse remark-rehype rehype-stringify @leafac/rehype-shiki shiki
-
unified
:unified
ライブラリのまとめ役。.use()
でチェーンされた処理を実行する。 -
remark-parse
: Markdownをmdast(Markdownの抽象構文木)
に変換する。 -
remark-rehype
:mdast
をhast(HTMLの抽象構文木)
に変換する。 -
rehype-stringify
:hast
をHTMLに変換する。 -
@leafac/rehype-shiki
: shiki用のrehype
プラグイン -
shiki
: シンタックスハイライター。
シンタックスハイライターに関しては、自分はReactやNext.jsに関する記事を主に書きたかったので、JSX記法にもちゃんと対応しているハイライターを探していました。
今回採用しているshikiは、MicrosoftでVSCodeの開発に携わっていたエンジニアの方が開発されたもので、VSCodeと同じ構文解析法を採用しているので、VSCodeと同じ精度でコードをハイライトすることができる、らしいです。
そのshiki
を使用するために、今回は@leafac/rehype-shiki
というプラグインを使用しています。実は同じ用途で開発されたrehype-shiki
というプラグインが別に存在しているんですが、こちらの方は最近メンテナンスがされておらず、さらにTypeScriptにも未対応なので、後発でTypeScript対応もされている@leafac/rehype-shiki
の方を採用しました。
これらのライブラリを使って、MarkdownテキストをHTMLテキストへ変換する関数を実装します。
import unified from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
import rehypeShiki from '@leafac/rehype-shiki';
import * as shiki from 'shiki';
export const markdownToHtml = async (markdown: string) =>
unified() // unifiedライブラリの処理をまとめる
.use(remarkParse) // Markdownをmdast(Markdownの抽象構文木)に変換
.use(remarkRehype) // mdastをhast(HTMLの抽象構文木)に変換
.use(rehypeShiki, {
highlighter: await shiki.getHighlighter({
theme: 'nord',
}),
}) // shikiハイライターでコードブロックをハイライト
.use(rehypeStringify) // hastをHTMLに変換
.processSync(markdown); // 上記の処理を行うデータをここで受け取る
こんな感じで、unified()
に必要な処理のプラグインを.use()
でチェーンしていきます。このmarkdownToHtml()
関数に、microCMSからフェッチしたブログコンテンツのMarkdownテキストを渡すと、コードハイライトがスタイリング済みのHTMLテキストが返却されます。
この処理にrehype-react
をさらにチェーンして、一気にReactに変換することもできます。が、そうするとgetStaticProps()
のpropsに含めてreturnできなくなるため、ここではHTMLテキスト化したデータを返すだけにしています。HTMLテキストのReact化は、propsを受け取ったブログ記事のページコンポーネントで行います。
CustomLink
コンポーネントを作成する
ブログ記事の中にはサイト内リンクだけでなく、外部サイトへのリンクも貼られていることも多いでしょう。なので、単純にすべての<a>
タグを<Link>
コンポーネントに置き換えるのは良くありません。
そこで、href
の値を判定して返却するコンポーネントを切り替える<CustomLink>
コンポーネントを作成します。これは、先ほどのremark-react
の記事の中で紹介されている<CustomLink>
をそのままパク…拝借しました。(TypeScript用に書き直してはいますが)
import Link from 'next/link';
const CustomLink = ({
children,
href,
}: {
children: string;
href: string;
}): JSX.Element =>
href.startsWith('/') || href === '' ? (
<Link href={href}>
<a>{children}</a>
</Link>
) : (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
export default CustomLink;
HTML内の<a>
は、全てこの<CustomLink>
に置き換える想定です。<CustomLink>
はhref
が/
で始まるか空文字の場合は、サイト内へのリンクと見做して<Link>
を使ったリンクを返し、それ以外の場合は外部サイトへのリンクと見做して、通常の<a>
タグのリンクを返します。こうすることによって、サイト内リンクではない他の外部サイトへの<a>
タグのリンクも<Link>
に置き換わらないようにします。
HTMLテキストをReactへ変換する処理を作成する
最後に、HTMLテキストをReactへ変換する処理を作成します。そのために必要なライブラリをインストールします。
npm i rehype-parse rehype-react
-
rehype-parse
: HTMLをhast
へ変換する。 -
rehype-react
:hast
をReactへ変換する。
HTMLテキストをReactへ変換する処理は以下のような感じになります。
const processor = unified()
.use(rehypeParse, { fragment: true }) // fragmentは必ずtrueにする
.use(rehypeReact, {
createElement: React.createElement,
components: {
a: CustomLink, // ←ここで、<a>を<CustomLink>に置き換えるよう設定
},
});
まず、unified()
に.use()
で最初にチェーンされているrehypeParse
は、HTMLテキストをhast
へ変換しています。
rehypeParse
にはfragment
というオブションがついていて、デフォルトではfalse
となっています。fragment
がfalse
のままだと、変換された結果に<html>
、<head>
、<body>
などの要素が含まれた内容で変換されてしまうので、{fragment:true}
というオブジェクトを一緒に渡して、変換結果にそれらの要素が含まれないようにしています。
その次の.use()
でチェーンされているrehypeReact
で、hast
をReactへ変換しています。
一緒に渡されているオプションオブジェクトの内の、createElement: React.createElement
は必須なので、必ず含めるようにします。
components:
オプションは、指定したHTML要素を任意のReactコンポーネントに置き換えるための設定です。今回は<a>
タグを<CustomLink>
へ置き換えたいので、{ a: CustomLink }
というオブジェクトを渡しています。
このprocessor()
は、ブログ記事のページコンポーネントの中で以下のように使用します。
const Page: NextPage<PageProps> = ({postData}) => (
<>
<h1>{postData.title}</h1>
<article>
{processor.processSync(postData.content).result}
</article>
</>
);
processor.processSync()
に変換したいHTMLテキストを渡し、その結果を.result
で取得することができます。
最終的にブログ記事のページコンポーネントは以下のような感じになると思います。
import React from 'react';
import {
GetStaticPaths,
GetStaticProps,
InferGetStaticPropsType,
NextPage,
} from 'next';
import unified from 'unified';
import rehypeParse from 'rehype-parse';
import rehypeReact from 'rehype-react';
import CustomLink from '../../../components/customLink';
import markdownToHtml from '../../../lib/transpiler';
// CMSから取得されるブログデータの型定義
type StaticProps = {
postData: {
id: string;
title: string;
content: string;
};
};
type PageProps = InferGetStaticPropsType<typeof getStaticProps>;
// HTMLをReactへ変換する関数
const processor = unified()
.use(rehypeParse, { fragment: true }) // fragmentは必ずtrueにする
.use(rehypeReact, {
createElement: React.createElement,
components: {
a: CustomLink, // ←ここで、<a>を<CustomLink>に置き換えるよう設定
},
});
const Page: NextPage<PageProps> = ({postData}) => (
<>
<h1>{postData.title}</h1>
<article>
{processor.processSync(postData.content).result}
</article>
</>
);
export const getStaticPaths: GetStaticPaths = async () => {
const paths = await getAllPostIds();
return {
paths,
fallback: true,
};
};
export const getStaticProps: GetStaticProps<StaticProps> = async (context) => {
const res = await getPostData(context); // CMSからブログデータをフェッチする関数
const contentHTML = await markdownToHtml(res.content); // ブログ本文をHTML化
const content = contentHTML.toString(); // ブログ本文のHTMLをテキスト化
const postData = {...res, content}; // ブログ本文がHTMLテキスト化されたブログデータ
return {
props: {
postData,
},
};
};
export default Page;
上記のコードは簡略化したものですが、私が実際に実装したコードの全体像はGitHubにも上げてありますので、よろしければご覧ください。
rehype-react
が個人的に最適解でした
rehype-react
を導入したことにより、実現したいことは概ね達成できました。
これでNext.jsの機能を活かしつつ、シンプルなMarkdown記法のままで気軽に記事を執筆することができます。rehype-react
の機能を使えば、同じ要領で<img>
タグをNext.jsの<Image>
コンポーネントに置き換える、といったこともできそうです。
MDXは今回採用には至りませんでしたが、技術としてはとても面白いと思っているので、どこか別の機会に使用できたらと考えています。
今回紹介した方法を参考にする際の注意点
最後に、今回紹介した方法を参考にする際の注意点を。
TypeScriptを使用している場合の注意点
Next.jsでTypeScriptを使っている場合、tsconfig.json
のstrict
がtrue
になっていると、rehype-react
のcomponents
オプションに渡しているオブジェクトが型チェックでエラーになります。
調べてみると、tsconfig.json
のstrict
がtrue
になっていると、関数の引数の型チェックがBivariantly
ではなくContravariantly
に行われるそうで、どうやらそこで引っかかっているようです。(strict
をfalse
にするか、tsconfig.jsonに"strictFunctionTypes": false
を設定してやると、エラーはとりあえず消えます)
このあたりは、どうもrehype-react
の型定義に原因があるようなのですが、そもそも私はこのBivariantlyとContravariantlyがなんなのか全然わかっていないので、これを期に勉強してみようと思います…
shiki
を導入する際の注意点
Next.jsに今回紹介した方法で開発されている私のブログですが、現在プレビューモードやISRで、ページがレンダーされずに500エラーとなる不具合が発生しています…
これに関しては、シンタックスハイライターにshiki
を使っていることが原因であるらしいことまでは突き止めたのですが、原因の詳細や解決策は現在調査中です。(普通にSSGでビルドするのは問題ないです。)
解決策が見つかったら、また共有させていただきたいと思います。
2021/04/08追記: エラーの原因と、とりあえずの対応作について書きました。
おわりに
最後まで読んでいただき、ありがとうございました!この記事がお役に立てば幸いです。
Discussion