Tistory 본문 글 목차 자동 생성하기
들어가며
예전부터 해야겠다고 생각만 하던 Tistory 본문 목차 생성 기능을 구현했다. 기능 자체가 어려운 편은 아니었지만, 여러 일정이이 겹치면서 계속 미뤄두고 있었다. 그러다가 여러 일정이 잘 마무리되어 정리를 마치고 문득 목차 생성 기능이 다시 떠올랐다. 글이 길어질수록 목차가 있는 편이 읽기 좋고, 나중에 내가 다시 글을 볼 때도 원하는 위치로 이동하기 편하겠다는 생각이 들었다. 그래서 미뤄두었던 기능을 구현했고, 구현한 김에 글로도 정리해보려 한다. 이번 글에서는 Tistory 본문에 있는 제목 태그를 기준으로 목차를 자동 생성하는 방법을 정리한다. 구현 방식은 단순하다. 본문 영역에서 제목 태그를 찾고, 해당 제목들을 목차 항목으로 변환한 뒤, 생성된 목차를 본문 상단에 삽입하면 된다.
1. 개요
목차 생성 기능은 기본적으로 DOM을 다루는 작업이다. Tistory 글 본문 안에는 제목처럼 보이는 텍스트들이 있고, 이 제목들은 보통 <h1>부터 <h6>까지의 heading 태그로 작성된다. 예를 들어 글 본문이 다음과 같은 구조라고 해보자.
<main id="article">
<h2>1. 개요</h2>
<p>본문 내용...</p>
<h2>2. 동작</h2>
<p>본문 내용...</p>
<h3>2.1. 제목 태그 파싱</h3>
<p>본문 내용...</p>
</main>목차 생성 스크립트는 #article 안에 있는 h2, h3 같은 제목 태그를 찾는다. 그리고 각 제목에 이동 가능한 id를 부여한 뒤, 해당 id를 가리키는 <a> 태그를 만들어 목차를 구성한다. 결과적으로는 다음과 비슷한 목차가 본문 상단에 생성된다.
<ul>
<li><a href="#1_개요">1. 개요</a></li>
<li><a href="#2_동작">2. 동작</a></li>
<li><a href="#2_1_제목_태그_파싱">2.1. 제목 태그 파싱</a></li>
</ul>Tistory에서 실제 본문 태그가 무엇인지 확인하려면 브라우저 개발자 도구를 사용하면 된다. 제목처럼 보이는 텍스트나 본문 영역에 마우스를 올리고 우클릭한 뒤 검사를 누르면 해당 요소의 태그 구조를 확인할 수 있다. 만약 우클릭이 막혀 있다면 개발자 도구를 직접 열어서 본문 영역을 찾아야 한다.
브라우저 설정에서 JavaScript를 비활성화하면 우클릭이 가능해지는 경우도 있지만, 다시 활성화해야 하므로 오히려 더 번거로울 수 있다.
이때 확인한 본문 선택자는 마지막 적용 방법에서 사용한다. Tistory 스킨마다 본문 영역의 클래스명이나 구조가 다를 수 있으므로, 본인 블로그의 실제 HTML 구조를 확인하는 것이 중요하다.
2. 동작 흐름
목차 생성 기능의 동작 흐름은 다음과 같다.
flowchart TB
A[본문 태그 찾기] --> B[본문 안의 제목 태그 파싱]
B --> C[제목 태그를 목차 노드로 변환]
C --> D[목차 HTML 생성]
D --> E[본문 상단에 목차 삽입]조금 더 구체적으로 보면 다음 순서로 동작한다.
- 웹 페이지에서 글 본문을 감싸는 태그를 찾는다.
- 본문 태그 안에 있는 제목 태그를 순서대로 파싱한다.
- 파싱한 제목 태그를 목차 항목으로 변환한다.
- 제목의 깊이에 따라 부모, 자식 관계를 구성한다.
- 목차 태그를 생성한다.
- 생성한 목차를 본문 상단에 삽입한다.
여기서 중요한 점은 제목 태그의 순서와 깊이를 유지하는 것이다. 단순히 h1, h2, h3를 모두 찾아서 나열하는 것만으로도 목차처럼 보이게 만들 수는 있다. 하지만 제목의 단계가 존재한다면 목차에서도 그 관계를 어느 정도 표현해주는 편이 좋다. 예를 들어 다음과 같은 제목 구조가 있다고 해보자.
1. 개요
2. 동작
2.1. 제목 태그 파싱
2.2. 목차 태그 생성
3. 적용 방법이 구조에서 2.1. 제목 태그 파싱과 2.2. 목차 태그 생성은 2. 동작의 하위 항목으로 표현되는 것이 자연스럽다. 그래서 제목의 태그 이름에서 깊이를 구하고, 그 깊이를 기준으로 부모와 자식 관계를 구성하도록 했다.
3. 코드 설명
소스코드와 데모 페이지는 아래 링크에 남겨두었다.
코드는 크게 TocContent와 TocMaker 두 클래스로 구성했다. TocContent는 목차 항목 하나를 나타내는 클래스이다. 본문 안에 있는 제목 태그 하나가 TocContent 하나로 변환된다. TocMaker는 실제 목차를 생성하는 클래스이다. 본문에서 제목 태그를 찾고, TocContent 목록을 만든 뒤, 최종적으로 화면에 삽입할 목차 태그를 생성한다.
3.1. TocContent
TocContent는 목차 항목 하나를 표현하기 위한 클래스이다.
class TocContent {
id;
href;
depth;
text;
parent = null;
children = [];
constructor(element, index) {
const fallbackId = element.textContent.trim().replaceAll('.', '_').replace(/\s+/g, '_') || `heading_${index}`;
element.id = element.id || `${fallbackId}_${index}`;
this.id = element.id;
this.href = `#${element.id}`;
this.depth = Number(element.tagName.replace('H', ''));
this.text = element.textContent;
}
}각 속성의 의미는 다음과 같다.
id: 제목 태그에 부여할 고유 식별값이다. 목차 항목을 클릭했을 때 해당 제목 위치로 이동하려면 제목 태그에id가 있어야 한다. 기존 제목 태그에 이미id가 있다면 그대로 사용하고, 없다면 제목 텍스트를 기반으로 새로 생성하도록 했다.href: 목차 항목에서 사용할 링크 값이다. 예를 들어 제목 태그의id가2_동작_1이라면 목차 링크는#2_동작_1이 된다.depth: 제목의 깊이를 나타낸다.<h1>이면1,<h2>이면2,<h3>이면3이 된다. 이 값을 기준으로 목차 항목의 부모, 자식 관계를 판단한다.text: 목차에 표시할 텍스트이다. 제목 태그의textContent를 그대로 사용한다.parent: 현재 목차 항목의 부모 목차 노드이다. 예를 들어<h3>제목이<h2>제목 아래에 있다면,<h2>에 해당하는TocContent가 부모가 된다.children: 현재 목차 항목의 자식 목차 노드 목록이다.<h2>아래에 여러 개의<h3>제목이 있다면, 해당<h3>목차 항목들이children에 들어간다.
처음에는 제목 텍스트에서 .과 공백만 _로 바꿔 id를 만들었다. 다만 같은 제목이 여러 번 등장하면 같은 id가 만들어질 수 있기 때문에, 위 코드에서는 index를 함께 붙여 중복 가능성을 줄였다. 목차 링크는 결국 id를 기준으로 동작하므로, 같은 페이지 안에서 id가 중복되지 않도록 처리하는 편이 안전하다. 부모 목차 노드와 연결하는 메서드는 다음과 같이 작성했다.
class TocContent {
setParent(parent) {
this.parent = parent;
this.parent.children.push(this);
return this;
}
findParent(depth) {
let parent = this;
while (parent) {
if (parent.depth < depth) {
break;
}
parent = parent.parent;
}
return parent;
}
}setParent는 현재 목차 항목의 부모를 지정하고, 부모의 children에도 현재 항목을 추가한다. findParent는 현재 항목을 기준으로 위쪽 부모를 거슬러 올라가면서 적절한 부모를 찾는 메서드이다. 예를 들어 마지막으로 처리한 제목이 <h4>이고, 새로 처리할 제목이 <h2>라면 <h4>의 부모를 계속 따라 올라가면서 <h2>보다 상위 단계에 있는 제목을 찾는다. 이 로직이 필요한 이유는 제목 단계가 항상 단순하게 증가하지는 않기 때문이다.
<h2>2. 동작</h2>
<h3>2.1. 제목 태그 파싱</h3>
<h4>2.1.1. 재귀 탐색</h4>
<h2>3. 적용 방법</h2>위 구조에서 3. 적용 방법은 바로 앞의 <h4>와 같은 부모를 가져서는 안 된다. 이전 제목이 무엇이었는지뿐 아니라, 이전 제목의 부모 관계를 따라 올라가면서 현재 제목이 들어갈 위치를 찾아야 한다.
3.2. TocMaker
TocMaker는 목차 생성을 담당하는 클래스이다.
class TocMaker {
tocElement;
targetElement;
text;
depthLimit = 3;
style = {};
constructor(tocElement, targetElement, text = 'Table of Contents', style = {}, depthLimit = 3) {
this.tocElement = tocElement;
this.targetElement = targetElement;
this.text = text;
this.style = style;
this.depthLimit = depthLimit;
}
static init(tocElement, targetElement, text = 'Table of Contents', style = {}, depthLimit = 3) {
return new TocMaker(tocElement, targetElement, text, style, depthLimit);
}
}각 속성의 의미는 다음과 같다.
tocElement: 목차를 삽입할 태그이다. 본문 최상단에 목차를 넣고 싶다면 본문 태그를 넘기면 된다.targetElement: 제목 태그를 파싱할 대상 태그이다. 일반적으로 글 본문 태그가 된다.text: 목차 제목이다. 예를 들어목차,Table of Contents같은 값을 사용할 수 있다.depthLimit: 목차로 만들 제목의 깊이 제한이다. 기본값은3으로 두었다. 이 경우<h1>,<h2>,<h3>까지만 목차 항목으로 만든다. 만약 값을2로 지정하면<h1>,<h2>까지만 목차에 포함된다.style: 생성할 목차 리스트에 적용할 스타일 객체이다.init:new TocMaker(...)를 직접 호출하지 않아도 되도록 만든 static 메서드이다. 반드시 필요한 메서드는 아니지만, 실제 적용 코드에서 조금 더 간단하게 호출하고 싶어서 추가했다.
const tocMaker1 = new TocMaker(...args);
const tocMaker2 = TocMaker.init(...args);두 방식은 결국 같은 객체를 생성한다.
3.3. render와 remove
목차를 화면에 그리는 메서드는 render이다.
class TocMaker {
render() {
this.remove();
const titles = this.#extractTitles([], this.targetElement, this.depthLimit);
const contents = this.#createContents(titles);
const wrapper = document.createElement('div');
wrapper.dataset.tocMaker = 'true';
wrapper.append(this.#createTocText(this.text), this.#createTocList(contents, this.style), document.createElement('hr'));
this.tocElement.prepend(wrapper);
}
remove() {
const toc = this.tocElement.querySelector('[data-toc-maker="true"]');
toc?.remove();
}
}render는 다음 순서로 동작한다.
- 기존에 생성된 목차가 있다면 제거한다.
- 본문에서 제목 태그를 추출한다.
- 추출한 제목 태그를 목차 노드로 변환한다.
- 목차 제목과 목차 리스트를 생성한다.
- 생성한 목차를
tocElement의 가장 앞에 삽입한다.
여기서 remove는 생성된 목차만 제거하도록 작성했다. 처음에는 tocElement의 모든 자식 노드를 제거하는 방식으로 작성할 수도 있지만, 이 방식은 위험하다. 특히 tocElement와 targetElement를 같은 본문 태그로 넘긴 경우, 목차를 지우려다가 본문 전체를 지워버릴 수 있다. 그래서 목차를 생성할 때 data-toc-maker="true" 속성을 가진 wrapper를 만들고, 제거할 때는 해당 wrapper만 찾아서 제거하도록 했다.
3.4. 제목 태그 추출하기
본문에서 제목 태그를 추출하는 코드는 다음과 같다.
class TocMaker {
#extractTitles(titles, node, depthLimit = 3) {
const tagName = node.tagName ?? '';
const isHeading = /^H[1-6]$/.test(tagName);
const depth = Number(tagName.replace('H', ''));
if (isHeading && depth <= depthLimit) {
titles.push(node);
}
for (const child of node.children) {
this.#extractTitles(titles, child, depthLimit);
}
return titles;
}
}이 메서드는 재귀적으로 동작한다. 현재 노드가 제목 태그인지 확인하고, 제목 태그라면 titles 배열에 추가한다. 그리고 현재 노드의 자식 노드를 다시 순회한다. 재귀 호출을 사용한 이유는 제목 태그가 본문 태그의 바로 아래에만 존재한다고 보장할 수 없기 때문이다. 예를 들어 다음과 같은 구조가 있을 수 있다.
<main id="article">
<h2>1. 개요</h2>
<p>본문 내용...</p>
<div>
<h2>2. 동작</h2>
<h3>2.1. 제목 태그 파싱</h3>
</div>
</main>만약 본문 태그의 바로 아래 자식만 확인한다면 div 안에 있는 2. 동작, 2.1. 제목 태그 파싱은 찾지 못한다. 그래서 자식 요소의 하위 요소까지 모두 탐색하도록 재귀적으로 구현했다. 이때 주의할 점은 depthLimit을 재귀 호출에도 그대로 넘겨야 한다는 것이다.
this.#extractTitles(titles, child, depthLimit);만약 이 값을 넘기지 않으면 최초 호출에서 depthLimit을 2로 지정하더라도, 내부 재귀 호출에서는 기본값인 3이 사용될 수 있다. 그러면 의도와 다르게 <h3>까지 목차에 포함될 수 있다.
3.5. 목차 노드 생성하기
추출한 제목 태그는 TocContent 객체로 변환한다.
class TocMaker {
#createContents(titles) {
const contents = [];
let last = null;
let index = 0;
while (titles.length > 0) {
const content = new TocContent(titles.shift(), index++);
if (last === null) {
last = content;
contents.push(last);
continue;
}
if (last.depth < content.depth) {
last = content.setParent(last);
continue;
}
if (last.depth === content.depth) {
if (last.parent) {
last = content.setParent(last.parent);
continue;
}
}
if (last.depth > content.depth) {
const parent = last.findParent(content.depth);
if (parent) {
last = content.setParent(parent);
continue;
}
}
last = content;
contents.push(content);
}
return contents;
}
}이 메서드의 핵심은 last이다. last는 직전에 처리한 목차 항목을 의미한다. 새로 처리할 제목의 깊이와 last의 깊이를 비교해서 부모, 자식 관계를 결정한다. 예를 들어 이전 제목이 <h2>이고 현재 제목이 <h3>이라면 현재 제목은 이전 제목의 자식이 된다.
<h2>2. 동작</h2>
<h3>2.1. 제목 태그 파싱</h3>반대로 이전 제목이 <h3>이고 현재 제목이 <h2>라면 현재 제목은 이전 제목의 자식이 아니다. 이 경우에는 이전 제목의 부모를 따라 올라가면서 현재 제목이 들어갈 위치를 찾아야 한다.
<h2>2. 동작</h2>
<h3>2.1. 제목 태그 파싱</h3>
<h2>3. 적용 방법</h2>이런 관계를 구성해두면 나중에 목차 태그를 만들 때 제목의 계층 구조를 유지하기 쉽다.
3.6. 목차 태그 생성하기
목차 제목은 간단하게 생성했다.
class TocMaker {
#createTocText(text) {
const strong = document.createElement('strong');
strong.innerText = text;
return strong;
}
}목차 항목은 TocContent의 children을 재귀적으로 순회하면서 만든다.
class TocMaker {
#createTocListItems(items, content) {
const anchor = document.createElement('a');
anchor.innerText = content.text;
anchor.href = content.href;
const li = document.createElement('li');
li.appendChild(anchor);
li.style.paddingLeft = `${10 * (content.depth - 1)}px`;
items.push(li);
for (const child of content.children) {
this.#createTocListItems(items, child);
}
return items;
}
#createTocList(contents, styles = {}) {
const ul = document.createElement('ul');
const mergedStyles = {
...styles,
listStyle: 'none',
};
for (const [key, val] of Object.entries(mergedStyles)) {
ul.style[key] = val;
}
while (contents.length > 0) {
ul.append(...this.#createTocListItems([], contents.shift()));
}
return ul;
}
}#createTocListItems: 목차 항목 하나를<li>로 만들고, 내부에<a>태그를 넣는다. 이때href에는 제목 태그의id를 가리키는 값이 들어간다. 그래서 목차 항목을 클릭하면 해당 제목 위치로 이동한다.paddingLeft: 제목 깊이에 따라 들여쓰기를 주기 위해 사용했다.<h2>보다<h3>가 더 안쪽에 보이면 목차의 계층이 조금 더 잘 드러난다.#createTocList: 외부에서 넘겨받은 스타일과 기본 스타일을 합쳐<ul>에 적용했다. 기존style객체를 직접 수정하지 않기 위해mergedStyles라는 새 객체를 만들었다.
4. 적용 방법
Tistory에 적용하려면 아래 순서대로 진행하면 된다.
- Tistory 관리자 페이지에서 스킨 편집 페이지로 이동한다.
html 편집버튼을 클릭한다.파일업로드탭을 클릭한다.- GitHub 소스코드 중
src/index.js파일을toc-maker.js로 업로드한다. HTML탭으로 이동한다.body태그 하단에 스크립트를 추가한다.
파일을 업로드한 뒤에는 실제 업로드 경로를 확인해야 한다. 필자의 경우 images/toc-maker.js로 업로드되었다. 따라서 아래 예시에서도 해당 경로를 사용했다.
<body>
<!-- body 태그를 찾은 후 맨 아래에 추가 -->
<script src="./images/toc-maker.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const article = document.querySelector('#content .entry-content .tt_article_useless_p_margin');
if (article) {
TocMaker.init(article, article, '목차', {
padding: '20px',
backgroundColor: '#f6f8fa',
borderRadius: '0.5rem',
}).render();
}
});
</script>
</body>위 코드에서 가장 중요한 부분은 다음 코드이다.
const article = document.querySelector('#content .entry-content .tt_article_useless_p_margin');이 선택자는 필자의 Tistory 스킨 기준으로 본문을 찾기 위한 값이다. Tistory 스킨마다 본문 구조가 다를 수 있으므로, 본인의 블로그에서 실제 본문 태그를 확인한 뒤 선택자를 바꿔야 한다. TocMaker.init의 첫 번째 인자와 두 번째 인자에 모두 article을 넘긴 이유는 목차를 본문 상단에 넣고, 제목 태그도 같은 본문 안에서 찾기 위해서이다.
TocMaker.init(article, article, '목차', {
padding: '20px',
backgroundColor: '#f6f8fa',
borderRadius: '0.5rem',
}).render();첫 번째 article은 목차를 삽입할 위치이다. 두 번째 article은 제목 태그를 파싱할 대상이다. 만약 목차를 본문이 아닌 다른 위치에 넣고 싶다면 두 값을 분리하면 된다.
<body>
<!-- body 태그를 찾은 후 맨 아래에 추가 -->
<script src="./images/toc-maker.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tocElement = document.querySelector('목차를 나타내려는 태그의 선택자');
const targetElement = document.querySelector('제목을 파싱하기 위한 본문 태그의 선택자');
if (tocElement && targetElement) {
TocMaker.init(tocElement, targetElement, '목차', {
padding: '20px',
backgroundColor: '#f6f8fa',
borderRadius: '0.5rem',
}).render();
}
});
</script>
</body>예를 들어 목차를 사이드바에 넣고 싶다면 tocElement는 사이드바 영역의 선택자가 되고, targetElement는 본문 영역의 선택자가 된다. 반대로 본문 최상단에 목차를 넣고 싶다면 두 값에 같은 본문 태그를 넘기면 된다.
마치며
오랫동안 미뤄두었던 Tistory 목차 생성 기능을 구현했다. 기능 자체는 단순하다. 본문에서 제목 태그를 찾고, 그 제목들을 링크로 바꿔 본문 상단에 넣어주면 된다. 다만 막상 구현해보면 생각보다 신경 쓸 부분이 있다. 제목 태그가 본문 바로 아래에만 존재하지 않을 수 있기 때문에 재귀적으로 탐색해야 하고, 제목 단계에 따라 부모와 자식 관계도 어느 정도 맞춰주어야 한다. 또 제목 태그에 id가 없으면 목차 링크가 동작하지 않기 때문에 id를 생성해주어야 하고, 중복 id가 생기지 않도록 처리하는 것도 필요하다. 아주 큰 기능은 아니지만, 글을 읽는 입장에서는 목차가 있는 편이 훨씬 편하다. 특히 글이 길어질수록 원하는 위치로 바로 이동할 수 있다는 점이 좋다. 미뤄둔 기간에 비하면 구현 자체는 금방 끝났지만, 작은 불편함을 하나 해결했다는 점에서 만족스러운 작업이었다.