投稿日

Astroで2カラムの右側に目次をつける方法

前提

本記事執筆時点での筆者のastroバージョンは5.15.3です

動機

Astroで2カラムにして目次をつけたいと思っていたのですが、以前導入したcustom-tocでは目次と本文がくっついていて、CSSで2カラムにして目次をつけるのは困難でした。そこで、自力で目次を実装しました。

以下のようにContentとheadingsがわかれて取得できるため、任意の位置に目次が実装できます。

完成したコードでは、目次をクリックしたら該当の見出しにジャンプする機能もついています。

[...slug].astro
---
/* 略 */
const post = Astro.props;
const { Content, headings } = await render(post);
---

以前はpost.render()と呼び出していましたが、今はrender(post)の順に呼び出すように変わっています。

renderすると、md/mdxをhtmlに変換したContentと次のようなheadingsが取得できます。

headingsの内容は次の型で表されます。

{ depth: number; slug: string; text: string }[]

depthは見出しの深さ(h1なら1、h2なら2といった具合)でslugとtextはどちらも同じ見出しのテキストが入っています。

slugはurlの一部になるので、url化する処理が入ります。.はとりのぞかれて、大文字が小文字に、スペースなどがハイフンに置き換わったものになっています。

日本語の見出しだとslugとtextがまったく同じであることも多いです。

2カラムの目次を実装する方針

一つ前のセクションでheadingsが取得できることが分かりましたが、以下のようにして実装していきます。

このコードはh2,h3,h4に対応したものになります。

このコードは任意の位置におけるため、display:flexするなりfloat:rightするなりdisplay:gridしてgrid-template-columns:を設定するなりお好きな方法で2カラムのレイアウトにすることができます。

[...slug].astro
<aside class="toc">
<h2>目次</h2>
<nav>
<ul>
{headings.map((h) => (
<li class={"depth-" + h.depth}>
<a href={"#" + h.slug}>{h.text}</a>
</li>
))}
</ul>
</nav>
</aside>
.toc li.depth-2 { margin-left: 0; }
.toc li.depth-3 { margin-left: 1rem; }
.toc li.depth-4 { margin-left: 2rem; }

考え方としては、depthを含んだクラス名をつけて、深さが深くなるごとにmargin-leftを大きくしていって目次のレイアウトを作っています。

参考:完成ソースコード全体

まだちょくちょく修正はしてますが、現段階の出来上がったソースコードを最後に載せます。

参考にしてみてください。

[...slug].astro
---
import { Image } from 'astro:assets';
import { type CollectionEntry, getCollection, render} from 'astro:content';
import BaseHead from '../../components/BaseHead.astro';
import Footer from '../../components/Footer.astro';
import FormattedDate from '../../components/FormattedDate.astro';
import Header from '../../components/Header.astro';
type Props = CollectionEntry<'blog'>;
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
props: post,
}));
}
const post = Astro.props;
const { Content, headings } = await render(post);
---
<html lang="jp">
<head>
<BaseHead title={post.data.title} description={post.data.description} tags={post.data.tags} />
<style>
main {
width: calc(100% - 2em);
max-width: 100%;
margin: 0;
}
.hero-image {
width: 100%;
}
.hero-image img {
display: block;
margin: 0 auto;
border-radius: 12px;
box-shadow: var(--box-shadow);
}
.prose {
width: 720px;
max-width: calc(100% - 2em);
margin: auto;
padding: 1em;
color: rgb(var(--gray-dark));
}
.title {
margin-bottom: 1em;
padding: 1em 0;
text-align: center;
line-height: 1;
}
.title h1 {
margin: 0 0 0.5em 0;
}
.date {
margin-bottom: 0.5em;
color: rgb(var(--gray));
}
.last-updated-on {
font-style: italic;
}
.layout {
display: grid;
grid-template-columns: 1fr 300px;
gap: 2rem;
max-width: 1100px;
margin: 0 auto;
padding: 2rem 1rem;
}
.content {
min-width: 0;
}
.toc {
position: sticky;
top: 2rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
padding-left: 1rem;
border-left: 2px solid #ddd;
}
.toc h2 {
font-size: 1.2rem;
margin-top: 0;
}
.toc ul {
list-style: none;
padding: 0;
}
.toc li.depth-2 { margin-left: 0; }
.toc li.depth-3 { margin-left: 1rem; }
.toc li.depth-4 { margin-left: 2rem; }
.toc a {
color: #444;
text-decoration: none;
}
.toc a:hover {
text-decoration: underline;
}
html {
scroll-behavior: smooth;
}
</style>
</head>
<Header />
<main>
<div class="layout">
<article class="content">
<div class="hero-image">
{post.data.heroImage && <Image width={1020} height={510} src={post.data.heroImage} alt="" />}
</div>
<div class="prose">
<div class="title">
<div class="date">
{post.data.pubDate && (
<div>
投稿日 <FormattedDate date={post.data.pubDate} />
</div>
)}
{post.data.updatedDate && (
<div class="last-updated-on">
最終更新日 <FormattedDate date={post.data.updatedDate} />
</div>
)}
</div>
<h1>{post.data.title}</h1>
</div>
</div>
<Content />
</article>
<aside class="toc">
<h2>目次</h2>
<nav>
<ul>
{headings.map((h) => (
<li class={"depth-" + h.depth}>
<a href={"#" + h.slug}>{h.text}</a>
</li>
))}
</ul>
</nav>
</aside>
</div>
</main>
</html>