2021/09/03

【Next.js】cheerioを使って目次を作成

@ 古曳 純基

Next.js

本ブログサイト(UnBlog)の目次にバグがあったので、先日修正しました。
修正にあたり、今後のためにも情報を残しておきたいと思ったので記事にしました。
cheerioを使った目次の作成について以下の記事を主に参考にしました。是非みてみてください。
https://kawa.dev/blogs/pnjad7zql
https://blog.microcms.io/create-table-of-contents/

cheerio(チェリオ)とは?

cheerioは、サーバー専用に設計されたjQeryがコアとなっているライブラリのことです。
cheerioのドキュメントらしきものが存在していたのでこちらを参考に説明していきます。
https://cheerio.js.org/

Cheerio is not a web browser
Cheerio parses markup and provides an API for traversing/manipulating the resulting data structure. It does not interpret the result as a web browser does. Specifically, it does not produce a visual rendering, apply CSS, load external resources, or execute JavaScript. This makes Cheerio much, much faster than other solutions. If your use case requires any of this functionality, you should consider projects like Puppeteer or JSDom.

(和訳)

CheerioはWebブラウザではありません
Cheerioは、マークアップを解析し、結果として得られるデータ構造を閲覧/操作するためのAPIを提供します。Webブラウザのように結果を解釈することはありません。具体的には、視覚的なレンダリングを行ったり、CSSを適用したり、外部リソースを読み込んだり、JavaScriptを実行したりすることはありません。このため、Cherioは他のソリューションよりもはるかに高速です。もしあなたのユースケースがこのような機能を必要とするなら、PuppeteerやJSDomのようなプロジェクトを検討すべきだ。

こちらの説明からもわかるように、cheerioはマークアップの解析を行なって得られるデータを閲覧・操作することが可能です。

目次生成やシンタックスハイライト(<pre>要素の中に書いたテキストを装飾すること)のような処理はクライアントサイドで行うのではなく、サーバーサイドで行っておくことによって応答速度が向上します。
cheerioのようにサーバーサイドでDOM操作ができるライブラリは、目次生成に重宝します。

cheerioの基本的な使い方

cheerioは、jQueryがコアとなるライブラリであるため、「$」記号をたくさん使います。
const $ = cheerio.load(body:string)によって、string型のbodyをいい感じにHTML Elementにパースしてくれます。
そして、$("h2,h3")のように書くことで先程のbodyからパースされたHTML Elementから指定されたDOM要素を抽出してくれます。
以下にcheerioの基本的なコードを示します。

//ライブラリのインポート
import cheerio from 'cheerio'

//文字列型のマークアップデータをHTML Elementに変換する(パース)
const $ = cheerio.load('<h1>Hoge</h1>');

//HTML Elementの操作
$("h1").text("Fuga"); //テキストノードの操作
$("h1").addClass("test"); //要素ノードに対して属性を追加

//HTML要素を文字列型に変換する
const result = $.html();
const result2 = $("body").html();

//コンソールに出力
console.log(result);
console.log(result2);

//コンソールの出力結果
//<html><head></head><body><h1 class="test">Fuga</h1></body></html>
//<h1 class="test">Fuga</h1>

雰囲気で良いですが使い方を理解いただけたでしょうか?
$.html();とすると、<html>要素まで遡って生成されてしまうため、$("body").html();とすることでそれを防いでいます。
また、cheerioのドキュメントによるとcheerio.loadの第3引数にfalseを指定することによって<html>要素まで遡って生成されてしまうのを防ぐことができると説明されています。

const $ = cheerio.load('<ul id="fruits">...</ul>', null, false);
$.html();


以下のサイトの「使い方」というセクションにおいて、他にも様々な操作方法を紹介されているので参考にしてみてください。
https://morizyun.github.io/javascript/node-js-npm-library-cheerio.html

目次作成のロジック

基本的な使い方を説明したところで、次に実際に目次ってどうやって作るんやろうってことを説明していきたいと思います。

UnBlogの場合、[id].tsxのgetStaticPropsの中でブログ詳細ページの情報をmicro-cmsのAPIからフェッチしてきます。
(Next.jsでは、データのフェッチのような時間のかかる処理は、バックエンド環境のgetStatcProps内で行います)
この場所でブログ詳細ページを構成するマークアップデータを加工すれば目次が作成できます。
UnBlogの実装では、h2,h3をブログ詳細ページの小見出しとして使います。
そのため、これらの要素をmicro-cmsから取得したマークアップデータから抽出して<li>要素にまとめて、目次コンポーネントのpropsとして渡すことで目次を作成しています。

目次作成1

以下のサイトを参考にして、実際に書いてみましょう。
https://kawa.dev/blogs/pnjad7zql

//目次作成関数
const generateTableOfContent = (body: HTMLElement) => {
  const $ = cheerio.load(`${body}`,{decodeEntities: false})
  let tableOfContent = ''
  tableOfContent = tableOfContent + '<ul>'

  //bodyからh2,h3の要素を抽出
  $("h2, h3").each((index,elem) => {
//
    const text = $(elem).html()
    const tag = $(elem)[0].name
    const refId = $(elem)[0].attribs.id
    
    tableOfContent = tableOfContent + 
    `<li class="toc_${tag}" key=${refId}>` +
      `<a href="#${refId}"><span>#</span>${text}</a>`+
    '</li>'
  })

  tableOfContent = tableOfContent + '</ul>'
  return tableOfContent
}

[id].tsxのgetStaticPropsの中

//getStaticPathsの中のパスの中でユーザーが選んだページコンポーネントのidが渡ってくる
export const getStaticProps: GetStaticProps = async (ctx) => {
//バックエンド環境
	  const id = ctx.params.id as string

	  //ブログデータを取得(上記のパスを取得)
	  const blog: Blog = await client.get({ endpoint: "blog", contentId: id })
	  //バックグランドでの目次生成(ここで上で定義した関数を実行する)
	  const toc = generateTableOfContent(blog.body)

	 //ページコンポーネントに渡す(今回の場合、ブログ詳細ページコンポーネント)
	  return {
	    props: {
	      blog,
	      toc,
	    },
	  }
	}

[id].tsxのページコンポーネントに渡す

const BlogPage: React.FC<BlogProps> = ({ blog, toc }) => {
  return (
    <>
     {/*divに対してDOM要素を挿入する*/}
      <div
          id="blog_toc"
          dangerouslySetInnerHTML={{
            __html: toc,
          }}
      />
    ...............
    </>
  )
}
export default BlogPage

以上で目次ができたと思います。

目次作成2

先程の方法とは少し違う方法で、目次を作成してみたいと思います。
以下のサイトを参考にしました。
https://blog.microcms.io/create-table-of-contents/

//[id].tsxの中のgetStaticPropsによるフェッチ
export const getStaticProps: GetStaticProps = async (ctx) => {
//バックエンド環境
  const id = ctx.params.id as string
  //ブログデータを取得(上記のパスを取得)
  const blog: Blog = await client.get({ endpoint: "blog", contentId: id })

  //バックグランドでの目次生成
  const $ = cheerio.load(`${blog.body}`);
  //抽出した要素を配列に直す
  const headings = $('h2, h3').toArray();
 //抽出した要素から必要なデータを取り出す
  const toc = headings.map(data => ({
    text: $(data).text(),//テキストコンテントの中身
    id: data.attribs.id,//a要素のhref属性に渡せば、参照を持てる
    name: data.name
  }));

  return {
    props: {
      blog,
      toc,
    },
  }
}

id.tsxのページコンポーネントの実装

const BlogPage: React.FC<BlogProps> = ({ blog, toc }) => {
  return (
    <>
      <div>
                  {toc.length ? (
                    <ul>
                      {toc.map((t)=>(
                        <li className={t.name} id={t.id}>
                          {t.text}
                        </li>
                      ))}
                    </ul>
                  ) : (
                    ""
                  )}
       </div>
       .............
    </>
  )
}

export default BlogPage

こちらの方法でも目次らしきものが作成できました。

まとめ

cheerioを使って、Next.jsの中で目次を作成することができました。
以下の記事によると、jsdomというライブラリを使って目次の生成をすることも可能なようです。
https://zenn.dev/d_suke/articles/5bd7b2da4de87c22368e
ここまで読んでくださった方、お疲れ様でした。

© 2021 powerd by UnReact