Next.js logo
NextJSでMarkDownを独自コンポーネントにマッピングしていい感じに表示する
投稿日: 2023年7月24日

ヘッドレスCMSなどでコンテンツをMarkDownで管理している場合、MarkDownをHTMLに変換する必要があります。変換を行うライブラリは数多くありますが、今回は本ブログでも利用している変換ライブラリであるreact-markdownを紹介したいと思います。

基本的な使い方

🔗

react-markdownはその名の通りReactコンポーネントとして提供されていて、

1<ReactMarkdown>{ここにmarkdownをstringで置く}</ReactMarkdown>

といった感じでコンポーネントのchildrenとしてMarkDown文字列を与えてあげると対応するHTMLタグに置き換えてくれます。

たとえば、下記のようなMarkDownの文書を変換すると、

1# 今日の天気
2
3今日は*晴れ*です。
4

下記のようなHTLMに変換されます。

1<h1>今日の天気</h1>
2
3<p>今日の天気は<em>晴れ</em>です。</p>

プラグインの導入

🔗

react-markdownの便利なところはプラグインが簡単に導入できて柔軟にカスタマイズ可能な点です。react-markdownは内部的にはremarkというライブラリによってマークダウンをHTMLに変換する処理を行っており、remarkで提供されている豊富なプラグインをそのまま利用することができます。

今回は例としてよく使われるプラグインである、remark-gfmを導入してみたいと思います。remark-gfmはGithub Flavored Markdownというマークダウンの拡張記法?に対応させるためのプラグインで、これを導入することによってテーブルや打ち消し線をHTMLに変換することができるようになります。

プラグインのインストール

プラグインは基本的にnpmでインストールが可能です。remark-gfmもnpmによってインストールが可能なので、npmやyarnでインストールを行います。

1npm install remark-gfm

プラグインの適用

プラグインは下記のようにReactMarkdownコンポーネントのプロパティに設定を行うことで適用できます。 

src/components/markdown.tsx
1import remarkGfm from "remark-gfm";
2
3export const Markdown = ({ children }: { children: string }) => {
4  return (
5    <ReactMarkdown
6      remarkPlugins={[remarkGfm]}
7    >
8      {children}
9    </ReactMarkdown>
10  );
11};

カスタムコンポーネントの適用

🔗

ここまででも十分便利なのですが、変換先を標準タグでなくカスタムコンポーネントにしたいケースもいくつか発生することもあると思います。例えば、nextjsを利用しているとリンクにはaタグでなくnext/linkのLinkコンポーネントを使いたくなったり、コードブロックをライブラリを利用してシンタックスハイライトを行いたくなったり、などなど。

こういったケースに対応するため、react-markdownではタグとコンポーネントの対応関係を指定できるようになっています。使い方は非常にシンプルで、

src/components/markdown.tsx
1import remarkGfm from "remark-gfm";
2
3export const Markdown = ({ children }: { children: string }) => {
4  return (
5    <ReactMarkdown
6      components={{
7        h1: ({children}) => (<div>{children}</div>),
8      remarkPlugins={[remarkGfm]}
9    >
10      {children}
11    </ReactMarkdown>
12  );
13};

のように、カスタムコンポーネントを適用したいタグと適用したいカスタムコンポーネントをkey-valueで指定するだけでOKです。

以下で、いくつか具体的な適用例をご紹介したいと思います。

aタグをnext/linkのLinkコンポーネントに変換

NextJSではLinkというコンポーネントが提供されており、リンクがviewpointに入ったタイミングで事前にリンク先を読み込んでおくことでパフォーマンスを最適化するprefetchが実現されています。このprefetch機能を活用するために、aタグをカスタムコンポーネントAに切り替えてみます。

src/components/a.tsx
1import Link from "next/link";
2
3type AProps = {
4  children: React.ReactNode;
5  href?: string;
6};
7
8export const A = ({ href, children }: AProps) => {
9  if (!href) {
10    return <></>;
11  }
12  // パスが/か#から始まっている場合は自サイト内のリンクと判断してnext/linkを使う
13  if (href.startsWith("/") || href.startsWith("#")) {
14    return (
15      <Link className="link-font-color" href={href}>
16        {children}
17      </Link>
18    );
19    // 他サイトへのリンクの場合はaタグを使う
20  } else {
21    return (
22      <a
23        className="link-font-color"
24        href={href}
25        {// 別タブで開く}
26        target="_blank"
27        rel="noopener noreferrer"
28      >
29        {children}
30      </a>
31    );
32  }
33};

上記のカスタムコンポーネントでは、サイト内での遷移ではLinkコンポーネントを利用することでパフォーマンスを最適化しつつ、外部サイトへの遷移は別タブで開くようにしています。
このカスタムコンポーネントは先ほどと同様に以下のように適用できます。

src/components/markdown.tsx
1import remarkGfm from "remark-gfm";
2import { A } from "@/components/a"; 
3
4export const Markdown = ({ children }: { children: string }) => {
5  return (
6    <ReactMarkdown
7      components={{
8        a: A,
9      remarkPlugins={[remarkGfm]}
10    >
11      {children}
12    </ReactMarkdown>
13  );
14};

コードブロックをシンタックスハイライトする

remark-gfmを導入していると、下記のようにバッククオートで囲われた範囲

1```
2code here
3```

1<pre>
2  <code>code here</code>
3</pre>

といった具合にcodeタグとpreタグに変換してくれます。この変換先もこれまでと同様にカスタムコンポーネントにできますが、コードブロックの場合はバッククオート3つで囲む場合とバッククオート1つで囲むインラインスタイルの2つの形式が同じコンポーネントにマッピングされるようになるので少し注意が必要です。

react-markdownのREADME.mdによると、

The props that are passed are what you probably would expect: an a (link) will get href (and title) props, and img (image) an src, alt and title, etc. There are some extra props passed.

code
inline (boolean?) — set to true for inline code
className (string?) — set to language-js or so when using ```js

と記載されており、childrenだけでなく、inlineclassNameというプロパティもカスタムコンポーネントに渡されることがわかります。

今回はコードブロックのカスタムコンポーネントの適用例として

  • インラインスタイルの場合はインラインテキストとして表示する
  • インラインスタイルでない場合はコードブロックにシンタックスハイライトを効かせる
  • ファイル名が定義されている場合はそれも表示する

ようなコンポーネントを作成してみたいと思います。MarkDownではコードブロックにファイル名を付与するようなフォーマットは標準では定義されておらず、remark-gfmでもファイル名を付与することはできないのですが、今回はコードブロックの最初に記載する言語名(コンポーネントにはclassNameとしてlanguage-{言語名}として渡される)の部分に無理やりファイル名まで記載してしまうオレオレ記法を採用し、コンポーネントの内部でclassNameを解析してファイル名を取得するような方式でいきたいと思います。

次にシンタックスハイライトの実現方法です。シンタックスハイライトを行うライブラリはいくつかありますが、今回はNext.jsで簡単に利用可能なreact-syntax-highlighterを利用したいと思います。

インストールは下記のコマンドで行います。

1npm install react-syntax-highlighter

インストール後はSyntaxHighlighterコンポーネントが利用可能になります。SyntaxHighlighterを利用して、今回は下記のようなコンポーネントを作成してみました。(tailwindを使用しています)

src/components/code.tsx
1"use client";
2
3import { CodeComponent } from "react-markdown/lib/ast-to-react";
4import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
5import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
6
7export const Code: CodeComponent = ({ children, inline, className }) => {
8  const language = className?.replace("language-", "").split(":")[0];
9  const filename = className?.replace("language-", "").split(":")[1];
10  if (inline) {
11    return (
12      <code className="mx-1 rounded-sm bg-gray-500 px-1 py-0.5 font-mono text-sm text-orange-400">
13        {children}
14      </code>
15    );
16  } else {
17    return (
18      <div className="my-4">
19        {filename && (
20          <div className="w-fit rounded-t bg-gray-600 px-2 py-1 font-mono text-xs text-white">
21            {filename}
22          </div>
23        )}
24        <SyntaxHighlighter
25          language={language}
26          style={vscDarkPlus}
27          showLineNumbers={true}
28          wrapLines={true}
29          customStyle={{
30            marginTop: "0",
31          }}
32        >
33          {String(children).replace(/\n$/, "")}
34        </SyntaxHighlighter>
35      </div>
36    );
37  }
38};

classNamelanguage-{言語名}:{ファイル名}という形式になっているので、それを変換してJSXを組み立てています。上のコードブロックもこの、tsx:src/components/code.tsxという記載をコードブロック冒頭に付与しています。

このように、プラグインを作らずともコンポーネントを適用することである程度柔軟な変換が実現できるところもreact-markdownの便利なところかなと思います。