Theme:

블로그: 사이드바 목차 만들기에 대해 "왜?"라고 물으면 답할 수 있으신가요?

\n\n> 블로그: 사이드바 목차 만들기에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n긴 글을 읽다 보면 "이 글에 뭐가 있는 거지?" 싶을 때가 있습니다. 특히 기술 블로그는 소제목이 많고 글이 긴 경우가 대부분이라, 목차 없으면 스크롤만 올렸다 내렸다 하게 됩니다.

목차가 있으면 전체 구조를 한눈에 볼 수 있고, 원하는 섹션으로 바로 점프할 수 있습니다. 블로그의 읽기 경험에 있어서 꽤 중요한 기능이라고 생각해서 초기에 구현했습니다.


기본 원리

사실 목차의 원리 자체는 단순합니다. heading 태그에 id를 부여하고, 목차의 a 태그가 그 id를 가리키게 하면 됩니다.

HTML
<h2 id="intro">소개</h2>

<!-- 이걸 클릭하면 위의 h2로 스크롤 -->
<a href="#intro">소개</a>

브라우저가 #id 형태의 앵커를 알아서 처리해주니까, 자바스크립트 없이도 동작합니다.

이전 글에서 rehypeSlug 플러그인을 통해 heading에 id를 자동으로 넣어뒀기 때문에, 이제 할 일은 두 가지입니다.

  1. 마크다운에서 heading 목록을 추출한다
  2. 그걸 사이드바에 링크 목록으로 렌더링한다

Heading 데이터 추출

MarkdownUtiltoc 메서드를 추가했습니다. 정규식으로 마크다운 본문에서 ## ~ #### 수준의 heading을 찾아냅니다.

JAVASCRIPT
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 이하는 목차에 넣으면 너무 깊어지니까 스킵합니다.

추출 결과는 이런 형태입니다.

JAVASCRIPT
[
    { level: 2, text: "기본 원리", slug: "기본-원리" },
    { level: 3, text: "heading 데이터 추출", slug: "heading-데이터-추출" },
    { level: 2, text: "목차 컴포넌트", slug: "목차-컴포넌트" },
    // ...
]

slugrehypeSlug가 heading에 부여하는 id와 동일한 방식으로 생성해야 합니다. 이게 어긋나면 목차를 클릭해도 스크롤이 안 됩니다. 처음에 이거 때문에 좀 헤맸습니다.


PostDetailToc 컴포넌트

추출한 데이터를 사이드바에 렌더링하는 컴포넌트입니다.

JSX
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는 대략 이런 식입니다.

CSS
.toc__item--h2 { padding-left: 0; }
.toc__item--h3 { padding-left: 1rem; }
.toc__item--h4 { padding-left: 2rem; }

단순하지만 계층 구조가 눈에 들어오니까 효과는 충분합니다.


페이지에 배치

글 상세 페이지의 aside 영역에 목차를 넣습니다.

JSX
const PostDetailPage = () => {
    const post = ...;

    return (
        <article>
            {/* 본문 */}
            <section>
                <ReactMarkdown>...</ReactMarkdown>
            </section>

            {/* 사이드바 */}
            <aside>
                <PostDetailToc post={post} />
            </aside>
        </article>
    );
};

데스크톱에서는 사이드바에 position: sticky를 걸어서 스크롤해도 목차가 따라오게 했고, 모바일에서는 공간이 부족하니까 숨깁니다.


한 가지 더 — 현재 위치 표시

목차가 있으면 "지금 내가 어디를 읽고 있는지"도 알려주면 좋겠다 싶어서, 스크롤 위치에 따라 현재 섹션을 하이라이트하는 기능도 추가했습니다.

IntersectionObserver로 각 heading의 가시 여부를 감지하고, 현재 보이는 heading에 해당하는 목차 항목에 active 클래스를 토글합니다. 자세한 구현은 코드가 좀 길어서 여기서는 생략하겠습니다.


정리

목차 구현 자체는 크게 어렵지 않습니다. 핵심은 두 가지:

  1. rehypeSlug로 heading에 id를 부여한다
  2. 같은 slug 로직으로 목차 데이터를 추출하고, <a href="#slug">로 연결한다

이게 맞물리면 나머지는 스타일링 문제입니다. 다음 글에서는 SEO를 위한 메타 태그 관리를 다루겠습니다.

댓글 로딩 중...