- Tables of Contents(TOC)란 번역하면 '목차'이다. 이름과 걸맞게, 컨텐츠의 제목 + 소제목을 한데 모아 목록으로 보여준다.
- 이번 글에서는 TOC를 어떻게 구현하였는지에 대해 기록한다.
TOC 구현하기
라이브러리를 활용하는 방법
TOC를 구현하기 위해서는 지난 글에서도 소개한 바 있는 remark를 활용할 수 있다. remark-toc 플러그인을 사용하면 제목만 모아서 TOC를 생성해주기 때문에, 비교적 간단한 구현이 가능하다.
그럼에도 불구하고 내가 remark-toc를 사용하지 않은 이유는 TOC가 마크다운 문서 내부에서만 사용가능했기 때문이다. 내 목적은 TOC 컴포넌트를 마크다운 바깥에 두는 것이어서, 마크다운 내부에서만 작동하면 디자인에 부합하는 결과물을 낼 수 없었다.
결국 디자인을 만족하기 위해 직접 TOC를 구현하기로 결정했다.
remark-toc가 하듯이, Heading 으로 작성된 제목 요소들을 TOC 목록으로 구성하기로 했다. 이를 위해서 아래의 과정을 거친다.
- 마크다운 문서에서 Heading 요소들을 전부 가져온다.
- 각 헤더에 id 값을 붙여준다. 이는 후에 TOC 클릭 시 선택한 제목 문단으로 이동할 수 있게끔 구현하기 위해서다.
- 이렇게 가져온 제목 요소들을 별도의 TOC 컴포넌트에서 렌더링한다.
제목(Heading) 데이터를 마크다운에서 가져오기
이전 글에서도 언급했듯, 현재 블로그는 gray-matter 패키지를 통해 마크다운의 front matter와 문자열 형식의 마크다운 컨텐츠를 파싱하고 있다. 여기서 문자열로 구성된 마크다운 컨텐츠 내부의 Heading 요소를 정규표현식을 통해 가져온다. Heading 요소는 라인의 시작을 1개 이상의 #을 붙여서 작성되므로, 1개 이상의 #으로 시작하는 모든 라인을 찾아내면 된다.
export const getHeadingForTOC = async (source: string) => {
const headings = source.split('\n').filter((str) => str.match(/^#+/));
return headings.map((str) => {
const headingText = str.replace(/^#+/, '');
return { text: headingText };
});
};
/*
아래와 같이 구성된 마크다운 텍스트를 줄 단위로 잘라
정규표현식을 통해 1개 이상의 연속하는 #으로 시작하는 라인들을 가져온다.
여기서 # 만을 제거한 텍스트 배열을 반환한다.
# This is a h1 tag
## This is a h2 tag
#### This is a h4 tag
=>
[
'This is a h1 tag',
'This is a h2 tag',
'This is a h4 tag',
]
*/
내 경우 가져온 Heading들에서 #을 제외한 텍스트 부분만을 파싱하도록 하였다. 텍스트만을 가져오는 이유는 추후에 텍스트를 id로 할당하여, TOC 요소를 클릭하면 해당 문단으로 넘어갈 수 있도록 하기 위해서다.
TOC 컴포넌트 만들기
Heading 요소만을 가져오는 것에 성공했다면, 이들을 렌더링하는 TOC 컴포넌트를 만든다. 위에서 언급한 문단 이동의 구현을 위해 a태그를 사용했다. a 태그는 href 속성에 '같은 페이지에 존재하는 다른 요소의 id 값'을 넣어주면, 해당 id를 가진 요소로 이동시켜준다.
interface TOCProps {
tocData: string[];
}
const TOC = ({ tocData }: TOCProps) => {
return (
<div>
{tocData.map((el) => {
const ref = el
.toLocaleLowerCase() // 영어는 전부 소문자로 변경한다
.replace(/[^\w\sㄱ-힣-]/g, ' ') // 한글과 알파벳, 숫자, 밑줄, -를 제외한 모든 문자를 찾아낸다
.trimStart()
.replaceAll(' ', '-'); // 공백문자는 -로 변경한다
return (
<a href={`#${ref}`} key={el}>
{el}
</a>
);
})}
</div>
);
};
/*
위 과정을 거치면 아래와 같이 변환된다.
ex) This is h1 tag => #this-is-h1-tag
*/
마크다운 Heading 요소에 id 부여하기
TOC 컴포넌트의 문단 내 이동이 제대로 기능하기 위해선 마크다운 내 Heading 요소들에 id값을 부여해야 한다. 그러기 위해선 next-mdx-remote가 마크다운을 렌더링 할 때, Heading 컴포넌트에 자동으로 id를 추가하도록 해야한다. next-mdx-remote를 사용하면 HTML 태그를 원하는 커스텀 컴포넌트로 대체할 수 있기 때문에, 이를 이용하여 구현했다. (참고)
먼저, HTML Heading 태그를 대신할 커스텀 컴포넌트를 작성한다. Heading 태그는 h6까지 존재하지만 h6까진 안 쓸 것 같아 h4까지만 지원하도록 구성했다. 목적은 id값을 넣는 것이기 때문에, Heading 태그의 자식으로 들어온 텍스트를 TOC와 똑같이 변환해 id로 부여한다.
interface HeadingProps extends React.HTMLProps<HTMLHeadingElement> {
type: 'h1' | 'h2' | 'h3' | 'h4';
}
const Heading = ({ type, children }: HeadingProps) => {
const id = children
?.toString()
.toLocaleLowerCase()
.replace(/[^\w\sㄱ-힣-]/g, ' ')
.replaceAll(' ', '-');
if (type === 'h1') {
return <h1 id={id}>{children}</h1>;
}
if (type === 'h2') {
return <h2 id={id}>{children}</h2>;
}
if (type === 'h3') {
return <h3 id={id}>{children}</h3>;
}
return <h4 id={id}>{children}</h4>;
};
export default Heading;
이렇게 만든 커스텀 컴포넌트를 MdxRemote의 components prop에 넘겨준다.
import HeadingComponent from '@/components/shared/Heading';
import { MDXRemote } from 'next-mdx-remote';
const customMdxComponents = {
h1: (props: React.HTMLProps<HTMLHeadElement>) => (
<HeadingComponent type="h1">{props.children}</HeadingComponent>
),
h2: (props: React.HTMLProps<HTMLHeadElement>) => (
<HeadingComponent type="h2">{props.children}</HeadingComponent>
),
h3: (props: React.HTMLProps<HTMLHeadElement>) => (
<HeadingComponent type="h3">{props.children}</HeadingComponent>
),
h4: (props: React.HTMLProps<HTMLHeadElement>) => (
<HeadingComponent type="h4">{props.children}</HeadingComponent>
),
};
/ * ... */
// components prop으로 커스텀 컴포넌트를 넘긴다
return (
<MDXRemote {...content} components={customMdxComponents} />
);
커스텀 컴포넌트를 설정하고 포스트 페이지를 확인해보면, Heading 태그에 id값이 들어가있음을 확인할 수 있다.
TOC를 통해 문단 사이 이동이 가능한 것도 확인 가능하다.
※ 추후 개선점
현재 TOC는 잘 작동하지만, 문단 내 이동 시 헤더 컴포넌트에 목표 문단이 가려지는 문제가 있다. 이 때문에 얼핏 내가 이동하려던 문단의 바로 다음 문단으로 이동한 듯한 착각이 들게 되기 때문에(...) 이동 후 헤더에 가려지지 않고 헤더 바로 밑에 문단이 표시되게끔 수정하려 한다.
'프로젝트' 카테고리의 다른 글
[블로그 프로젝트] 4. giscus로 댓글 기능 구현하기 (0) | 2024.02.22 |
---|---|
[블로그 프로젝트] 3. 카테고리 분류 + 페이지네이션 구현 (0) | 2024.02.16 |
[블로그 프로젝트] 2. mdx 마크다운으로 게시물 페이지 만들기 (1) | 2024.02.11 |
[블로그 프로젝트] 1. TailwindCSS + α 로 디자인 구현하기 (1) | 2024.02.07 |
[블로그 프로젝트] 0. 블로그 기획과 디자인 (1) | 2024.01.15 |