Harry Park's Blog

Gatsby Blog에 목차 추가하기

이 포스트에서는 Table of Contents(목차) Component를 만들고 본문 우측 상단에 위치시키는 과정에 대해서 알아본다.

TableOfContent 필드 확인

각 markdown 문서의 목차는 이미 gatsby-transformer-remark플러그인에 의해서 HTML로 만들어져 있다. 이 플러그인은 gatsby-starter-blog에 기본적으로 들어 있는 플러그인으로 markdown문서를 HTML 문서로 변환해주는 역할을 한다.

{
  allMarkdownRemark {
    nodes {
      id
      fields {
        slug
      }
      tableOfContents // highlight-line
    }
  }
}

GraphQL 콘솔에서 위와 같이 쿼리해보면 노드의 tableOfContents 필드에 HTML 이 들어 있는 것을 알 수 있다. 스타일은 적용되어 있지 않으며, <a href='/slug/#header-title'> 형태의 링크까지 제공된다.

어떤 블로그에서는 결과물로써 HTML이 나오기 때문에 이 것을 활용하려면 파싱이 필요하다 한다. HTML이 아닌 데이터구조로 목차가 제공되는 mdx로의 전환을 고려하는 방법도 있다. 아마 특정 UI framework을 적용 했을 경우 제공되는 ListListItem류의 Component를 사용하길 원해서일 것이라 추측된다.

하지만 내 생각은 조금 다르다. 결과적으로 그런 Component가 최종적으로 만들어내는 결과물은 은 결국 <ul><li>이기 때문에 다를 바가 없다. 목차는 고 수준의 기능을 요하는 것이 아니기에 간단한 스타일만 입혀 준다면 그 결과는 같다.

{
  "id": "26d3eb03-c789-568f-b537-6803e3c98ec0",
  "fields": {
    "slug": "/2020/12/03/add-table-of-content-gatsby/"
  },
  "tableOfContents": "<ul>\n<li><a href=\"/2020/12/03/add-table-of-content-gatsby/#%EC%BF%BC%EB%A6%AC%EB%A5%BC-%ED%86%B5%ED%95%B4%EC%84%9C-tableofcontent%EB%A5%BC-%ED%99%95%EC%9D%B8%ED%95%98%EC%9E%90\">쿼리를 통해서 tableOfContent를 확인하자</a></li>\n<li><a href=\"/2020/12/03/add-table-of-content-gatsby/#%EB%AA%A9%EC%B0%A8-%EC%BB%B4%ED%8D%BC%EB%84%8C%ED%8A%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0\">목차 Component 만들기</a></li>\n<li><a href=\"/2020/12/03/add-table-of-content-gatsby/#%EB%AA%A9%EC%B0%A8%EB%A5%BC-blog-post-template%EC%97%90-%EB%B6%99%EC%9D%B4%EA%B8%B0\">목차를 blog post template에 붙이기</a></li>\n</ul>"
}

Table of Contents Component 만들기

tableOfContents 필드에서 제공하는 HTML을 그대로 사용할 수 있는 React Component를 사용한다.

React Component 생성

import React from "react"

const TableOfContents = ({ content }) => {
  return (
    <div
      // 스타일링을 위해서 클래스이름 부여 한다.
      className="table-of-content"
      // dangerouslySetInnerHTML는 보안 관점에서 위험하지만
      // innerHTML을 사용하겠다는 뜻이다.
      dangerouslySetInnerHTML={{ __html: content }}
    />
  )
}

export default TableOfContents

스타일 적용

React Component에 스타일을 적용하는 방법은 여러가지가 있다.

  • style.css 같은 global CSS파일을 수정하는 전통적인 방법.
  • CSS-module: Component를 위한 CSS를 작성하고 그 것을 각 Component에서 import하는 방식.
  • CSS-in-JS: 코드 안에서 스타일을 작성는 방식.

어떤 것을 사용해도 상관없지만 적어도 CSS를 고수하는 사람이 있다면 SCSS을 사용을 추천한다. CSS의 superset으로 CSS를 그대로 사용할 수 도 있으며, 다음과 같은 계층구조를 가진 CSS 작성이 가능하다. 계층구조에서는 .table-of-content을 반복적으로 쓰지 않아도 된다. (플러그인: gatsby-plugin-sass)

.table-of-content {
  ul {
    margin-left: "0px";
    li {
      color: blue;
      a {
        decoration: none;
      }
    }
  }
}

우리는 미리 만들어진 HTML에 스타일을 적용해야 한다는 점이 특히 중요하다. React Component를 생성 시점에 <ul>, <li> 각각에 className 혹은 style을 할당하는 일반적인 상황이 아니다. 따라서 위의 예제와 같이 .table-of-contents 클래스를 기준으로 내부에 있는 ulli 그리고 a까지 스타일을 설정해야 한다.

만든 Component 적용하기

이제 TableOfContents Component를 가져다 쓰기만 하면 되는데 여기서부터 HTML와 css에 대한 약간의 지식이 필요하다.

레이아웃 수정

우선 기본으로 구성되어 있던 레이아웃을 다음과 같이 변경하고자 한다.

layout

포스트의 헤딩, 본문, 풋터 전체를 감싸고 있던 컨테이너인 <article> 외부에 flexbox 컨테이너를 만들어 감싼다. 그 후 <article>을 하나의 flex item으로 만들고, 새로 추가하는 <TableOfContents> Component도 flex item으로 만든다.

src/templates/blog-post.tsx:

import TableOfContents from "../components/table-of-contents"

const BlogPostTemplate: React.FC<PageProps<BlogPostBySlugQuery>> = ({
  data,
  location,
}) => {
  return (
    <Layout location={location} title={siteTitle}>
      <div className="blog-post-container">
        <div className="content">
          <article>...</article>
          <nav className="blog-post-nav">...</nav>
        </div>
        <TableOfContents content={data.markdownRemark.tableOfContents} />      </div>
    </Layout>
  )
}

export default BlogPostTemplate

export const pageQuery = graphql`
  query BlogPostBySlug(...) {
    ...
    markdownRemark(id: { eq: $id }) {
      ...
      tableOfContents
    }
    ...
  }
`

스타일 적용

목표한 레이아웃과 같게 태그를 구성하고, 쿼리문에는 tableOfContents 필드만 추가한다. 그리고 다음과 같이 대략적인 레이아웃을 잡기 위한 스타일을 추가한다.

.blog-post-container {
  display: flex;
  flex-wrap: wrap;
  flex-direction: row;

  .content {
    flex-grow: 0;
    max-width: calc(100% * 2 / 3);
    flex-basis: calc(100% * 2 / 3);
  }
  .table-of-content {
    top: 0;
    flex-grow: 0;
    max-width: calc(100% / 3);
    flex-basis: calc(100% / 3);
    margin-left: 1rem;
    max-width: 18rem;
    max-height: calc(100vh - 200px);
    position: sticky;    overflow: auto;
  }
}

한가지 눈 여겨봐야할 css속성은 .table-of-content에 쓰인 position: sticky이다. 페이지 전체를 스크롤 할때 같이 올라가다가 최상단에 이르면 더이상 위로 올라가지 않게 만들 때 쓰인다. 본문이 스크롤된다고 해서 목차 메뉴가 사라지면 매우 불편할 것이기 때문에 반드시 적용해야하는 속성이다.

이제 그림과 같이 목차 메뉴가 본문 우측에 배치되었다.

after

링크가 동작하도록 하는 방법

만들어진 목차의 아이템 중 하나를 클릭해 보면 브라우저의 주소창에 해시 부분이 변경되지만 아무일도 일어나지 않는 것을 알 수 있다. URL의 가장 뒤에 붙은 #해쉬와 같은 id를 가진 element가 있어야 하기 때문이다. 현재는 헤딩 태그(<h1>, <h2>) 에 id가 할당되어 있지 않기 때문이다.

그 작업을 해주는 플러그인이 gatsby-remark-autolink-headers이다. npm 또는 yarn으로 설치하고 다음과 같이 gatsby-config.js에 추가한다. 한가지 재미 있는 사실은 이 플러그인이 "플러그인의 플러그인"이라는 사실이다. 이게 가능하게 만들 것이라고는 상상하지도 못한 것인데 역시 Gatsby는 훌륭하다.

{
  resolve: `gatsby-transformer-remark`,
  options: {
    plugins: [
      // 생략
      `gatsby-remark-autolink-headers`,
    ],
  }
},

플러그인 설치 후 바뀐점 확인

모양부터 기능에 영향을 미치는 다양한 옵션이 설정 가능한 것처럼 보이지만 일단은 그냥 추가했다. 이제 목차의 아이템 중 하나를 클릭하면 해당 섹션으로 이동하는 모습을 볼 수있다. 플러그인 설치 후 달라진 것은 각 헤딩 제목 좌측에 링크를 뜻하는 듯한 아이콘이 추가된 것이다.

link icon added

그리고 markdown에 의해서 만들어진 헤딩 태그에 id가 부여된 것을 확인할 수 있다. 브라우저는 url 뒤에 붙어있는 해쉬 값을 읽고 그 값을 id로 가지고 있는 엘리멘트를 찾아 화면을 가장 상단에 보여주도록 스크롤한다.

header has id

끝.