👾

Markdownのサイト内リンクをNext.jsの<Link>にしたい

2021/04/04に公開

先日、Next.js+microCMS+VercelのJAMStack構成で、自分のブログを作成しました。

https://takahira.io/blog

ブログ記事のデータは、microCMSからMarkdown形式で入稿するようにしているのですが、記事の中でブログ内の別の記事へのリンクを貼る時に、Next.jsの<Link>を使えたら便利だな、と思いました。

この記事ではそれをどうすれば実現できるのか、あれこれ試行錯誤した経緯を含めてご紹介したいと思います。

まずはMDXを使ってみた

まず最初に考えたのはMDXを使う方法でした。MDXとは、簡単に説明するとJSXをそのまま埋め込めるMarkdown記法です。

https://mdxjs.com/

↓以下は公式サイトからの例ですが、こんな感じで.mdx拡張子のファイルに記述できます。Markdownの記法で文章を書きつつ、別ファイルからエクスポートされているReactコンポーネントをインポートし、それをそのまま使うこともできます。

sample.mdx

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の公式ブログでも紹介されているので、その辺りを参考にしました。

https://nextjs.org/blog/markdown

そして色々と試行錯誤の末、自分のブログにMDXを導入することに成功しました…が、

結局、MDXは使わないことにした

実装を終えて、しばらく記事の書き心地を試してみたのですが、やっぱりMDXを使うのはやめることにしました。理由は以下の通りです。

1. MDXはヘッドレスCMSでは扱いづらい

率直に言うと、サイトのコンテンツをMDXで書くなら、ヘッドレスCMSなどの外部のデータストアを使うより、プロジェクト内の任意のディレクトリ内に直接.mdxファイルを置いて管理する方が、一番シンプルで楽だと思います。

特にNext.jsの場合は、@next/mdxというライブラリを導入すれば、Next.jsプロジェクト内のpages/ディレクトリに.mdxファイルを置いて、それをそのままサイトのページとして扱うことができます。

有名なところだと、Tailwind CSSの公式ブログがこのスタイルで構築されています。ブログの開発方法について紹介している記事もあるので、Next.jsで.mdxファイルを扱いたい場合は、参考にしてみると良いと思います。

https://blog.tailwindcss.com/building-the-tailwind-blog

しかし、今回の私のブログの場合は、ブログのコンテンツは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>に置き換えるようにすればいいのでは?と考え始めました。

そんな時に見つけたのが、以下の記事でした。

https://dev.to/jameswallis/how-to-use-the-remark-markdown-converters-with-next-js-projects-a8a

こちらの記事で紹介されているのは、remark-reactというunifiedのプラグインをNext.jsに導入して、Markdownで書かれたコンテンツをReactに変換する方法です。ついでにMarkdownのサイト内リンクを<Link>に置き換える方法も紹介されていました。まさに自分がやりたいと思っていたことでした。

unifiedとは

unifiedは、(大雑把に説明すると)MarkdownやHTMLなどのフォーマットで書かれたテキストを解析したり変換したりするJavaScriptライブラリをまとめたプロジェクトです。Markdownを処理するremarkや、HTMLを処理するrehypeなども、このunifiedプロジェクトに属しています。

https://unifiedjs.com/

unifiedの特徴は、MarkdownやHTMLで書かれたテキストを、抽象構文木(AST)に変換できる点です。これによりMarkdownのテキストをHTMLテキストに変えたりといった、別フォーマットへの変換を柔軟に行うことができます。

さらにunifiedのライブラリは用途に応じて様々なプラグインが用意されており、それらの中から必要なものを選択し、組み合わせて使用することができます。

rehype-reactでサイト内リンクを<Link>にする

先ほどの記事の中では、remark-reactを使ってMarkdownからReactへ変換し、その際にサイト内リンクを<Link>にする方法が紹介されていますが、remark-reactはTypeScriptに未対応である上、私の場合はrehypeのプラグインを使ってブログ記事内のコードブロックをハイライトしたかったので、remark-reactと似たプラグインであるrehype-reactを使うことにしました。

remark-reactMarkdownからReactへ変換するのに対し、rehype-reactHTMLから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: mdasthast(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テキストへ変換する関数を実装します。

/lib/transpiler.ts
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用に書き直してはいますが)

/components/customLink/index.tsx
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となっています。fragmentfalseのままだと、変換された結果に<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で取得することができます。

最終的にブログ記事のページコンポーネントは以下のような感じになると思います。

/pages/blog/[id]/index.tsx
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にも上げてありますので、よろしければご覧ください。

https://github.com/THiragi/takahira

rehype-reactが個人的に最適解でした

rehype-reactを導入したことにより、実現したいことは概ね達成できました。

これでNext.jsの機能を活かしつつ、シンプルなMarkdown記法のままで気軽に記事を執筆することができます。rehype-reactの機能を使えば、同じ要領で<img>タグをNext.jsの<Image>コンポーネントに置き換える、といったこともできそうです。

MDXは今回採用には至りませんでしたが、技術としてはとても面白いと思っているので、どこか別の機会に使用できたらと考えています。

今回紹介した方法を参考にする際の注意点

最後に、今回紹介した方法を参考にする際の注意点を。

TypeScriptを使用している場合の注意点

Next.jsでTypeScriptを使っている場合、tsconfig.jsonstricttrueになっていると、rehype-reactcomponentsオプションに渡しているオブジェクトが型チェックでエラーになります

調べてみると、tsconfig.jsonstricttrueになっていると、関数の引数の型チェックがBivariantlyではなくContravariantlyに行われるそうで、どうやらそこで引っかかっているようです。(strictfalseにするか、tsconfig.jsonに"strictFunctionTypes": falseを設定してやると、エラーはとりあえず消えます)

https://qiita.com/ryokkkke/items/390647a7c26933940470#strictfunctiontypes

このあたりは、どうもrehype-reactの型定義に原因があるようなのですが、そもそも私はこのBivariantlyとContravariantlyがなんなのか全然わかっていないので、これを期に勉強してみようと思います…

Next.jsにshikiを導入する際の注意点

今回紹介した方法で開発されている私のブログですが、現在プレビューモードやISRで、ページがレンダーされずに500エラーとなる不具合が発生しています…

これに関しては、シンタックスハイライターにshikiを使っていることが原因であるらしいことまでは突き止めたのですが、原因の詳細や解決策は現在調査中です。(普通にSSGでビルドするのは問題ないです。)

解決策が見つかったら、また共有させていただきたいと思います。
2021/04/08追記: エラーの原因と、とりあえずの対応作について書きました。

おわりに

最後まで読んでいただき、ありがとうございました!この記事がお役に立てば幸いです。

Discussion