😸

個人ブログ(powered by Notion API)に目次機能をつける

2024/05/13に公開

前回の記事で書ききれなかった、目次機能の実装について書きます。
この個人ブログです。

やること

最終的なアウトプットはこんな感じです。

noteの目次レイアウトをちょっと意識しています。

実装方針

実装方針をざっくり書くとこんな感じです。

  • Notion APIから受け取ったブロック配列を検査して、h1, h2, h3要素だけを取得して目次要素とする
  • 目次要素をレンダリング処理に渡す
    • 1記事内に複数存在する可能性のあるOGPデータと違って、目次は記事に対して1つ
    • なので使うか使わないか気にせず、とりあえずpropsに渡してます
  • table_of_contents ブロックがあったら、目次要素をレンダリングする
    • HTMLにnavタグというものがあるそうで、それを返すようにしました。

目次のHTML構造が想像できなかったんですが、こんな形式になるようです。

<nav>
  <h2>目次</h2>
  <ol>
    <li><a href="#section1">セクション1</a></li>
    <li>
      <a href="#section2">セクション2</a>
      <ol>
        <li><a href="#section2-1">セクション2-1</a></li>
        <li><a href="#section2-2">セクション2-2</a></li>
      </ol>
    </li>
    <li>
      <a href="#section3">セクション3</a>
      <ol>
        <li><a href="#section3-1">セクション3-1</a></li>
        <li>
          <a href="#section3-2">セクション3-2</a>
          <ol>
            <li><a href="#section3-2-1">セクション3-2-1</a></li>
            <li><a href="#section3-2-2">セクション3-2-2</a></li>
          </ol>
        </li>
      </ol>
    </li>
  </ol>
</nav>

navタグ内の中にリスト要素を持たせる感じですね。
リストのネストによって、h1/h2/h3要素を表現することができます。

目次のアンカーリンク

当初アンカーリンクは大変そうだったので、とりあえず文字列で目次が出てれば十分かと考えていたんですが、
思ったよりも簡単に実装できそうだったので対応することにしました。

<li><a href="#section1">セクション1</a></li>

このようにhref要素でヘッダー要素のidを指定してあげると、アンカーリンクとして機能します。
被リンクされるHTMLブロックの方にidの設定が必要です。
何らかのidを使うか、タイトルのテキストをそのまま使うかです。
タイトルのテキストだと重複するとめんどくさい問題がありますが、URLは見やすくなるので、そっちが好みな人もいるかと。

僕は結局Notionのブロックidを詰めました。

<h1 id={block.id}>
  <TextComponent richTexts={block.heading_1.text as RichText[]} />
</h1>

ブロックidを使う懸念として、セキュリティリスクがあるかなと思ったんですが、
僕のNotion APIのキーが流出して、ブログ記事データが不正に取得された際に、
ブロックidがバレていると、記事のヘッダー要素のブロックが検索しやすくなってしまう!
これは……まあええか……というわけでブロックidを使うことにしました。
たぶんただのUUIDですし。

ヘッダー要素の検査

Notion APIからもらった記事のブロック配列の中から、h1/h2/h3要素だけを抜き出します。
リンクカードのための処理もあり、何も考えずに書いたら、ブロック配列のmapを2回やることになったので、そこは計算量の問題があるので、同じmapの中でやることにしました。

const blocksWithOGP: NotionBlockWithChildren[] = []
const tableOfContentsBlocks: NotionBlockWithChildren[] = []

await Promise.all(
  blocksWithChildren.map(async (block, index) => {
    /// 目次用のブロックを抽出
    if (['heading_1', 'heading_2', 'heading_3'].includes(block.type)) {
      tableOfContentsBlocks.push(block)
    }
    //…
  })
)

目次要素のレンダリングは頭使う必要があった

後はHTMLにレンダリングできれば解決なんですが、ここが一苦労でした。
というのは、HTMLのリストのタグはネストしています。
簡単な例からはじめると、

h1
h1
h1

このヘッダー要素であれば、

<nav>
  <ol>
    <li><a href="#section1">セクション1</a></li>
    <li><a href="#section2">セクション2</a></li>
    <li><a href="#section3">セクション3</a></li>
  </ol>
</nav>

とレンダリングします。
これなら簡単です。
では次の例はどうでしょう?

h1
  h2
    h3
    h3

この場合、出力したいHTMLはこちらとなります。

<nav>
  <ol>
    <li><a href="#section1">セクション1</a></li>
    <ol>
      <li><a href="#section1-1">セクション1-1</a></li>
      <ol>
        <li><a href="#section1-1-1">セクション1-1-1</a></li>
        <li><a href="#section1-1-2">セクション1-1-2</a></li>
      </ol>
    </ol>
  </ol>
</nav>

h1/h2/h3要素を適切に扱おうとすると、だいぶ大変ですね。
1ブロックだけでレンダリングができず、前後の要素を考慮する必要があります。
たとえば任意のh1ブロックに対して、

  • 前ブロックが存在しない、もしくはh2/h3の場合前の<ol>をつける
  • 前ブロックがh1であれば前の<ol>不要
  • 次ブロックが存在しない場合後ろの</ol>をつける
  • 次ブロックがh1であれば後ろの</ol>不要
  • 次ブロックがh2であれば後ろに<ol>をつける
  • 次ブロックがh3であれば後ろに<ol><ol>をつける
    • h2/h3のとき、h1要素の後ろの</ol>要素をどうしよう……

という条件になり、これは厳しい、となりました。
適切なネストを考えなくて良いのであれば、

  • h1なら<ol></ol>なし
  • h2なら<ol></ol>
  • h3なら<ol><ol></ol></ol>

と出力すると、実装は楽に書けます。

Claudeが再帰関数書いてきた

この実装、生成AIのClaudeに質問しながら進めてたんですが、ここで再帰関数を使った実装を提案してきて面白かったです。
いや分岐条件ミスってて、10個のヘッダー要素に対して100個ぐらい目次が生成されるコードでしたけど、
パッと見だと天才的なロジックに見えて、すごいなと思いました。

h1/h2/h3ごとに仕分け

1ブロックずつ処理すると、ロジックがとても難しくなることがわかりました。
難しいポイントを分析すると、

  • h1/h2/h3でそれぞれネストが発生する
  • 前後の要素によってタグが閉じたり閉じなかったりする

「前後の要素によってはタグが閉じないケースがある」というのが扱いづらさの元凶なので、これをなくす方法を考えました。
ヘッダー要素の配列をよく見ると、

h1 )1
  h2 )2 
    h3 )3
    h3 )

このように連続するヘッダー要素をまとめられたなら、タグが必ず閉じるという前提ができます。
この場合、

[[h1], [h2], [h3, h3]]

という二重配列にします。
出力したかったHTMLを改めて眺めると……

<nav>
  <ol>
    <li><a href="#section1">セクション1</a></li>
    <ol>
      <li><a href="#section1-1">セクション1-1</a></li>
      <ol>
        <li><a href="#section1-1-1">セクション1-1-1</a></li>
        <li><a href="#section1-1-2">セクション1-1-2</a></li>
      </ol>
    </ol>
  </ol>
</nav>

となっており、この二重配列なら<ol></ol>で囲んでいる単位を扱えそうです。

方針

実装方針は下記のとおりです。

  • 目次要素の入った NotionBlockWithChildren[] が来る
  • 先頭から順にh1, h2, h3で仕分けて二重配列をつくる
  • 二重配列の先頭からJSXコンポーネントを生成

この方針で書いてみたコードがこちらです。長いので折りたたみます。

TableOfContentsComponent
const TableOfContentsComponent = ({ tableOfContents }: { tableOfContents: NotionBlockWithChildren[] }) => {
  if (tableOfContents.length === 0) {
    return null
  }

  const renderTableOfContents = (blocks: NotionBlockWithChildren[]) => {
    const groupedBlocks: NotionBlockWithChildren[][] = []
    const sameHeadingBlocks: NotionBlockWithChildren[] = []

    blocks.forEach((block, index) => {
      if (sameHeadingBlocks[sameHeadingBlocks.length - 1] &&
          block.type != sameHeadingBlocks[sameHeadingBlocks.length - 1].type) {
        groupedBlocks.push([...sameHeadingBlocks])
        sameHeadingBlocks.length = 0
      }

      sameHeadingBlocks.push(block)

      if (index == blocks.length - 1) {
        groupedBlocks.push([...sameHeadingBlocks])
      }  
    })

    return (
      <ol>
        {groupedBlocks.map((blocks) => {
          switch (blocks[0]?.type) {
            case 'heading_1':
              return blocks.flatMap((block) => renderBlock(block))
            case 'heading_2':
              return (<ol key={blocks[0]?.id}>{blocks.flatMap((block) => renderBlock(block))}</ol>)
            case 'heading_3':
              return (<ol key={blocks[0]?.id}><ol>{blocks.flatMap((block) => renderBlock(block))}</ol></ol>)
            default:
              return null
          }
        })}
      </ol>
    )
  }

  const renderBlock = (block: NotionBlockWithChildren) => {
      switch (block.type) {
        case 'heading_1':
          return (
            <li key={block.id}>
              <a href={`#${block.id}`}>{block.heading_1.text[0].plain_text}</a>
            </li>
          )
        case 'heading_2':
          return (
            <li key={block.id}>
              <a href={`#${block.id}`}>{block.heading_2.text[0].plain_text}</a>
            </li>
          )
        case 'heading_3':
          return (
            <li key={block.id}>
              <a href={`#${block.id}`}>{block.heading_3.text[0].plain_text}</a>
            </li>
          )

        default:
          return null
    }
  }

  return (
    <nav>
      <h1>目次</h1>
      {renderTableOfContents(tableOfContents)}
    </nav>
  )
}

これで出力されるHTMLですが、本来出したかったやつと若干差があります。

<nav>
  <h1>目次</h1>
  <ol>
    <li><a href="#section1">セクション1</a></li>
    <ol>
      <li><a href="#section2">セクション2</a></li>
    </ol>
    <ol>
      <ol>
        <li><a href="#section3">セクション3</a></li>
        <li><a href="#section4">セクション4</a></li>
      </ol>
    </ol>
  </ol>
</nav>

厳密に親子関係をつけたいなら、更に配列の要素の一個前を見るとかしないとダメな気がします。
ただ僕の個人ブログは、特にセクション番号表記はしていないので、
このHTMLであれば、小見出しのネストが効くので、一旦はこれでいいのかと思っています。

終わり

細かいところはプルリクを見てください。
所感としては、リンクカードよりだいぶ簡単でした。
ネストしたリストは、目次だけでなく、通常のリストでも実はうまく出せていないので、そこも対応したいですね。

スクラップ

https://zenn.dev/st43/scraps/40bd55b3c954ad

(了)

Discussion