본문 바로가기
프로젝트

[블로그 프로젝트] 5. TOC(Tables of Contents) 만들기

by Piva 2024. 2. 28.
  • Tables of Contents(TOC)란 번역하면 '목차'이다. 이름과 걸맞게, 컨텐츠의 제목 + 소제목을 한데 모아 목록으로 보여준다.
  • 이번 글에서는 TOC를 어떻게 구현하였는지에 대해 기록한다.

TOC 구현하기

라이브러리를 활용하는 방법

  TOC를 구현하기 위해서는 지난 글에서도 소개한 바 있는 remark를 활용할 수 있다. remark-toc 플러그인을 사용하면 제목만 모아서 TOC를 생성해주기 때문에, 비교적 간단한 구현이 가능하다.

 

  그럼에도 불구하고 내가 remark-toc를 사용하지 않은 이유는 TOC가 마크다운 문서 내부에서만 사용가능했기 때문이다. 내 목적은 TOC 컴포넌트를 마크다운 바깥에 두는 것이어서, 마크다운 내부에서만 작동하면 디자인에 부합하는 결과물을 낼 수 없었다.

 

블로그 포스트 페이지 디자인

 

  결국 디자인을 만족하기 위해 직접 TOC를 구현하기로 결정했다.

 

  remark-toc가 하듯이, Heading 으로 작성된 제목 요소들을 TOC 목록으로 구성하기로 했다. 이를 위해서 아래의 과정을 거친다.

  1. 마크다운 문서에서 Heading 요소들을 전부 가져온다.
  2. 각 헤더에 id 값을 붙여준다. 이는 후에 TOC 클릭 시 선택한 제목 문단으로 이동할 수 있게끔 구현하기 위해서다.
  3. 이렇게 가져온 제목 요소들을 별도의 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값이 들어가있음을 확인할 수 있다.

Heading 태그에 id가 적용된 모습

 

 

  TOC를 통해 문단 사이 이동이 가능한 것도 확인 가능하다.

문단 이동

 

 

※ 추후 개선점

  현재 TOC는 잘 작동하지만, 문단 내 이동 시 헤더 컴포넌트에 목표 문단이 가려지는 문제가 있다. 이 때문에 얼핏 내가 이동하려던 문단의 바로 다음 문단으로 이동한 듯한 착각이 들게 되기 때문에(...) 이동 후 헤더에 가려지지 않고 헤더 바로 밑에 문단이 표시되게끔 수정하려 한다.

 


 

Parse MDX file in Next JS?

I have an .mdx file I'm importing into a page with Next JS. I'd like to create link fragments to every heading on the page. So if my default markdown output is: <h2>Title 1</h2> <p&g...

stackoverflow.com