はじめに
はじめまして、株式会社メンバーズの大西です。
普段はWebエンジニアとして、ヘッドレスCMSやAstroなどを使用したJamstack構成の開発を中心に取り組んでいます。
本記事では、microCMSから取得した記事データを元に、Astroとcheerioを使って目次(TOC: Table of Contents)を生成する方法について解説していきます。
目次があれば、読者は記事の全体像を一目で把握でき、知りたい情報にスムーズにアクセスできるようになるため、読者の離脱を防ぎやすくなります。
本記事の最終的な完成形は以下のイメージです。
事前準備
今回使用する主な技術スタックとバージョンは以下の通りです。
- Astro: v5.7
- cheerio: v1.0
cheerioは軽量で高速なHTML/XMLパーサーライブラリで、今回はmicroCMSから取得したHTMLコンテンツの中にある見出しタグを抽出するために使用します。
なお本記事は、AstroとmicroCMSのチュートリアル「AstroとmicroCMSでつくるブログサイト」の拡張版になります。本記事はこのチュートリアルを終えている前提で話を進めていきます。
実装手順
microCMSで記事を入稿
まずはじめに、microCMSで記事を入稿します。
本文(フィールドID:content
)のリッチエディタで「見出し2」と「見出し3」を利用して記事を作成してください(記事タイトルで h1
を使用する前提なので、本文では「見出し2」と「見出し3」を使用します)。
microCMSのリッチエディタでは、見出しを設定すると自動的にid属性が付与されます。このid属性を後ほど目次のアンカーリンクとして使用します。
cheerioのインストール
続いて、プロジェクトにcheerioをインストールします。
npm install cheerio
cheerioを使ってHTMLから見出しを抽出
cheerioを使って記事ページの見出し要素を抽出します。
// /src/pages/blog/[blog_id].astro
---
import * as cheerio from 'cheerio';
import { getBlogs } from '../../library/microcms.js';
import Layout from '../../layouts/Layout.astro';
import Toc from '../../components/Toc.astro';
export async function getStaticPaths() {
const response = await getBlogs({ fields: ['id', 'title', 'content'] });
return response.contents.map((content) => ({
params: {
blog_id: content.id,
},
props: { content },
}));
}
const { content } = Astro.props;
const cheerioDom = cheerio.load(content.content);
const headings = cheerioDom('h2, h3').toArray();
const toc = headings.map((data) => ({
id: data.attribs.id,
text: cheerioDom(data).text(),
tag: data.tagName,
}));
---
<Layout title={content.title}>
<Toc tocItems={toc} />
<div set:html={content.content} />
</Layout>
まず、cheerio.load()
を使い、microCMSから取得したHTMLコンテンツをcheerioで解析可能なオブジェクトに変換します。
次に、そのオブジェクトから h2
, h3
タグを指定し、目次となる見出し要素を取得します。取得した各見出し要素からid
、text
(見出しテキスト)、tag
(タグ名)を抽出し、それらを一つの配列にまとめていきます。body
内では、先ほど抽出した見出しデータの配列を、次に作成するToc
コンポーネントに渡します。
Tocコンポーネントを作成し、抽出した見出しを階層構造に変換
次に、抽出した見出しデータを階層構造に変換して表示するTocコンポーネントを作成します。
// /src/components/Toc.astro
---
export interface Props {
tocItems: Array<{
id: string;
text: string;
tag: string;
}>;
}
type TocItem = {
id: string;
text: string;
tag: string;
children?: TocItem[];
};
const { tocItems } = Astro.props;
function buildNestedToc(items: Props['tocItems']): TocItem[] {
const result: TocItem[] = [];
let currentH2: TocItem | null = null;
for (const item of items) {
if (item.tag === 'h2') {
currentH2 = {
...item,
children: [],
};
result.push(currentH2);
} else if (item.tag === 'h3' && currentH2) {
currentH2.children!.push(item);
}
}
return result;
}
const nestedToc = buildNestedToc(tocItems);
---
<nav class="toc">
<p class="toc-title">目次</p>
<ul class="toc-list">
{nestedToc.map((item) => (
<li class={`toc-item ${item.tag}`}>
<a href={`#${item.id}`} class="toc-link">
{item.text}
</a>
{item.children && item.children.length > 0 && (
<ul class="toc-sublist">
{item.children.map((child: TocItem) => (
<li class={`toc-item ${child.tag}`}>
<a href={`#${child.id}`} class="toc-link">
{child.text}
</a>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
buildNestedToc
関数でフラットな見出しの配列を階層構造に変換します。h2
要素を親として、その後に続く h3
要素を子要素として配置します。具体的な変換前と変換後の配列データは以下のようになります。
変換前(tocItems
)
[
{ id: 'hca1f775802', text: '章1タイトル', tag: 'h2' },
{ id: 'hfe276746c3', text: 'セクション1-1', tag: 'h3' },
{ id: 'hf89c3e355c', text: 'セクション1-2', tag: 'h3' },
{ id: 'h79eec94d79', text: '章2タイトル', tag: 'h2' },
{ id: 'h57adfb5530', text: 'セクション2-1', tag: 'h3' }
]
変換後(nestedToc
)
[
{
"id": "hca1f775802",
"text": "章1タイトル",
"tag": "h2",
"children": [
{
"id": "hfe276746c3",
"text": "セクション1-1",
"tag": "h3"
},
{
"id": "hf89c3e355c",
"text": "セクション1-2",
"tag": "h3"
}
]
},
{
"id": "h79eec94d79",
"text": "章2タイトル",
"tag": "h2",
"children": [
{
"id": "h57adfb5530",
"text": "セクション2-1",
"tag": "h3"
}
]
}
]
そして、変換後の配列をAstroのテンプレート内でリストとしてレンダリングします。a
タグの href
属性に id
を設定して、目次からアンカーリンクとしてジャンプできるようにします。
スタイルの調整
目次にCSSを追加し、スタイルを調整します。h2
と h3
でスタイルを分けたり、インデントを付けることで階層構造を分かりやすくします。
// /src/components/Toc.astro
<style>
.toc {
background-color: #f9f9f9;
border: 1px solid #ddd;
padding: 15px;
margin-bottom: 20px;
border-radius: 5px;
}
.toc-title {
font-size: 1.2em;
font-weight: bold;
margin-top: 0;
margin-bottom: 10px;
}
.toc-list {
list-style: none;
padding: 0;
margin: 0;
}
.toc-sublist {
list-style: none;
padding-left: 20px;
margin-top: 5px;
margin-bottom: 0;
}
.toc-item.h2 {
font-weight: bold;
margin: 10px 0;
}
.toc-item.h3 {
font-weight: normal;
margin: 5px 0;
}
.toc-link {
text-decoration: none;
color: #007acc;
}
.toc-link:hover {
text-decoration: underline;
}
</style>
目次の表示/非表示をmicroCMS側から制御する
ここまでで目次の表示はできるようになったので、目次の表示/非表示をmicroCMS側から設定できるようにしていきます。
この実装を行うことで、デフォルト設定では目次を表示し、短い記事では手動で目次を非表示にするなど、記事ごとに使い分けが可能になります。
まずは、microCMSの管理画面で、記事ごとに目次の表示/非表示を制御できるように真偽値のフィールドを追加します。
ブログコンテンツの「API設定」→「APIスキーマ」で真偽値のフィールドを追加し、フィールドIDは insert_toc
で設定します。デフォルトで目次を表示する場合は、詳細設定で初期値の設定が可能です。
「目次の挿入」のトグルボタンが追加されていることを確認し、ボタンをオンに設定しておきます。
続いて、ソースコード側のブログ記事ページで、microCMSから insert_toc
フィールドを取得し、条件分岐で目次の表示を制御する実装を追加します。ブログ記事の型定義も修正する必要があります。
// /src/pages/blog/[blog_id].astro
const response = (await getBlogs({ fields: ["id", "title", "content", "insert_toc"] }))
getBlogs
関数のフィールドに insert_toc
を追加します。
// /src/pages/blog/[blog_id].astro
{content.insert_toc && <Toc tocItems={toc} />}
insert_toc
が true
の場合のみ Toc
コンポーネントを表示するように修正します。
この実装によりmicroCMSの管理画面から目次の表示/非表示を簡単に制御できるようになりました。
本記事の最終的なアウトプットは以下の通りです。
まとめ
本記事でご紹介した実装により、階層構造を持つ目次を生成できるようになりました。
また、microCMSの管理画面から目次の表示/非表示を簡単に設定することができます。
cheerioはHTML/XMLを解析・操作できるため、今回の目次生成以外にも様々な場面で活用できます。
私自身も記事コンテンツ内にスタイリング用の要素を追加する用途で使用した経験がありますが、
その他にも、img
タグの src
属性をカスタマイズして画像最適化を行ったり、コードブロック( pre
タグ)にコピーボタンを自動挿入したりと、様々な活用方法があります。
このように、cheerioのDOM操作・解析機能を活用することで、microCMSから取得したコンテンツを柔軟に加工・最適化できるようになります。
皆さんもぜひ活用してみてください。