next js에서 TOC(Table Of Contents) 만들기
Intersection observer를 사용하여 TOC 구현
TOC
TOC는 Table of Contents의 약자로 아티클의 목차입니다.
TOC가 있는 아티클은 전체적인 컨텐츠를 확인하고, 현재 어느 단락을 읽고 있는지를 파악할 수 있습니다.
또한 다른 단락으로의 이동을 간편하게 해줍니다.
이번 프로젝트에선 md문서를 html로 파싱하여 <h>태그를 찾은 다음 <section>태그로 단락을 나누고 intersection observer를 사용하여 TOC를 구현하였습니다.
소소한 목표..
이번 목표는 최초 렌더링되는 html에 TOC가 포함되어 seo와 ux를 고려하는 것입니다.
intersection observer
를 사용하기 때문에 RCC
로 동작하지만, 서버에서 미리 정의한 로직에 따라 사용되는 state의 initial state
를 내려줌으로써 lazy loading을 제거해 UX
를 향상시키고 SEO
를 개선하고 싶었습니다.
Heading 추출하기
notion sdk를 사용하여 md문서 불러오기
저는 노션에서 작성한 페이지를 notion sdk를 사용하여 불러오고 있습니다.
우선 서버에서 아티클의 segment인 slug로 해당 게시글의 id를 찾은 다음, id로 아티클의 md파일을 불러옵니다.
//use notion sdk
const getNotionArticleData = unstable_cache(async (id: string) => {
const mdblocks = await n2m.pageToMarkdown(id)
const mdString = n2m.toMarkdownString(mdblocks)
return mdString.parent
})
export async function getArticleData(slug: string) {
const page = await getSlugPage(slug)
const post = await getNotionArticleData(String(page.id))
if (!post) {
throw new Error("Notion data not found")
}
const { headings } = await getArticleHeadings(post)
return { post, headings }
}
markdown을 html로 변환하기
불러온 markdown형식의 게시글을 getArticleHeadings
함수를 사용하여 html로 변환하고 <h>태그를 추출합니다.
md 문서는 unified
, remark
, rehype
를 사용하여 html로 변환할 수 있습니다.
async function getArticleHeadings(post: string) {
const result = await unified()
.use(remarkParse) // md -> AST
.use(remarkRehype) // md AST -> html
.use(rehypeSlug) // add id to <h> tag
.use(rehypeExtractHeadings) // <h> 태그 추출
.use(rehypeStringify) // 문자열로 변환
.process(post) //변환할 md문서
return {
headings: result.data.headings as Heading[],
}
}
remarkParse
: 마크다운(MD) 문서를 Remark의 AST로 파싱합니다.remarkRehype
: Remark의 AST를 rehype의 HTML AST로 변환합니다.
마크다운의 구조를 HTML의 구조로 변환합니다. 예를 들어,# Heading 1
은<h1>Heading 1</h1>
로 변환됩니다.rehypeSlug
: 모든 헤더 태그에 id 속성을 자동으로 추가하는 플러그인입니다.
<h1>Title</h1>
같은 헤딩 태그에 고유 id를 부여하여,id="title"
속성을 추가합니다. 이를 통해 각 헤딩이 링크할 수 있는 대상이 됩니다.rehypeExtractHeadings
: 커스텀 rehype 플러그인으로, HTML AST에서<h1>
,<h2>
,<h3>
태그를 찾아서 헤딩의id
, 텍스트 내용, 레벨(헤딩의 깊이)을 추출합니다.rehypeStringify
: HTML AST를 문자열로 변환하는 플러그인입니다.
visit 함수로 tree를 순회하며 <h>태그 찾기
rehypeExtractHeadings
의 인자인 tree
와 file
은 rehype
unified 파이프라인에서 호출될 때, 자동으로 전달받는 인자입니다.
tree
:tree
는rehype
가 파싱한 html문서로, AST 형태로 제공됩니다.file
:VFile
객체로, 파일 관련 메타 데이터 등을 포함할 수 있습니다.visit
:visit
함수는 트리구조인 html을DFS
로 탐색하는 함수입니다. 때문에 트리의 자식뿐 아니라 리프노드까지 트리의 모든 노드를 재귀적으로 탐색합니다.
visit
으로 html
의 모든 노드를 탐색하며 <h>태그를 찾아 필요한 메타 데이터 저장하고 이를 file
에 추가합니다.
import { Node } from "unist"
import { visit } from "unist-util-visit"
import { VFile } from "vfile"
type Heading = {
id: string
textContent: string
level: number
}
export function rehypeExtractHeadings() {
return (tree: Node, file: VFile) => {
const headings: Heading[] = []
const extractText = (node: Node): string => {
if (node.type === "text") {
return (node as any).value as string
}
if ("children" in node && Array.isArray(node.children)) {
return node.children.map(extractText).join("")
}
return ""
}
visit(tree, "element", (node: Element) => {
if (["h1", "h2", "h3"].includes(node.tagName)) {
const id = node.properties?.id as string
const textContent = extractText(node)
headings.push({
id,
textContent,
level: parseInt(node.tagName.replace("h", ""), 10),
})
}
})
file.data = { headings }
}
}
section 추가하기
아티클 렌더 컴포넌트에서 getArticleData
함수를 호출하여 추출한 headings
객체와 post를 렌더링 합니다.
import Markdown from "react-markdown"
<Markdown
remarkPlugins={[remarkBreaks, remarkGfm, remarkEscapeHtml]}
rehypePlugins={[rehypeSlug, rehypeHighlight, rehypeRaw, rehypeSection]}
components={{
// 렌더링 컴포넌트
}}
>
{post}
</Markdown>
react-markdown
은 md파일을 간편하게 react component
로 변환해주는 라이브러리입니다.
getArticleHeadings
함수에서 변환한 html은 react-markdown
라이브러리에서 렌더링 할 수 없습니다. 이미 html 문자열로 변환되었기 때문에 라이브러리를 사용하면 한 번 더 변환되어 제대로된 렌더링이 불가합니다.
때문에 렌더링 컴포넌트에서 plugin을 사용하여 다시 html로 파싱해줍니다….
꺽새(<, >)를 문자열로 치환하기
rehypeRaw
플러그인은 Markdown
을 HTML
로 변환할 때 HTML
태그를 랜더링하는 방법을 제어하는 플러그인입니다.
md파일에 작성된 #### heading 4
를 리액트 컴포넌트로 렌더링 하면 해당 md문법은 html로 파싱되어 <h4> heading 4 </h4>
로 나타나게 됩니다.
rehypeRaw
를 react-markdown
에 추가하면 HTML 코드가 특수 문자로 이스케이프되어 heading 4
가 태그네임 없이 올바르게 랜더링됩니다.
그렇기 때문에, 노션에서 꺽새(<, >)태그를 사용하여 텍스트를 작성하게 되면, 텍스트인 <h4>heading 4</h4>
가 실제 <h4> 요소로 렌더링 됩니다.
md 문서가 html로 파싱되기 전 텍스트로 작성된 모든 태그네임을 찾아 문자로 변환해주어야 합니다.
export function remarkEscapeHtml() {
return (tree: Root) => {
visit(tree, "html", (node: Html) => {
if ("value" in node && typeof node.value === "string") {
node.value = node.value.replace(/</g, "<").replace(/>/g, ">")
}
})
visit(tree, "list", (node: List) => {
node.children.forEach((child: ListItem) => {
visit(child, "text", (textNode: Text) => {
textNode.value = textNode.value
.replace(/</g, "<")
.replace(/>/g, ">")
})
})
})
}
}
text로 작성된 모든 꺽새(<, >)태그를 찾아 문자로 치환하는 plugIn을 만들어 추가해주었습니다.
<section>태그 추가하기
렌더링을 위해 html로 파싱하는 과정에서 rehypeSection
커스텀 plugIn을 만들어 <section> 태그를 추가해줍니다.
<h>태그와 그 하위 다른 태그들을 하나의 <section>으로 묶지 않고 <h>태그만 intersection observer
로 관찰하면 뷰포트 내에 <h>태그가 존재하지 않을 때, TOC에 현재 보고 있는 단락이 표시되지 않게 됩니다.
<h>태그의 하위 태그들을 하나의 <section>으로 묶고 <h>태그와 동일한 id를 부여하여
intersection observer
가 <section> 태그 전체를 주시하게 해야합니다.
export const rehypeSection = () => {
return (tree: Parent) => {
const newChildren: Element[] = []
let currentSection: Element | null = null
tree.children.forEach((node: Node) => {
if (
isElement(node, "h1") ||
isElement(node, "h2") ||
isElement(node, "h3")
) {
if (currentSection) {
newChildren.push(currentSection)
}
currentSection = {
type: "element",
tagName: "section",
properties: { id: node.properties?.id },
children: [node],
}
} else if (currentSection) {
currentSection.children.push(node as Element)
} else {
newChildren.push(node as Element)
}
})
if (currentSection) {
newChildren.push(currentSection)
}
tree.children = newChildren
}
}
이전 rehypeExtractHeadings
플러그인 함수와 동일하게 tree를 인자로 받습니다.
tree를 탐색할 때 DFS를 사용하는 visit
이 아닌 forEach로 <h>태그를 찾습니다.
모든 노드를 재귀적으로 탐색하는 visit
에 반해 forEach는 루트의 자식 노드만 탐색합니다. 자식의 자식 노드까지는 탐색할 수 없습니다.
아티클 컴포넌트 렌더링
react-markdown
을 통해 아티클을 렌더링 할 때 rehypeSection
플러그인에서 생성한 <section>태그와 id를 사용합니다.
<Markdown
remarkPlugins={[remarkBreaks, remarkGfm, remarkEscapeHtml]}
rehypePlugins={[rehypeSlug, rehypeHighlight, rehypeRaw, rehypeSection]}
components={{
section: ({ id, children }) => <section id={id}>{children}</section>,
h1: ({ id, children }) => (
<h1 id={id} className={styles.h1}>
{children}
</h1>
),
h2: ({ id, children }) => (
<h2 id={id} className={styles.h2}>
{children}
</h2>
),
h3: ({ id, children }) => (
<h3 id={id} className={styles.h3}>
{children}
</h3>
)
}}
>
{post}
</Markdown>
heading의 하위 요소를 <section>태그를 사용해 하나의 단락으로 묶고, id를 부여함으로써 intersection observer
가 감지할 element를 만들어주었습니다.
이제 intersection observer
에 관찰할 element를 등록하면 됩니다..!!
요소 관찰하기
TOC컴포넌트에서는 <section>을 관찰하는 커스텀 훅인 useScrollSpy
를 호출합니다.
서버에서 받아온 initialHeadings
(id, text, level이 포함된 배열)를 인자로 넘겨줍니다.
export function TableOfContents({ initialHeadings }: TableOfContentsProps) {
const { activeIndexs } = useScrollSpy(initialHeadings)
return (
// toc list 렌더링
)
}
Intersection Observer란
IntersectionObserver 인터페이스는 대상 요소와 상위 요소, 또는 대상 요소와 최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우 이를 비동기적으로 감지할 수 있는 Web API입니다.
new IntersectionObserver((entries) ⇒ callbackFn(entreis));
형태로 사용합니다.
new
연산자를 사용하여 객체를 만들고, 주시하는 요소가 뷰포트에 교차 될 때 비동기적으로 callbackFn
을 호출합니다. 교차된 entries(관찰자 목록)는 배열의 형태로 받을 수 있습니다.
관찰할 요소는 IntersectionObserver.observe(targetElement);
로 설정할 수 있습니다.
생성한 객체의 observe
메서드를 사용하여 인자로 넘겨주면 됩니다.
관찰을 끝내고 싶다면 IntersectionObserver.unobserve(targetElement);
를 호출하면 됩니다.
useScrollSpy
해당 훅에서 intersection observer
를 사용하여 <section>이 뷰포트에서 교차 될 때를 비동기적으로 감지할 수 있습니다.
useEffect(() => {
const headingElements = initialHeadings.map(
(heading) => document.getElementById(heading.id) as Element,
)
// DOM 요소를 참조하고 상태 업데이트
ElementRef.current = headingElements.filter((el) => el !== null)
const observer = new IntersectionObserver((entries) => {
handleIntersectionHeader(entries)
})
ElementRef.current.forEach((heading) => observer.observe(heading))
return () => {
ElementRef.current.forEach((heading) => observer.unobserve(heading))
}
}, [initialHeadings])
훅 내부에서 initialHeadings
가 변할 때 마다 IntersectionObserver
를 생성하고, 관찰할 section을 설정해주었습니다.
initialHeadings
배열에 존재하는 heading의 id를 이용하여 section Element
를 찾고 ref변수에 할당합니다. 이후 배열을 순회하며 관찰할 대상으로 등록하고, 해당 요소가 뷰포트에 교차될 때handleIntersectionHeader
가 호출되도록 하였습니다.
function addIntersectingEntries(entries: IntersectionObserverEntry[]) {
const intersectingEntries = entries.filter((entry) => entry.isIntersecting)
if (intersectingEntries.length) {
setActiveIndexs((prev) => [
...prev,
...intersectingEntries.map((entry) =>
ElementRef.current.indexOf(entry.target),
),
])
}
}
function removeUnintersectingEntries(entries: IntersectionObserverEntry[]) {
const unIntersectingEntries = entries.filter(
(entry) => !entry.isIntersecting,
)
if (unIntersectingEntries.length) {
setActiveIndexs((prev) =>
prev.filter(
(index) =>
!unIntersectingEntries.some(
(entry) => ElementRef.current.indexOf(entry.target) === index,
),
),
)
}
}
function handleIntersectionHeader(entries: IntersectionObserverEntry[]) {
addIntersectingEntries(entries)
removeUnintersectingEntries(entries)
}
감지된 관찰자목록은 IntersectionObserverEntry[]
형태로 넘겨집니다. 또한, 뷰포트에 새로 등장한 것과 사라진 것을 나누지 않고 변동된 모든 요소를 하나의 배열에 담아 주기 때문에, isIntersecting
메서드를 사용하여 필터링해줘야 합니다.
addIntersectingEntries
함수에서 변경된 요소 중 뷰포트에 새로 들어온 요소만을 필터링하고 activeIndexs
배열에 추가하여 상태를 업데이트 합니다.
removeUnintersectingEntries
함수에선 반대로 뷰포트에서 사라진 요소를 필터링하여 상태를 업데이트합니다.
export function TableOfContents({ initialHeadings }: TableOfContentsProps) {
const { activeIndexs } = useScrollSpy(initialHeadings)
return (
<aside className="aside-container">
<CustomTypography>목차</CustomTypography>
<nav>
<ul>
{initialHeadings.map((heading, index) => (
<li
key={index}
style={{ paddingLeft: `${(heading.level - 1) * 10}px`}}
className={`heading-list ${
activeIndexs.includes(index) ? "active" : "" }`}
>
<a
href={`#${heading.id}`}
onClick={(e) => handleScroll(e, heading.id)}
>
{heading.textContent}
</a>
</li>
))}
</ul>
</nav>
</aside>
)
}
useScrollSpy
를 호출한 렌더링 컴포넌트에선 activeIndexs
상태를 리턴받고, 이 배열에 포함된 lndex에 active
클래스네임을 추가해줍니다.
이전에 트리를 순회하며 지정한 heading의 level에 따라 padding의 값을 조절하였습니다.
또한 이동하고 싶은 목차를 클릭 했을 때, 해당 위치로 스크롤이 이동되어야 하므로 앵커 태그에 href=heading.id
속성을 추가합니다.
const handleScroll = (
e: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
id: string,
) => {
e.preventDefault()
const targetElement = document.getElementById(id)
if (targetElement) {
const headerOffset = 50
const elementPosition = targetElement.getBoundingClientRect().top
const offsetPosition = elementPosition + window.pageYOffset - headerOffset
window.scrollTo({
top: offsetPosition,
behavior: "smooth",
})
window.history.replaceState(null, "", `#${id}`)
}
}
dom에서 이동할 타겟의 위치를 찾습니다.
헤더 위치의 오프셋을 계산하여 위치를 정하고, 부드럽게 이동되기 위해 기본 동작을 차단한 후 behavior: "smooth"
옵션을 추가하여 widow.scrollTo()
를 호출합니다.
e.preventDefault()
를 호출하였기 때문에 fragment 식별자(#)가 자동으로 업데이트 되지 않습니다. window.history.replaceState()
를 사용하여 url에 fragment 식별자를 추가해주어야 합니다.
회고
칸세쟈
멋진 TOC가 완성되었습니다. 일단 전 만족합니다.
aside에 TOC를 배치하지 않고, 아티클의 최상단에만 배치하고 싶다면 remark-toc 플러그인을 사용하면 됩니다.
처음 만들었을 때, section 태그를 추가하지 않고 heading만을 주시하도록 구현했더니 있으니만 못한 TOC가 되어버려… 꾸역꾸역 section 태그를 추가하고 로직을 변경했습니다.
더 좋은 방법이 있겠지만, 전 생각해내지 못했습니다.
infinite scroll을 구현하며 react-intersection-observer 라이브러리를 설치하여 사용하고 있었기에, 해당 라이브러리를 그대로 쓱싹 하고싶었습니다.
하지만 주시하는 요소를 배열로 설정하지 못해서 web api를 사용하여 깡으로 만들게 되었습니다.
또한, 서버에서 heading 요소를 추출하며 html로 변환한 걸 react-markdown에서 사용하지 못했습니다. react-markdown은 children으로 md문서를 넘겨줘야 하니까요.
이미 render compenent를 모두 만들고 react-markdown에 연결해두었기 때문에… 깡 html을 렌더하고 싶지 않았습니다.
css를 잘 했다면 md를 html로 2번 파싱하는 행위는 하지 않아도 됐겠죠…. 일단 전 만족합니다.