astro-notion-blogに目次機能を追加してみた
動機
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>これで、サイドバーに自動的に見出し一覧を表示できるようになりました 🎉

まとめ
「いつか長い記事を書きたいな〜」という気持ちだけで、またしても準備から入ってしまいました。下準備大好きおじさんです。 自分のブログを見返してみると、見出しを全然つけてない記事もあってびっくり。💣 これからは読みやすさも意識して、ちゃんと見出しを使っていけたらなと思ってます。