動機
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>
これで、サイドバーに自動的に見出し一覧を表示できるようになりました 🎉
まとめ
「いつか長い記事を書きたいな〜」という気持ちだけで、またしても準備から入ってしまいました。下準備大好きおじさんです。
自分のブログを見返してみると、見出しを全然つけてない記事もあってびっくり。💣
これからは読みやすさも意識して、ちゃんと見出しを使っていけたらなと思ってます。