あでぃ工房 のカバー画像

Site icon image あでぃ工房

🔖 astro-notion-blogに目次機能を追加してみた

8分で読めます

動機

Zennについてる目次、かっこいいですよね。

自分はまだそこまで長い記事を書いたことないんですが、ちょっと背伸びして目次だけ先に付けてみることにしました。

  • 記事内の見出し(h1, h2, h3)から目次を自動生成
  • サイドバーにリスト形式で表示し、クリックで該当箇所へスクロール

この2つを実現します。

このブログは astro-notion-blog を使って構築していいるので、その前提で見てください。

実装

1. 目次表示用コンポーネントの作成

対象: src/components/TableOfContentsSidebar.astro

見出しのリストを受け取って、リンク付きのリストとして表示するコンポーネントを作ります。

    ---
    import type { Block, RichText } from '../lib/interfaces.ts'; // 型定義をインポート
    import { slugify } from '../lib/blog-helpers.ts'; // 後述するID生成関数

    export interface Props {
      headings: Block[]; // 見出しブロックの配列
    }
    const { headings } = Astro.props;

    const extractPlainText = (richTexts: RichText[] | undefined): string => {
      if (!richTexts) return '';
      return richTexts.map(rt => rt.PlainText).join('');
    };
    ---

    {headings && headings.length > 0 && (
      <nav class="toc-sidebar" aria-labelledby="toc-heading">
        <h2 id="toc-heading" class="toc-title">目次</h2>
        <ul class="toc-list">
          {headings.map(heading => {
            let text = '';
            let level = 0;
            // 見出しタイプに応じてテキストとレベルを決定
            if (heading.Type === 'heading_1' && heading.Heading1) {
              text = extractPlainText(heading.Heading1.RichTexts);
              level = 1;
            } else if (heading.Type === 'heading_2' && heading.Heading2) {
              // ... (h2, h3 も同様)
              text = extractPlainText(heading.Heading2.RichTexts);
              level = 2;
            } else if (heading.Type === 'heading_3' && heading.Heading3) {
              text = extractPlainText(heading.Heading3.RichTexts);
              level = 3;
            }
            const id = text ? slugify(text) : ''; // テキストからIDを生成

            return text ? (
              <li class={`toc-item toc-item-level-${level}`}>
                <a href={`#${id}`}>{text}</a>
              </li>
            ) : null;
          })}
        </ul>
      </nav>
    )}

    <style>
      /* 省略: サイドバーに合わせたスタイルを設定 */
      .toc-sidebar { /* ... */ }
      .toc-title { /* ... */ }
      .toc-list { /* ... */ }
      .toc-item-level-2 { padding-left: 1em; }
      .toc-item-level-3 { padding-left: 2em; }
    </style>

2. 見出しID生成ロジック

対象 : src/lib/blog-helpers.ts

日本語の見出しからも安全なIDを生成するため、以下の slugify 関数を定義します。

    export const slugify = (text: string): string => {
      if (!text) return '';
      return text
        .toString()
        .toLowerCase()
        .trim()
        .replace(/\s+/g, '-') // スペースをハイフンに
        // 日本語や特殊文字を考慮した置換処理(必要に応じて調整)
        .replace(/[^\w\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF0-9-]+/g, '') 
        .replace(/-+/g, '-')
        .replace(/^-+|-+$/g, '');
    };

    // 既存の見出しID生成関数 buildHeadingId もこの slugify を使うように修正
    export const buildHeadingId = (heading: Heading1 | Heading2 | Heading3) => {
      const text = heading.RichTexts.map((rt: RichText) => rt.PlainText).join('').trim();
      return slugify(text);
    };

さらに、各見出しコンポーネント(Heading1.astro など)でもこの関数を使って、id 属性を設定します。

    ---
    // src/components/notion-blocks/Heading1.astro (Heading2, Heading3も同様)
    import { buildHeadingId } from '../../lib/blog-helpers.ts';
    // ... 他のインポート ...
    const { block } = Astro.props;
    const id = buildHeadingId(block.Heading1!); // block.Heading2, block.Heading3 など
    ---
    <a href={`#${id}`} id={id}> {/* hタグ自体でも良い */}
      <h1>{/* 見出しテキスト */}</h1>
    </a>

3. 記事ページでの目次データ準備と配置

対象 : src/pages/posts/[slug].astro

記事の全ブロックから見出しブロック(h1〜h3)だけを抽出して、目次コンポーネントに渡します。

---
// ...
import TableOfContentsSidebar from '../../components/TableOfContentsSidebar.astro';

const [blocks, /* ...他のデータ... */] = await Promise.all([/* ... */]);

// 目次用の見出しブロックを抽出 (h1, h2, h3)
const tocHeadings = blocks.filter(
  (block) =>
    block.Type === 'heading_1' ||
    block.Type === 'heading_2' ||
    block.Type === 'heading_3'
);
---
<Layout /* ... */ >
  {/* ... */}
  <div slot="aside" class="aside">
    <TableOfContentsSidebar headings={tocHeadings} />
    {/* ... 他サイドバーコンテンツ ... */}
  </div>
</Layout>

これで、サイドバーに自動的に見出し一覧を表示できるようになりました 🎉

最新記事の上にこんな感じに表示されます

まとめ

「いつか長い記事を書きたいな〜」という気持ちだけで、またしても準備から入ってしまいました。下準備大好きおじさんです。

自分のブログを見返してみると、見出しを全然つけてない記事もあってびっくり。💣
これからは読みやすさも意識して、ちゃんと見出しを使っていけたらなと思ってます。

前後の記事

複数のMacでフォントを自動同期するCLIツールを作った話
◀︎ 次の記事

複数のMacでフォントを自動同期するCLIツールを作った話

SNSシェア機能にタグを入れてちょっとだけ強化した
前の記事 ▶︎

SNSシェア機能にタグを入れてちょっとだけ強化した