今回は、Next.jsで作られているこのブログにオリジナルの目次を追加しようと思いました。Wordpressだとプラグイン一発なのですが、Next.jsプロジェクトの目次ってどう作ればいいんだろう?というところからですね😅

本記事について

必要な技術

こちらをプロジェクトに採用しているのを前提としています。

記事のゴール

ブログに目次(Table of Contents = Toc)を追加する

要件定義

  .mdx記事の、見出し(h1,h2,h3...h6)が階層的に目次になるように作成する

  作成した目次をクリックすると、見出しに遷移する。アドレスバーに表示されるのも~#見出し名となる。

  折り返しは考慮しない

設計

目次の生成は、

  1. .mdxファイルの見出しを、入れ子構造のオブジェクトに格納し
  2. 機械的に<ul>および<li>タグで生成

という流れで処理することになります。この類の機能の作成を行った人であれば想像がつくはず。問題はページ内リンクの実装だが、結論から言うとMDX Providerにより、記事内のh1~h6を一律で書き換えることで対応できます。

設計のポイント

  Tocコンポーネントを作成する。

  remark-tocを使って各ブログページのマークダウン部をtypescriptのオブジェクトにする。

  Tocコンポーネント内でオブジェクトを使って目次を組み立てる

実装

目次コンポーネントと必要な処理を作る

Toc.tsxを作る

今回はNext13プロジェクトのappレイアウトlib/components以下にToc.tsxと言う名称でクラスを作成しました。各ページ共通で呼び出されるlayout.tsxで、.mdxから変換されたhtmlをこのコンポーネントに渡すことで、目次を出力します。

lib/components/Toc.tsx
import { Configs } from "lib/utils";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";

type Prop = {
  body: string
};

// 入れ子の目次オブジェクトを型定義する
type TocData = {
  [key: string]: TocData
};

const Toc: React.FC<Prop> = ({ body }) => {
  const [tocObject, setTocObject] = useState<{ [key: string]: any }>({});

  const showNumberString = false // trueにすると、章立ての番号が表示される
  useEffect(() => {
    const parser = new DOMParser();
    const doc = parser.parseFromString(body, "text/html");

    const headings = doc.querySelectorAll("h1, h2, h3, h4, h5, h6");

    // このstackで階層構造を管理する。
    const stack: any[] = [];
    let tocData: TocData = {};

    headings.forEach((heading) => {
      const level = parseInt(heading.tagName.charAt(1), 10);
      const text = heading.textContent || "";

      while (stack.length && stack[stack.length - 1].level >= level) {
        stack.pop();
      }

      let parent = tocData;

      stack.forEach(item => {
        parent = parent[item.text];
      });

      parent[text] = {};

      stack.push({ text: text, level: level });
    });

    setTocObject(tocData);
  }, [body]);

  const renderToc = (
    data: { [key: string]: any },
    parentNumbers: number[] = []
  ) => {
    return (
      <ul className="toc-list px-0 mx-3 my-2 list-none">
        {Object.keys(data).map((key, index) => {
          const currentNumbers = [...parentNumbers, index + 1];
          const numberString = currentNumbers.join("-");

          return (
            <li key={key} className="p-0 m-0 text-xs">
              <Link href={`#${key.replace(/\s+/g, "-").toLowerCase()}`} className="text-black dark:text-white">
                {showNumberString && numberString + ". "}{key}
              </Link>
              {renderToc(data[key], currentNumbers)}
            </li>
          )
        })}
      </ul>
    );
  };

  // 以下taildinw

  return (
    <div className={`toc xl:fixed xl:pl-8 xl:top-16 hidden xl:block `}>
      <div className="toc-box p-2 rounded-xl break-words">
        <div className="rounded-xl shadow-2xl bg-white dark:bg-black px-4 py-2">
          <p className="pt-0 font-bold">目次</p>
          {renderToc(tocObject)}
        </div>
      </div>
      <style jsx>{`
        .toc-box {
          background: linear-gradient(0deg, rgb(195,34,175,1) 0%, rgba(253,187,45,1) 100%)
        }
        .toc {
          margin-left: ${Configs.layouts.pageWidth};
        }
        .toc-list li {
          padding: 0px;
        }
      `}</style>
    </div>
  );
};

export default Toc;
layout.tsxにToc.tsxの呼び出し処理を追加する

記事ページ共通で呼ばれるコンポーネントに、Tocタグを追加します。その際に、.mdxページからReact.ReactNode型のchildrenが渡されてきますが、これを stringに置換した上で、Tocに渡します。

あなたのご利用中のlayout.tsxに合わせて、実装してください

app/layout.tsx
// ... importは省略

import Toc from 'lib/components/Toc'
import { renderToString } from 'react-dom/server';

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {

  let childrenHtml = '';
  if (React.isValidElement(children)) {
    // React.ReactNodeをhtmlに置換
    childrenHtml = renderToString(children);
  }

 // 以下の<html><Navbar>などのタグはデフォルトのものそのままです。
  // Tocを呼び出す箇所はコンテンツに合わせてどうぞ
  return (
    <html lang="en">
      <body>
        <Navbar />
          <main className="">{children}</main>
          <Toc body={childrenHtml} />
        <Footer />
      </body>
    </html>
  )
}

mdx-component.tsxを設置し、h1~h6のid属性を埋め込むように変更

プロジェクトのルート直下にmdx-component.tsxを設置し、アンカーリンクでの移動のため必要な属性を追加してくださいませ。

mdx-component.tsx
import type { MDXComponents } from 'mdx/types';

export function useMDXComponents(components: MDXComponents): MDXComponents {

  const CustomH1: React.FC<{ children: React.ReactNode }> = ({ children }) => (
    <h1 id={(children as string).toLowerCase().replace(/\s+/g, '-')}>{children}</h1>
  );
  const CustomH2: React.FC<{ children: React.ReactNode }> = ({ children }) => (
    <h2 id={(children as string).toLowerCase().replace(/\s+/g, '-')}>{children}</h2>
  );
  const CustomH3: React.FC<{ children: React.ReactNode }> = ({ children }) => (
    <h3 id={(children as string).toLowerCase().replace(/\s+/g, '-')}>{children}</h3>
  );
  const CustomH4: React.FC<{ children: React.ReactNode }> = ({ children }) => (
    <h4 id={(children as string).toLowerCase().replace(/\s+/g, '-')}>{children}</h4>
  );
  const CustomH5: React.FC<{ children: React.ReactNode }> = ({ children }) => (
    <h5 id={(children as string).toLowerCase().replace(/\s+/g, '-')}>{children}</h5>
  );
  const CustomH6: React.FC<{ children: React.ReactNode }> = ({ children }) => (
    <h6 id={(children as string).toLowerCase().replace(/\s+/g, '-')}>{children}</h6>
  );

  return {
    // 他に処理があればここに追加
    h1: CustomH1,
    h2: CustomH2,
    h3: CustomH3,
    h4: CustomH4,
    h5: CustomH5,
    h6: CustomH6
  }
}

動作確認(テスト)

ローカルでyarn run devでプロジェクトを起動し、記事ページで目次が動いていることを確認します。

page-anchor-link.gid

(ちなみに、モバイルでは消すようにしています。https://dev.classmethod.jp/さんなども目次を消すようにしているようですので)

参考文献

Markdown All in One

【vscode】Markdownにおける目次(TOC)の作成に、Markdown All in Oneが便利だった件

VSCodeにはプラグインで、.mdファイルを編集するだけで自動で目次を生成してくれるものがあるので、これを使えたら瞬殺だな、と思ったのですが残念😢

MDX Providerを使ったタグの書き換え

MDX Provider

ページリンク部分は、この方法を使って書き換えられそうなことがわかりました。ありがとうございました。

Tocの先行実績

Table of Contents for MDX with Next.js

こちらの方のブログ参考になりました。おかげで、独自実装もアリだなと思えました。ありがとうございました。

まとめ・感想

実装自体は、なかなか楽しかったですがかゆいところに手が届く目次を作るためには、さらなる車輪の再発明をする感が漂ってきますね...。ただ、Webエンジニアの人にはWordpressよりは絶対に自分のイメージする形に実装しやすいと思うので、フロントエンドの実装力がある人や、Next.jsを今後も使い続けたい!という方ならこの方法は大いにアリかと思った次第でした。


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