본문 바로가기

Development/기타

Tistory 본문 글 목차 자동 생성 기능 구현

들어가며

예전부터 해야지... 해야지... 했던 Tistory 목차 생성 기능을 구현했다. 기능 구현 난이도는 매우 쉬움이었으나, 귀차니즘 + 외주 프로젝트 개발로 인해 계속 미루어두고 있었다. 외주 프로젝트 개발이 잘 마무리되어 후기를 작성하다가 목차 생성 기능 구현이 번뜩 떠올라 구현 후 글을 작성하였다.

1. 개요

목차 생성 기능은 매우 간단하다. 해당 제목이 포함된 글 본문 태그를 찾아서 모든 제목 태그를 파싱한 후 새로운 목차 태그를 생성해서 넣어주기만 하면 되기 때문이다. Tistory를 포함한 대부분의 웹 페이지 본문 글에는 제목이 있으며, 이 제목은 주로 <h1> ~ <h6> 태그로 구성되어 있다. 이를 확인하려면 제목처럼 보이는 텍스트에 마우스를 올려놓고 우클릭하여 검사를 누르면 브라우저 개발자 도구에 해당 태그르 자세히 볼 수 있다. 만약, 마우스 우클릭이 막혀있는 상태라면 어쩔 수 없이 개발자 도구(F12)를 열어 직접 찾는 수 밖에 없다.

브라우저 설정에서 Javascript 비활성하면 마우스 우클릭을 할 수 있긴 하나, 다시 활성으로 돌려야 하니 더 번거로울 수 있다고 생각한다.

이렇게 알아낸 태그는 맨 마지막 적용 방법에서 사용하니 참고하도록 하자.

2. 동작

동작 원리는 다음과 같다.

  1. 웹 페이지에서 글의 본문을 이루고 있는 본문 태그를 찾는다.
  2. 본문 태그 내에 존재하는 모든 제목 태그를 파싱한다.
  3. 파싱한 제목 태그를 활용하여 목차 태그를 생성한다.
  4. 생성한 목차 태그를 본문 태그 최상단에 배치시킨다.

3. 코드 설명

소스코드와 데모 페이지는 아래 링크로 남겨놓았다.

소스코드를 보면 TocContentTocMaker라는 클래스를 볼 수 있으며, 이에 대한 설명은 다음과 같다.

3.1. TocContent

TocContent는 목차 요소의 노드를 나타내는 클래스로 정의하였으며, 요소는 다음과 같다.

class TocContent {
  id;
  href;
  depth;
  text;
  parent = null;
  children = [];

  constructor(element) {
    element.id = element.textContent.replaceAll('.', '_').replaceAll(' ', '_');

    this.id = element.id;
    this.href = `#${element.id}`;
    this.depth = Number(element.tagName.replace('H', ''));
    this.text = element.textContent;
  }
}
  • id : 목차 항목 태그의 고유 식별값이며, 해당 제목에서 .과 공백()을 _로 대체한 값으로 초기화해주도록 하였다.
  • href : 목차 항목은 <a>태그를 사용할 것아며, 클릭 시 해당 제목으로 이동시킬 용도로 사용하였다.
  • depth : 제목의 수준을 나타낸다. 가령, <h1>, <h2> 태그의 수준은 각각 1과 2가 되며, 값이 작을수록 수준이 높다.
  • text : 목차의 텍스트이며 제목 태그의 텍스트를 그대로 사용한다.
  • parent : 목차 항목의 부모 태그이며, 이는 수준으로 결정된다. 가령, <h1>, <h2> 태그가 있을 때, <h1>태그는 <h2>태그의 부모 태그가 된다.
  • children : 목차 항목의 자식 태그이며, 이는 수준으로 결정된다. 가령, <h1>, <h2> 태그가 있을 때, <h2>태그는 <h1>태그의 자식 태그가 된다.

그 외 메소드는 부모 태그와 연결하는 setParent, depth로 부모 태그를 찾는 findParent가 있다.

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;
  }
}

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;
  }
}
  • tocElement : 목차를 삽입하려는 태그이며, 목차는 해당 태그안에 생성된다.
  • targetElement : 본문의 제목을 파싱하기 위한 태그이며, 해당 태그 안에 포함된 제목 태그만 파싱한다.
  • text : 목차의 제목이다.
  • depthLimit : 목차의 깊이 제한이며, 초기값은 3으로 지정하였다. 가령, 해당값을 2로 지정하면 <h1> ~ <h2>태그만 목차로 생성한다.
  • style : 목차의 스타일 값이다.

init 메소드는 TocMaker를 초기화하기 위한 static 메소드인데, 사실 해당 메소드는 필요없으나, <script>에서 new 키워드를 사용하고 싶지 않아서 작성하였다.

class TocMaker {
  static init(
    tocElement,
    targetElement,
    text = this.text,
    style = {},
    depthLimit = 3,
  ) {
    return new TocMaker(tocElement, targetElement, text, style, depthLimit);
  }
}

const tocMaker1 = new TocMaker(...args);
const tocMaker2 = TocMaker.init(...args);

public method로는 renderremove가 있으며 각각 목차 생성, 목차 제거 기능을 수행하도록 작성하였다.

class TocMaker {
  render() {
    const titles = this.#extractTitles([], this.targetElement, this.depthLimit);
    const contents = this.#createContents(titles);

    this.tocElement.prepend(
      this.#createTocText(this.text),
      this.#createTocList(contents, this.style),
      document.createElement('hr'),
    );
  }

  remove() {
    const children = Array.from(this.tocElement.childNodes);

    while (children.length > 0) {
      this.tocElement.removeChild(children.pop());
    }
  }
}

여기서 가장 핵심 로직은 render 메소드 안에서 호출하는 private 메소드들이며, 실행 순서대로 제목 태그 추출, 목차 노드 생성, 목차 제목 생성, 목차 항목 생성 기능을 수횅한다. 목차 제목 추출과 목차 목차 항목 생성 시 목차의 순서를 보장하기 위하여 재귀적 호출 방식으로 구현하였다. 가령, 아래와 같은 구조의 웹 문서가 있다고 가정해보자.

<main id="article">
  <h1>1<h1>
  <h2>1.1</h2>
  <div>
    <h1>2</h1>
    <h2>2.1</h2>
  </div>
</main>

만약, 제목 요소를 파싱할 때 재귀적 호출을 하지 않으면 1, 1.1 만 파싱하게 되므로, 재귀적 호출로 모든 태그의 최하위 요소까지 탐색하도록 하였다.

class TocMaker {
  #extractTitles(titles, node, depthLimit = 3) {
    const tagName = node.tagName ?? '';
    const depth = Number(tagName.replace('H', ''));

    if (Number.isNaN(depth) === false && depth <= depthLimit) {
      titles.push(node);
    }

    for (const child of node.children) {
      this.#extractTitles(titles, child);
    }

    return titles;
  }
}

추출한 제목 요소를 목차 노드로 생성할 때, 부모 태그와 자식 태그 간 관계를 유지시켜서 목차의 순서가 조정되도록 구현하였다.

class TocMaker {
  #createContents(titles) {
    const contents = [];

    let last = null;

    while (titles.length > 0) {
      const content = new TocContent(titles.shift());

      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;
  }
}

마지막으로 목차 노드로 목차 태그를 생성할 때 목차 노드 간 관계를 유지시키기 위해 재귀적 호출 방식으로 구현하였다.

class TocMaer {
  #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}px`;

    items.push(li);

    for (const child of content.children) {
      this.#createTocListItems(items, child);
    }

    return items;
  }

  #createTocList(contents, styles = {}) {
    styles.listStyle = 'none';

    const ul = document.createElement('ul');

    for (const [key, val] of Object.entries(styles)) {
      ul.style[key] = val;
    }

    while (contents.length > 0) {
      ul.append(...this.#createTocListItems([], contents.shift()));
    }

    return ul;
  }
}

4. 적용 방법

Tistory에 적용하려면 아래 작업만 해주면 된다.

  1. 스킨 편집 페이지로 이동 후 html 편집 버튼 클릭
  2. 파일업로드 탭을 클릭 후 GitHub 소스코드 중 src/index.jstoc-maker.js로 업로드
  3. HTML 탭을 클릭 후 아래 코드 추가

파일을 업로드 한 후에 파일명을 살펴보자. 필자는 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>

위의 소스코드에서 article을 찾아오는 document.querySelector의 인자값은 개요에 작성한 내용을 참고하여 목차를 생성하려는 본문 태그의 선택자를 입력하면 된다. 만약, 목차를 본문 태그가 아닌 다른 곳에 나타내고 싶다면 아래와 같이 수정하면 된다.

<body>
  <!-- body 태그를 찾은 후 맨 아래에 추가 -->
  <script src="./images/toc-maker.js"></script>
  <script>
    document.addEventListener('DOMContentLoaded', () => {
      if (article) {
        TocMaker.init(
          document.querySelector('목차를 나타내려는 태그의 선택자'),
          document.querySelector('제목을 파싱하기 위한 본문 태그의 선택자'),
          '목차',
          {
            padding: '20px',
            backgroundColor: '#f6f8fa',
            borderRadius: '0.5rem',
          },
        ).render();
      }
    });
  </script>
</body>

마치며

드디어 오랫동안 미뤄두었던 숙제 하나를 해결하였다. 매우 작은 일이지만, 오히려 매우 작다고 생각하여 지금껏 미뤄온 게 아닌가 싶다. 본 글을 보면 알겠지만 설명이 다소 투머치하다. 간만에 글을 쓰고 싶은 탓에 별 것 아닌 내용임에도 조금 투머치하게 작성해보았다.