블로그: 사이드바 목차 만들기 — 글 읽기 경험 개선
블로그: 사이드바 목차 만들기에 대해 "왜?"라고 물으면 답할 수 있으신가요?
\n\n> 블로그: 사이드바 목차 만들기에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n긴 글을 읽다 보면 "이 글에 뭐가 있는 거지?" 싶을 때가 있습니다. 특히 기술 블로그는 소제목이 많고 글이 긴 경우가 대부분이라, 목차 없으면 스크롤만 올렸다 내렸다 하게 됩니다.
목차가 있으면 전체 구조를 한눈에 볼 수 있고, 원하는 섹션으로 바로 점프할 수 있습니다. 블로그의 읽기 경험에 있어서 꽤 중요한 기능이라고 생각해서 초기에 구현했습니다.
기본 원리
사실 목차의 원리 자체는 단순합니다. heading 태그에 id를 부여하고, 목차의 a 태그가 그 id를 가리키게 하면 됩니다.
<h2 id="intro">소개</h2>
<!-- 이걸 클릭하면 위의 h2로 스크롤 -->
<a href="#intro">소개</a>
브라우저가 #id 형태의 앵커를 알아서 처리해주니까, 자바스크립트 없이도 동작합니다.
이전 글에서 rehypeSlug 플러그인을 통해 heading에 id를 자동으로 넣어뒀기 때문에, 이제 할 일은 두 가지입니다.
- 마크다운에서 heading 목록을 추출한다
- 그걸 사이드바에 링크 목록으로 렌더링한다
Heading 데이터 추출
MarkdownUtil에 toc 메서드를 추가했습니다. 정규식으로 마크다운 본문에서 ## ~ #### 수준의 heading을 찾아냅니다.
const MarkdownUtil = {
toc({ post }) {
const content = post.content;
const toc = [];
const matches = content.matchAll(/^(#{2,4})\s+(.*)$/gm);
for (const match of matches) {
const level = match[1].length;
const fullText = match[2];
// "[텍스트](링크)" 형태의 heading 처리
const linkMatch = fullText.match(/^\[(.*?)\]\(.*?\)$/);
const text = linkMatch ? linkMatch[1] : fullText.trim();
// rehypeSlug가 생성하는 것과 동일한 slug 생성
const slug = text
.toLowerCase()
.replace(/[^\w\s가-힣-]/g, '')
.replace(/\s+/g, '-');
toc.push({ level, text, slug });
}
return toc;
},
};
h1은 글 제목으로 쓰니까 빼고, h2 ~ h4만 추출합니다. h5 이하는 목차에 넣으면 너무 깊어지니까 스킵합니다.
추출 결과는 이런 형태입니다.
[
{ level: 2, text: "기본 원리", slug: "기본-원리" },
{ level: 3, text: "heading 데이터 추출", slug: "heading-데이터-추출" },
{ level: 2, text: "목차 컴포넌트", slug: "목차-컴포넌트" },
// ...
]
slug는 rehypeSlug가 heading에 부여하는 id와 동일한 방식으로 생성해야 합니다. 이게 어긋나면 목차를 클릭해도 스크롤이 안 됩니다. 처음에 이거 때문에 좀 헤맸습니다.
PostDetailToc 컴포넌트
추출한 데이터를 사이드바에 렌더링하는 컴포넌트입니다.
const PostDetailToc = ({ post }) => {
const toc = MarkdownUtil.toc({ post });
if (toc.length === 0) return null;
return (
<nav className="toc">
<div className="toc__title">목차</div>
<ol className="toc__list">
{toc.map((item) => (
<li key={item.slug} className={`toc__item toc__item--h${item.level}`}>
<a href={`#${item.slug}`} className="toc__link">
{item.text}
</a>
</li>
))}
</ol>
</nav>
);
};
toc__item--h2, toc__item--h3, toc__item--h4 클래스로 레벨별 들여쓰기를 다르게 줍니다. CSS는 대략 이런 식입니다.
.toc__item--h2 { padding-left: 0; }
.toc__item--h3 { padding-left: 1rem; }
.toc__item--h4 { padding-left: 2rem; }
단순하지만 계층 구조가 눈에 들어오니까 효과는 충분합니다.
페이지에 배치
글 상세 페이지의 aside 영역에 목차를 넣습니다.
const PostDetailPage = () => {
const post = ...;
return (
<article>
{/* 본문 */}
<section>
<ReactMarkdown>...</ReactMarkdown>
</section>
{/* 사이드바 */}
<aside>
<PostDetailToc post={post} />
</aside>
</article>
);
};
데스크톱에서는 사이드바에 position: sticky를 걸어서 스크롤해도 목차가 따라오게 했고, 모바일에서는 공간이 부족하니까 숨깁니다.
한 가지 더 — 현재 위치 표시
목차가 있으면 "지금 내가 어디를 읽고 있는지"도 알려주면 좋겠다 싶어서, 스크롤 위치에 따라 현재 섹션을 하이라이트하는 기능도 추가했습니다.
IntersectionObserver로 각 heading의 가시 여부를 감지하고, 현재 보이는 heading에 해당하는 목차 항목에 active 클래스를 토글합니다. 자세한 구현은 코드가 좀 길어서 여기서는 생략하겠습니다.
정리
목차 구현 자체는 크게 어렵지 않습니다. 핵심은 두 가지:
rehypeSlug로 heading에 id를 부여한다- 같은 slug 로직으로 목차 데이터를 추출하고,
<a href="#slug">로 연결한다
이게 맞물리면 나머지는 스타일링 문제입니다. 다음 글에서는 SEO를 위한 메타 태그 관리를 다루겠습니다.