react-markdownとreact-syntax-highlighterを利用して、コードブロックをカスタマイズする

最終更新日:

Table of contents

react-markdown は、マークダウンをレンダリングするReactコンポーネントを提供するライブラリです。マークダウンの変換方法をカスタマイズしたり、特定の要素を指定して、カスタムコンポーネントを適用できます。react-syntax-highlighter は、Reactでシンタックスハイライトを行えるライブラリです。
このチュートリアルでは、react-markdownとreact-syntax-highlighterを利用して、コードブロックの表示をカスタマイズする方法を紹介します。

記事内で使用している主なソフトウェアのバージョン

  • Next.js(next): 13.5.1
  • react-markdown(react-markdown): 9.0.0
  • react-syntax-highlighter(react-syntax-highlighter): 15.5.0

前提条件

  • Next.jsのプロジェクトを作成済みであること

Next.jsのセットアップについて知りたい場合は、以下のドキュメントをご確認ください。

概要

Newtから返却されるマークダウンテキストの内容をもとに、コードブロックを抜き出して、表示をカスタマイズします。
具体的には、以下について説明します。

  • 言語の指定を行わず、シンタックスハイライトを適用する
  • 言語を指定して、シンタックスハイライトを適用する
  • ファイル名を表示する

1. react-markdownの準備

1-1. react-markdownをインストールする

まずは、react-markdown をインストールします。

## npmを利用している場合
npm install react-markdown

## yarnを利用している場合
yarn add react-markdown

1-2. 記事本文の表示にreact-markdownを利用する

もともと以下のような投稿取得メソッドと、投稿詳細ページがあるとします。
article モデルの body フィールドにはマークダウンフィールドが定義されており、dangerouslySetInnerHTML で直接HTMLデータを扱っているものとします。

lib/newt.ts
1import 'server-only'
2import { createClient } from 'newt-client-js'
3import { cache } from 'react'
4import type { Article } from '@/types/article'
5
6const client = createClient({
7  spaceUid: process.env.NEWT_SPACE_UID + '',
8  token: process.env.NEWT_CDN_API_TOKEN + '',
9  apiType: 'cdn',
10})
11
12export const getArticleBySlug = cache(async (slug: string) => {
13  const article = await client.getFirstContent<Article>({
14    appUid: 'blog',
15    modelUid: 'article',
16    query: {
17      slug,
18      select: ['_id', 'title', 'slug', 'body'],
19    },
20  })
21  return article
22})
app/articles/[slug]/page.tsx
1import { getArticleBySlug } from '@/lib/newt'
2import styles from '@/app/page.module.css'
3
4type Props = {
5  params: {
6    slug: string
7  }
8}
9
10export default async function Article({ params }: Props) {
11  const { slug } = params
12  const article = await getArticleBySlug(slug)
13  if (!article) return
14
15  return (
16    <main className={styles.main}>
17      <h1>{article.title}</h1>
18      <div dangerouslySetInnerHTML={{ __html: article.body }} />
19    </main>
20  )
21}

ここに、まずreact-markdownを追加します。
react-markdownでは、マークダウンテキストを直接扱うため、body の返却形式をHTMLではなく、マークダウンテキストにしましょう。
fmt演算子 を利用します。

lib/newt.ts
export const getArticleBySlug = cache(async (slug: string) => {
  const article = await client.getFirstContent<Article>({
    appUid: 'blog',
    modelUid: 'article',
    query: {
      slug,
      select: ['_id', 'title', 'slug', 'body'],
      body: {
        fmt: 'text',
      },
    },
  })
  return article
})

続いて、dangerouslySetInnerHTML の部分、ReactMarkdown を利用する書き方に変更しましょう。

app/articles/[slug]/page.tsx
import ReactMarkdown from 'react-markdown'
import { getArticleBySlug } from '@/lib/newt'
import styles from '@/app/page.module.css'

(省略)

export default async function Article({ params }: Props) {
  const { slug } = params
  const article = await getArticleBySlug(slug)
  if (!article) return

  return (
    <main className={styles.main}>
      <h1>{article.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: article.body }} />
      <div>
        <ReactMarkdown>{article.body}</ReactMarkdown>
      </div>
    </main>
  )
}

これでreact-markdownを利用できるようになりました。

2. 言語の指定を行わず、シンタックスハイライトを適用する

次に、言語の指定を行わず、自動でシンタックスハイライトを適用してみましょう。

2-1. react-syntax-highlighterをインストールする

まず react-syntax-highlighter をインストールします。型情報もあわせてインストールしましょう。

## npmを利用している場合
npm install react-syntax-highlighter
npm install -D @types/react-syntax-highlighter

## yarnを利用している場合
yarn add react-syntax-highlighter
yarn add -D @types/react-syntax-highlighter

2-2. シンタックスハイライトを行う前の状態

Newtのマークダウンフィールドでは以下のようにコードブロックを定義しているとします。

```
const hello = () => {
  console.log('Hello!')
}
```

すると、返却されるHTMLデータは以下のようになります。

<pre>
  <code>
    const hello = () => {
      console.log('Hello!')
    }
  </code>
</pre>

pre タグの子要素として code タグが入り、その中にコードが記載されます。
※ 詳細は マークダウンエディタ のドキュメントをご確認ください。

この時、フロントエンドでは、以下のようにプレーンな状態で表示されています。
markdown-highlight1.png

2-3. 言語の指定を行わず、シンタックスハイライトを適用する

ReactMarkdown では components オプションを利用して、特定の要素に対してカスタムコンポーネントを割り当てることができます。ここでは pre 要素に対して、カスタムコンポーネントを割り当て、その子供が code 要素である場合に、表示をカスタマイズしましょう。

処理内容の詳細は以下の通りです。

  1. if (childType !== 'code') { のところで、子要素が code であるかチェックしています
  2. const { children: code } = childPropscode 要素内にあるコード情報を取得します
  3. react-syntax-highlighterの SyntaxHighlighter コンポーネントがシンタックスハイライトを適用します
app/articles/[slug]/page.tsx
import ReactMarkdown from 'react-markdown'
import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/default-highlight'
import { getArticleBySlug } from '@/lib/newt'
import styles from '@/app/page.module.css'
import type { ClassAttributes, HTMLAttributes } from 'react'
import type { ExtraProps } from 'react-markdown'

(省略)

export default async function Article({ params }: Props) {
  const { slug } = params
  const article = await getArticleBySlug(slug)
  if (!article) return

  const Pre = ({
    children,
    ...props
  }: ClassAttributes<HTMLPreElement> &
    HTMLAttributes<HTMLPreElement> &
    ExtraProps) => {
    if (!children || typeof children !== 'object') {
      return <code {...props}>{children}</code>
    }
    const childType = 'type' in children ? children.type : ''
    if (childType !== 'code') {
      return <code {...props}>{children}</code>
    }

    const childProps = 'props' in children ? children.props : {}
    const { children: code } = childProps

    return (
      <SyntaxHighlighter>{String(code).replace(/\n$/, '')}</SyntaxHighlighter>
    )
  }

  return (
    <main className={styles.main}>
      <h1>{article.title}</h1>
      <div>
        <ReactMarkdown>{article.body}</ReactMarkdown>
        <ReactMarkdown
          components={{
            pre: Pre,
          }}
        >
          {article.body}
        </ReactMarkdown>
      </div>
    </main>
  )
}

また、ここでは highlight.js のシンタックスハイライトを適用する SyntaxHighlighter を利用していますが、Prism を利用すれば Prism のシンタックスハイライトも適用可能です。
お好きな方をご利用ください。

import SyntaxHighlighter from 'react-syntax-highlighter' でSyntaxHighlighterをインストールした場合、サーバーコンポーネントではエラーが発生するのでご注意ください。
ここでは react-syntax-highlighterのリポジトリにあるコメント を参考に、import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/default-highlight' からインストールすることで、サーバーコンポーネントでSyntaxHighlighterを利用しています。

また、様々なテーマが用意されており、ファイルをインポートすることで、お好きなスタイルを簡単に適用できます。

ここでは、dracula というテーマを利用してみましょう。

app/articles/[slug]/page.tsx
import ReactMarkdown from 'react-markdown'
import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/default-highlight'
import { dracula } from 'react-syntax-highlighter/dist/esm/styles/hljs'
import { getArticleBySlug } from '@/lib/newt'
import styles from '@/app/page.module.css'
import type { ClassAttributes, HTMLAttributes } from 'react'
import type { ExtraProps } from 'react-markdown'

(省略)

    return (
      <SyntaxHighlighter>{String(code).replace(/\n$/, '')}</SyntaxHighlighter>
      <SyntaxHighlighter style={dracula}>
        {String(code).replace(/\n$/, '')}
      </SyntaxHighlighter>
    )

これで、ハイライトが適用され、以下のように表示されることがわかります。
markdown-highlight2.png

3. 言語を指定して、シンタックスハイライトを適用する

次に、言語の指定を行うやり方を説明します。

3-1. コンテンツに言語識別子を追加する

まず、Newtのコンテンツに言語識別子を追加しましょう。
マークダウンフィールドの内容を以下のように変更します。
言語識別子として typescript を入力して、このコードを TypeScript としてシンタックスハイライトします。

※ 利用可能な言語識別子については react-syntax-highlighterでサポートされている言語(highlight.js) をご確認ください。

```
```typescript
const hello = () => {
  console.log('Hello!')
}
```

すると、返却されるHTMLは以下のようになり、code のクラス名が language-typescript となることがわかります。このように、言語識別子を記載すると language-{言語識別子} という名前で、クラスが付与されます。

<pre>
  <code class="language-typescript">
    const hello = () => {
      console.log('Hello!')
    }
  </code>
</pre>

3-2. 言語を指定して、シンタックスハイライトを適用する

フロントエンドでは、渡されたクラス名をもとに、シンタックスハイライトを行いましょう。

処理内容の詳細は以下の通りです。

  1. childProps から className を読み取ります
  2. 先頭の language- を空文字に変換し、language として言語識別子を特定します
  3. SyntaxHighlighterの language に指定された言語識別子を設定します
app/articles/[slug]/page.tsx
(省略)

  const Pre = ({
    children,
    ...props
  }: ClassAttributes<HTMLPreElement> &
    HTMLAttributes<HTMLPreElement> &
    ExtraProps) => {
    if (!children || typeof children !== 'object') {
      return <code {...props}>{children}</code>
    }
    const childType = 'type' in children ? children.type : ''
    if (childType !== 'code') {
      return <code {...props}>{children}</code>
    }

    const childProps = 'props' in children ? children.props : {}
    const { children: code } = childProps
    const { className, children: code } = childProps
    const language = className?.replace('language-', '')

    return (
      <SyntaxHighlighter style={dracula}>
      <SyntaxHighlighter language={language} style={dracula}>
        {String(code).replace(/\n$/, '')}
      </SyntaxHighlighter>
    )
  }

  (省略)

これで、指定した言語でハイライトが適用されるようになりました。
表示も以下のように変わっていることがわかります。

markdown-highlight3.png

4. ファイル名を表示する

最後にファイル名も表示してみましょう。

4-1. コンテンツにファイル名を追加する

Newtでは : を区切り文字として利用することで、言語識別子を正しく認識したまま、返却されるクラス情報に他の情報を追加できます。

```typescript
```typescript:utils/hello.ts
const hello = () => {
  console.log('Hello!')
}
```

これで、返却されるHTMLは以下のようになり、code のクラス名が language-typescript:utils/hello.ts となることがわかります。

<pre>
  <code class="language-typescript:utils/hello.ts">
    const hello = () => {
      console.log('Hello!')
    }
  </code>
</pre>

4-2. ファイル名を表示する

渡されたクラス名から、言語識別子とファイル名の情報をそれぞれ取得しましょう。
そして、ファイル名がある場合はヘッダーを追加して表示することにします。

app/articles/[slug]/page.tsx
(省略)

  const Pre = ({
    children,
    ...props
  }: ClassAttributes<HTMLPreElement> &
    HTMLAttributes<HTMLPreElement> &
    ExtraProps) => {
    if (!children || typeof children !== 'object') {
      return <code {...props}>{children}</code>
    }
    const childType = 'type' in children ? children.type : ''
    if (childType !== 'code') {
      return <code {...props}>{children}</code>
    }

    const childProps = 'props' in children ? children.props : {}
    const { className, children: code } = childProps
    const language = className?.replace('language-', '')
    const classList = className ? className.split(':') : []
    const language = classList[0]?.replace('language-', '')
    const fileName = classList[1]

    return (
      <SyntaxHighlighter language={language} style={dracula}>
        {String(code).replace(/\n$/, '')}
      </SyntaxHighlighter>
      <>
        {fileName && (
          <div>
            <span>{fileName}</span>
          </div>
        )}
        <SyntaxHighlighter language={language} style={dracula}>
          {String(code).replace(/\n$/, '')}
        </SyntaxHighlighter>
      </>
    )
  }

  (省略)

これで、pre 要素の前に、ファイル名の情報を持った div 要素が追加されるようになりました。

あとは、お好みでスタイルを当てていただければ、以下のようにファイル名を表示できます。
markdown-highlight4.png

NewtMade in Newt