본문 바로가기

개발

Gatsby 블로그: TOC(목차) 추가하기

기능 소개

Table of Contents(TOC)는 개별 포스트 페이지에서 PC 화면을 기준으로 우측에 있는 목차를 의미한다. 포스트의 소제목들을 나열해서 보여주는 기능으로 내용을 체계적으로 파악하는 데 도움이 되고, 클릭시 해당 부분으로 스크롤이 이동한다.

gatsby-transformer-remark

gatsby-transformer-remark는 마크다운으로 작성된 포스트를 html로 변환시켜주는 플러그인으로, gatsby-starter-blog에 기본적으로 포함되어 있다. 이 플러그인은 html 외에도 tableOfContents라는 필드를 마크다운 노드에 추가해준다. GraphiQL에서 다음과 같은 쿼리를 실행해보면 마크다운에서 hash(#)로 작성한 소제목들이 <ul>, <li>, <a> 태그로 변환되어 tableOfContents의 값으로 들어있는 것을 확인할 수 있다.

 

{
  allMarkdownRemark {
    nodes {
      tableOfContents
    }
  }
}

 

예를 들어 마크다운에서 다음과 같이 작성했다면, tableOfContents는 그 아래 html을 string으로 변환한 값이 된다. #의 개수에 따라 중첩된 리스트가 만들어지고, 각 링크의 href에는 제목 텍스트로 생성된 ID값이 들어간다.

 

# title 1
<!-- 내용 1-->
## sub-title 1
<!-- ... -->
## sub-title 2
<!-- ... -->
# title 2
<!-- 내용 2 -->

 

<ul>
  <li>
    <p><a href="#title-1">title 1</a></p>
    <ul>
      <li><a href="#sub-title-1">sub-title 1</a></li>
      <li><a href="#sub-title-2">sub-title 2</a></li>
    </ul>
  </li>
  <li><a href="#title-2">title 2</a></li>
</ul>

 

tableOfContents를 쿼리를 통해 불러온 다음, 원하는 부모 컴포넌트에 React의 'dangerouslySetInnerHTML'을 이용해 값을 설정하면 TOC는 생성이 된다. 하지만 아직 본문의 소제목들(<h> 태그들)에 ID가 설정되어 있지 않기 때문에 링크는 작동하지 않는다.

이 때 마크다운에서 일일이 제목들에 ID를 추가하여 기능을 완성할 수도 있겠지만, gatsby-remark-autolink-headers 플러그인을 사용하면 제목 텍스트를 kebab-case로 변환한 ID값(tableOfContents의 href 값과 일치), 링크 아이콘 등이 자동으로 추가되고 각종 옵션들도 사용할 수 있어서 여러모로 편리하다.

gatsby-remark-autolink-headers

gatsby-remark-autolink-headers는 gatsby-transformer-remark의 sub-플러그인으로 설치 과정은 매우 간단해서 링크를 참고하면 될 것 같다. 플러그인을 설치하고 다시 개발 서버를 실행시켜보면 마크다운 본문에 해당하는 HTML이 다음과 같이 변경된 것을 확인할 수 있다.

 

<h1 id="title-1" style="position:relative;">
  title 1
  <a href="#title-1" aria-label="title 1 permalink" class="anchor after">
    <svg>
    <!-- 자동 추가된 아이콘 (생략)  -->
    </svg>
  </a>
</h1>

옵션

위 HTML에서 <a> 태그에 "anchor after"라는 클래스가 삽입된 것은 플러그인 옵션 중 isIconAfterHeader 값을 true로 주었기 때문이다. 또 자동으로 추가되는 기본 아이콘 외에 사용하고자 하는 아이콘이 있다면 icon 옵션을 설정하면 된다.

gatsby-remark-autolink-headers 플러그인은 이처럼 몇가지 옵션을 제공하는데, 그 중 offsetY 값은 스크롤 위치를 지정해준다. 예를 들어 상단 헤더 부분의 높이를 값으로 지정하면 원래 스크롤 위치 + 해당 값만큼으로 스크롤이 이동하여 TOC 링크 클릭시 제목 텍스트가 헤더에 가려지는 것을 방지할 수 있다.

 

문제점

그런데 위 설명은 사실 expected behavior이고 실제 작동은 이와 달라서 고치는 데 시간을 좀 보냈다. URL Fragment가 포함된 링크를 처음부터 새로 불러올 때는 예상대로 작동을 했지만, 페이지가 다 로드되고 난 뒤에 TOC 링크를 클릭해보면 offsetY가 적용되지 않은 위치로 스크롤이 이동됐다.

플러그인의 gatsby-browser.js 파일을 보면 스크롤 위치를 업데이트하는 과정을 알 수 있다. 여기에서 export된 함수들은 Gatsby Browser API 문서에서 자세한 내용을 확인할 수 있다.

 

let offsetY = 0

const getTargetOffset = hash => {
  // URL Fragment(#)를 가지고
  // 이동할 스크롤 위치를 리턴하는 함수
}

// 최초 랜더링
exports.onInitialClientRender = (_, pluginOptions) => {
  // 플러그인에 offsetY 옵션을 주었다면 기억해둠
  if (pluginOptions.offsetY) {
    offsetY = pluginOptions.offsetY
  }

  requestAnimationFrame(() => {
    const offset = getTargetOffset(window.location.hash)
    if (offset !== null) {
      window.scrollTo(0, offset)
    }
  })
}

// 이후 스크롤 위치 업데이트
exports.shouldUpdateScroll = ({ routerProps: { location } }) => {
  const offset = getTargetOffset(location.hash)
  return offset !== null ? [0, offset] : true
}

 

shoudUpdateScroll의 문서를 보면 리턴값이 true인 경우 브라우저의 default behavior대로, false인 경우 스크롤 이동하지 않음, 그리고 배열인 경우 해당 값으로 스크롤 이동을 한다고 되어 있다. 하지만 설명과 달리 배열을 리턴하는 경우에 넘겨준 offset 값으로 스크롤이 이동되지 않았다(관련 Github 이슈).

해결 방법

문제를 해결하기 위한 방법을 소개한다. 현재 코드처럼 offsetY 값을 스크롤 위치를 계산하는 데 사용하는 것이 아니라, 아예 <h>의 자식으로 <a> 태그를 삽입하여 id를 주고, style을 이용해 <h>로부터 offsetY 만큼 상단에 위치하도록 하는 것이다.

 

<h1 style="position:relative;">
  <a id="title-1" style="position: absolute; top: -72px"></a>
  title 1
  <a href="#title-1" aria-label="title 1 permalink" class="anchor after">
    <svg>
    <!-- 자동 추가된 아이콘 (생략)  -->
    </svg>
  </a>
</h1>

 

이렇게 함으로써 얻을 수 있는 장점은 별도의 계산 과정 없이 <h> element가 우리가 원하는 위치에 오도록 스크롤을 이동시킬 수 있다는 것이다. 브라우저의 Default Behavior를 사용하기 때문에 플러그인의 gatsby-browser.jsgatsby-ssr.js에 포함된 스크롤에 관여하는 코드들을 모두 없애도 된다.

플러그인의 index.js 파일을 조금 수정하면 원하는 결과를 얻을 수 있다. (기존 코드)

 

// imports 생략

// helper function
function patch(context, key, value) {
  if (!context[key]) {
    context[key] = value
  }

  return context[key]
}

module.exports = (
  { markdownAST },
  {
    // ...
    offsetY = 0, // plugin의 offsetY 값
  }
) => {
  slugs.reset()

  visit(markdownAST, `heading`, node => {
    // depth에 따른 return 생략
    // id 생성 과정 생략

    const data = patch(node, `data`, {})


    /* <h>에는 더 이상 id를 주지 않으므로 주석 처리
      // patch(data, `id`, id)
      // patch(data, `htmlAttributes`, {})
      // patch(data.htmlAttributes, `id`, id)
      // patch(data.hProperties, `id`, id)
    */

    // style: 'position: relative' 의 경우 원래 icon이 false가 아닐 경우 삽입되는데,
    // <a> 태그의 위치를 absolute로 설정하기 위해 항상 삽입되도록 변경
    patch(data, `hProperties`, {})
    patch(data.hPropperties, `style`, `position:relative;`);

    // 현재 node(<h>)의 자식으로 <a> 노드 삽입
    node.children.unshift({
      type: "link",
      url: "",
      title: null,
      children: [],
      data: {
        hProperties: {
          id,
          style: `position: absolute; top: -${offsetY}px`,
        }
      }
    })

    if (icon !== false) {
      // patch(data.hProperties, `style`, `position:relative;`)

      // icon 삽입 생략
    }
  })

  return markdownAST
}

내용 추가

Material UI v4의 공식문서(링크)가 위와 같은 방식으로 되어있어서 참고했었는데, 현 시점에서 다시 보니 v5 문서는 이보다 간단하게 CSS의 scroll-margin-top 속성(링크)을 사용하여 상단 헤더 부분을 제외한 위치로 스크롤이 이동되도록 한 것을 확인했다.

 

<h> element와 랜더링 되지 않는 <a>position 속성을 이용하는 것보다 더 자연스럽고 좋은 방법인 듯하다.