마크다운에서 #
을 통한 헤딩을 통해 자동으로 목차를 생성해주는 기능을 추가하도록 하겠다.
목차를 생성하려면 MDX 콘텐츠에서 헤딩 태그(h1, h2, h3 등)를 추출하여 이를 목차로 표시해야 한다.
이를 구현하기 위해 다음과 같은 단계를 따른다.
0. 배너 위치 산정하기
블로그 글 페이지 오른쪽에 위치할 수 있도록 justify-between
를 해주고 sticky
로 TopBanner의 위치를 잡아준다.
// `app/blog/[slug]/page.tsx`
import TocBanner from "app/components/toc-banner";
return (
<div className="flex justify-between">
<section className="mt-16">
... 생략
</section>
<div className="ml-auto">
<div className="ml-24 top-[120px] hidden min-w-[280px] max-w-[280px] self-start lg:block sticky top-0">
<TocBanner headings={post.headings} />
</div>
</div>
<div/>
);
// `components/toc-banner.tsx`
export default function TocBanner({ headings }) {
return (
<div className="overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-md">
<div className="p-4">
<p
id="toc-header"
className="text-primary text-sm font-extrabold leading-6"
>
목차
</p>
<ul
id="toc-content"
className="mt-2 gap-2 flex flex-col items-start justify-start text-md"
>
</ul>
</div>
</div>
);
}
1. MDX 콘텐츠에서 헤딩 추출
MDX 파일을 렌더링하는 과정에서 헤딩 데이터를 추출하고 이를 TocBanner로 전달한다.
getMDXData
함수에서 heading을 추출하는 함수를 포함시켜 내보낸다.
이렇게 getBlogPosts에서 MDX 파일의 헤딩 목록을 추출하도록 수정한다.
// `app/blog/utils.ts`
// MDX 본문에서 헤딩을 추출
function extractHeadings(content: string): Heading[] {
let headingRegex = /^(#{1,6})\s+(.*)$/gm;
let codeBlockRegex = /```[\s\S]*?```/g;
let headings: Heading[] = [];
// 코드 블록 제거
let contentWithoutCode = content.replace(codeBlockRegex, (match) => {
return ''.padEnd(match.length, ' '); // 코드 블록 자리에 같은 길이의 공백을 남겨둠
});
let match;
while ((match = headingRegex.exec(contentWithoutCode)) !== null) {
let [_, hashes, text] = match;
headings.push({ level: hashes.length, text: text.trim() });
}
return headings;
}
// MDX 데이터 가져오기
function getMDXData(dir) {
let mdxFiles = getMDXFiles(dir)
return mdxFiles.map((file) => {
let { metadata, content } = readMDXFile(path.join(dir, file))
let slug = path.basename(file, path.extname(file))
let headings = extractHeadings(content);
return {
metadata,
slug,
content,
headings,
}
})
}
export function getBlogPosts() {
return getMDXData(path.join(process.cwd(), 'posts'))
}
2. TocBanner 컴포넌트에서 목차 렌더링
목차 렌더링을 할 때 주의할 점이 있다. ⚠️
기존의 블로그 템플릿에서 헤딩을 클릭할 때 아래의 slugify
함수로 헤딩을 파싱하여 #heading-name
의 href 형식으로 URL을 지원하고 있다.
따라서 해당 href 형식을 맞춰서 목차를 렌더링 해야한다.
// components/mdx.tsx
function slugify(str) {
return str
.toString()
.toLowerCase()
.trim() // Remove whitespace from both ends of a string
.replace(/\s+/g, "-") // Replace spaces with -
.replace(/&/g, "-and-") // Replace & with 'and'
.replace(/[^\w\-]+/g, "") // Remove all non-word characters except for -
.replace(/\-\-+/g, "-"); // Replace multiple - with single -
}
function createHeading(level) {
const Heading = ({ children }) => {
let slug = slugify(children);
return React.createElement(
`h${level}`,
{ id: slug },
[
React.createElement("a", {
href: `#${slug}`,
key: `link-${slug}`,
className: "anchor",
}),
],
children
);
};
Heading.displayName = `Heading${level}`;
return Heading;
}
하지만 이 코드에는 한국어 지원이 안되고 있으니 추가해주자! 그리고 utils 폴더로 분리해서 적용해준다.
// utils/slugify.ts
export default function slugify(str) {
if (!str) return "";
return str
.toString()
.toLowerCase()
.trim() // 문자열 양쪽 공백 제거
.replace(/\s+/g, "-") // 공백을 '-'로 변환
.replace(/&/g, "-and-") // '&'를 '-and-'로 변환
.replace(/[^\w\-가-힣]+/g, "") // 한글, 영문, 숫자, '-' 외 문자 제거
.replace(/\-\-+/g, "-"); // 연속된 '-'를 단일 '-'로 변환
}
slugify 함수를 분리해줬으니까 TocBanner에서 목차를 생성할 때 href 링크를 slugify 함수로 적용해준다.
이렇게 하면, Blog 페이지에서 MDX 파일의 헤딩 정보를 기반으로 TocBanner에 목차가 생성된다. 각 목차 항목은 해당 헤딩으로 이동하는 링크를 포함한다.
// `components/toc-banner.tsx`
import slugify from "utils/slugify";
export default function TocBanner({ headings }) {
return (
<div className="overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 shadow-md">
<div className="p-4">
<p
id="toc-header"
className="text-primary text-sm font-extrabold leading-6"
>
목차
</p>
<ul
id="toc-content"
className="mt-2 flex flex-col items-start justify-start text-sm"
>
{headings.map((heading, index) => {
const slug = slugify(heading.text);
return (
<li key={index} className={`ml-${heading.level}`}>
<a href={`#${slug}`}>
{heading.text}
</a>
</li>
);
})}
</ul>
</div>
</div>
);
}
3. 스크롤 동작 개선 (부드러운 스크롤)
Next.js 프로젝트에서 기본적으로 앵커 링크 클릭 시 부드러운 스크롤이 동작하지 않을 수 있다.
이를 위해 CSS로 부드러운 스크롤 기능을 추가한다.
// globals.css
html {
scroll-behavior: smooth;
}
유용한 플러그인
헤딩을 자동으로 추출해주는 유용한 플러그인이 존재한다. rehype-slug와 rehype-autolink-headings 플러그인을 사용하면 목차 기능 구현에 유용하게 사용할 수 있다.
이 플러그인들을 활용해서 목차를 생성하는 방법은 다음과 같다.
rehype-slug
MDX 렌더링 시 헤딩에 slug id를 자동으로 추가한다.
ex) <h2 id="my-heading">My Heading</h2>
rehype-autolink-headings
rehype-slug가 추가한 ID를 이용해 헤딩에 자동으로 앵커 링크를 추가한다.
ex) <h2 id="my-heading"><a href="#my-heading">My Heading</a></h2>
필자는 위의 플러그인들을 사용하지 않았지만, 간편하게 목차를 생성하고 싶다면 사용해보는 것을 추천한다!
기존 블로그 템플릿에서 이미 정규표현식으로 헤딩 추출 방식을 사용하고 있었기 때문에 플러그인을 사용하면 형식이 맞지 않을 수도 있을 것 같다는 판단을 해서, 위의 slugify 함수의 정규표현식을 따라 헤딩을 파싱해서 목차를 만들어야겠다고 결정했었다.
Conclusion
플러그인을 사용한다면, 편리한 점이 분명 있지만 프로젝트 구조와 요구사항에 맞는 방식으로 선택해야 한다.
또한, 앞서 말했듯이 주의할 점은 rehype-slug에서 생성된 id와 목차에서 생성된 href가 일치하도록 목차 생성 시에도 동일하게 슬러그를 적용해야 한다.
플러그인을 사용하는 것이 아닌, 커스텀한 방식으로 id를 생성하고 싶으면 정규표현식을 사용하면 된다. 다만, 슬러그 생성과 링크 추가, 코드 블록 처리 등을 직접 구현해야 한다.
주요 차이점은 다음과 같다.
extractHeadings | rehype-slug + rehype-autolink-headings | |
---|---|---|
헤딩 추출 방식 | 정규표현식 | AST 변환 및 렌더링 단계 |
슬러그 자동 생성 | 직접 구현 필요 | 자동으로 생성 |
코드 블록 처리 | 수동으로 제거 | 자동으로 무시 |
사용 단계 | MDX 콘텐츠 파싱 단계 | MDX 렌더링 단계 |
rehype 공식문서에서는 추천하는 이유를 다음과 같이 설명하고 있다.
"remark와 rehype 플러그인은 MDX와 함께 사용할 때 그 진가를 발휘합니다. 이를 통해 기술 문서, 블로그 포스트, 교육 자료 등 다양한 온라인 자료를 더욱 효과적으로 작성하고 관리할 수 있습니다. Next.js에서 이러한 플러그인들을 적절히 활용하여 웹 프로젝트의 품질을 한 단계 끌어올려 보세요. "