요약: 게시글 본문의 h2~h4 제목을 읽어 목차를 자동 생성하고, 읽는 위치를 강조하며, 화면 폭에 따라 접힘/우측 고정으로 보여주는 기능을 라이브러리 없이 만들었다. 서버 렌더링은 건드리지 않고
post-toc.js하나와 CSS 한 블록이면 된다.
"블로그기능을 하나씩 직접 만들기" 두 번째 글이다. 이번 주제는 긴 글에서 꼭 필요한 목차(TOC) 다. 본문은 TinyMCE로 쓴 HTML이라 제목 태그(h2~h4)가 이미 들어 있다. 이 제목을 그대로 목차로 쓰면 작성자가 따로 신경 쓸 것이 없다.
설계: 서버가 아니라 브라우저에서 만든다
목차 데이터를 서버에서 따로 만들려면 본문을 파싱해야 하고 캐시도 신경 써야 한다. 대신 브라우저가 이미 가진 DOM에서 제목을 읽어 목차를 만들기로 했다.
- 서버: 본문 영역(
.board-post-content)과 진행바 마크업, 스크립트 태그만 내려준다. - 브라우저:
post-toc.js가 제목을 수집해<nav>를 만들고 본문 앞에 끼워 넣는다.

목차 만들기
제목을 모으되 텍스트가 빈 제목은 뺀다. 2개 미만이면 목차가 의미 없으므로 아무것도 만들지 않는다.
// 목차 — 제목이 2개 미만이면 만들지 않는다
var headings = Array.prototype.slice.call(content.querySelectorAll('h2, h3, h4'))
.filter(function (h) { return h.textContent.trim() !== ''; });
if (headings.length < 2) {
return;
}
각 제목에는 앵커로 쓸 id 가 필요하다. 에디터가 id를 넣어 둔 제목은 그대로 쓰고, 없는 것만 toc-N 을 붙인다. 레벨(h2/h3/h4)은 li 의 클래스(post-toc__h3)로 남겨 CSS에서 들여쓰기만 다르게 준다.
headings.forEach(function (h, i) {
if (!h.id) {
h.id = 'toc-' + (i + 1);
}
var li = document.createElement('li');
li.className = 'post-toc__' + h.tagName.toLowerCase();
var a = document.createElement('a');
a.href = '#' + h.id;
a.textContent = h.textContent.trim();
li.appendChild(a);
list.appendChild(li);
links.push(a);
});
링크 텍스트는 textContent 로 넣었다. 제목에 HTML이 섞여 있어도 innerHTML 처럼 해석되지 않아 XSS 걱정이 없다.
접힘과 펼침: details + 화면 폭
목차는 <details> 로 감쌌다. JS 없이도 열고 닫히고, 키보드와 스크린리더 접근성도 기본으로 따라온다. 처음 열림 상태만 화면 폭으로 정한다.
details.open = window.matchMedia('(min-width: 1400px)').matches;
1400px 이상에서는 본문 오른쪽 여백에 고정하고 펼쳐 둔다. 그보다 좁으면 본문 위에 접힌 채로 놓는다.
@media (min-width: 1400px) {
.post-toc {
position: fixed;
top: 6rem;
right: max(1rem, calc((100vw - 1320px) / 2 - 17rem));
width: 16rem;
max-height: calc(100vh - 8rem);
overflow-y: auto;
margin: 0;
}
}
right 는 본문 컨테이너(1320px) 바깥 여백을 계산해 목차가 본문과 겹치지 않게 하고, 화면이 아슬아슬하면 max() 로 최소 1rem 을 보장한다. 목차가 길어도 max-height + overflow-y: auto 로 화면을 넘지 않는다.

지금 읽는 제목 강조
IntersectionObserver 로 제목이 화면 위쪽 30% 영역에 들어오면 해당 링크에 aria-current 를 건다. rootMargin 을 0px 0px -70% 0px 로 줘서 화면 아래 70%를 감지 영역에서 뺀 것이다.
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) {
return;
}
var idx = headings.indexOf(entry.target);
links.forEach(function (a, n) {
if (n === idx) {
a.setAttribute('aria-current', 'true');
} else {
a.removeAttribute('aria-current');
}
});
});
}, { rootMargin: '0px 0px -70% 0px' });
스타일을 .active 같은 임의 클래스가 아니라 aria-current 속성에 걸었다. 시각 강조와 접근성 정보가 한 속성으로 같이 가기 때문이다. IntersectionObserver 가 없는 브라우저는 이 부분만 건너뛰고 목차 자체는 동작한다.
제목으로 이동할 때 상단에 붙어 답답하지 않도록 scroll-margin-top: 1rem 을 제목에 줬다.
덤: 읽기 진행바
같은 스크립트에서 상단 3px 진행바도 갱신한다. 본문 영역을 얼마나 지났는지를 비율로 계산하고, 접근성을 위해 role="progressbar" 와 aria-valuenow 도 같이 갱신한다. 스크롤 핸들러는 passive: true 로 등록해 스크롤 성능을 해치지 않게 했다.
정리
- 제목 태그가 이미 있는 HTML이라면 목차는 서버 작업 없이 DOM에서 만드는 것으로 충분하다.
details,aria-current,matchMedia,IntersectionObserver같은 브라우저 기본 기능만으로 라이브러리 없이 끝난다.- 목차는 제목이 2개 이상일 때만 만들고, 좁은 화면에서는 접어 둬 본문을 밀어내지 않게 한다.
실제 적용중인 모습


댓글 0개