cheerioとhighlight.jsを利用して、コードブロックをカスタマイズする

最終更新日:

Table of contents

cheerio は、Node.js上でjQueryライクなAPIでHTMLをパース・操作できるJavaScriptライブラリです。highlight.js は、Node.js上でコードのシンタックスハイライトを行えるライブラリです。
このチュートリアルでは、cheerioとhighlight.jsを利用して、コードブロックの表示をカスタマイズする方法を紹介します。

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

  • Next.js(next): 13.5.1
  • cheerio(cheerio): 1.0.0-rc.12
  • highlight.js(highlight.js): 11.9.0

前提条件

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

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

概要

Newtから返却されるHTMLデータ(マークダウンフィールド)の内容をもとに、コードブロックの表示をカスタマイズします。
具体的には、以下について説明します。

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

1. cheerio・highlight.jsをインストールする

まずは、cheeriohighlight.js をインストールします。

## npmを利用している場合
npm install cheerio highlight.js

## yarnを利用している場合
yarn add cheerio highlight.js

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

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

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

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

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}

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

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

この時、フロントエンドの表示は以下のようになります。
cheerio-highlight1.png

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

ここにシンタックスハイライトを追加します。
まず、マークダウンフィールドから返却されるHTMLデータの形式をおさらいしましょう。コードブロックの場合、以下のような形式で返されます。
※ 詳細は マークダウンエディタ のドキュメントをご確認ください。

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

pre タグの子要素として code タグが入り、その中にコードが記載されます。
cheerioで要素を指定する場合、$('pre code') とすることで、コードブロックの情報を抽出できます。

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

  1. cheerioの load 関数に article.body を渡すことでHTMLのパースを行います
  2. $('pre code').each((_, elm) => { /* 変換処理 */ }) として、コードブロック要素のみを抽出して順次処理を実行します
  3. hljs.highlightAuto($(elm).text()) として、抽出した要素にシンタックスハイライトを適用します
  4. スタイルで必要となる hljs クラスを、code要素に付与します
  5. $.html()を実行して、パースしたDOMノードを再びHTMLへと変換して article.body に再代入します
app/articles/[slug]/page.tsx
import { load } from 'cheerio'
import hljs from 'highlight.js'
import { getArticleBySlug } from '@/lib/newt'
import styles from '@/app/page.module.css'

type Props = {
  params: {
    slug: string
  }
}

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

  const $ = load(article.body)
  $('pre code').each((_, elm) => {
    const result = hljs.highlightAuto($(elm).text())
    $(elm).html(result.value)
    $(elm).addClass('hljs')
  })
  article.body = $.html()

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

また、highlight.jsでは様々なテーマが用意されており、cssファイルをインポートすることで、お好きなスタイルを簡単に適用できます。
highlight.jsの Demo を見ると、すべてのテーマを確認することができます。

ここでは、github-dark.css というテーマを利用してみましょう。

app/articles/[slug]/page.tsx
import { load } from 'cheerio'
import hljs from 'highlight.js'
import { getArticleBySlug } from '@/lib/newt'
import styles from '@/app/page.module.css'
import 'highlight.js/styles/github-dark.css'

(省略)

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

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

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

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

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

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

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

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

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

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

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

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

  1. $(elm).attr('class')code 要素に付与されているクラスを読み取ります
  2. 先頭の language- を空文字に変換し、language として言語識別子を特定します
  3. hljs.highlight($(elm).text(), { language }) で指定された言語識別子でハイライトを行います
  4. もし、言語が指定されていなかったり、ハイライト時にエラーが発生した場合は highlightAuto を使います
app/articles/[slug]/page.tsx
(省略)

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

  const $ = load(article.body)
  $('pre code').each((_, elm) => {
    const result = hljs.highlightAuto($(elm).text())
    const className = $(elm).attr('class')
    const language = className?.replace('language-', '')

    let result
    if (language) {
      try {
        result = hljs.highlight($(elm).text(), { language })
      } catch {
        result = hljs.highlightAuto($(elm).text())
      }
    } else {
      result = hljs.highlightAuto($(elm).text())
    }
    $(elm).html(result.value)
    $(elm).addClass('hljs')
  })
  article.body = $.html()

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

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

cheerio-highlight3.png

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

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

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

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

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

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

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

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

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

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

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

  const $ = load(article.body)
  $('pre code').each((_, elm) => {
    const className = $(elm).attr('class')
    const language = className?.replace('language-', '')
    const classList = className ? className.split(':') : []
    const language = classList[0]?.replace('language-', '')
    const fileName = classList[1]

    let result
    if (language) {
      try {
        result = hljs.highlight($(elm).text(), { language })
      } catch {
        result = hljs.highlightAuto($(elm).text())
      }
    } else {
      result = hljs.highlightAuto($(elm).text())
    }
    $(elm).html(result.value)
    $(elm).addClass('hljs')

    if (fileName) {
      $(elm).parent().before(`<div><span>${fileName}</span></div>`)
    }
  })
  article.body = $.html()

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

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

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

NewtMade in Newt