목차가 깨지는 흔한 경우: README는 GitHub에서 잘 보이는데, 같은 파일을 Bitbucket에서 열거나 slug 규칙이 다른 Jekyll 사이트에서 빌드하면 TOC 링크가 어디로도 이동하지 않는다. Markdown은 한 글자도 다르지 않은데 제목 id는 달라져 있다.

Markdown 목차 생성은 한 줄로 끝날 일처럼 보인다. 제목을 읽고, slug를 만들고, 링크로 엮으면 된다 — 처음 80%는 정말 그렇다. 함정은 나머지 20%에 있다. 렌더러마다 slugify 규칙이 다르고, 중복 처리 방식이 다르고, Unicode에 대한 입장이 다르다. 잘못된 타깃용으로 만든 TOC는 TOC가 없는 것보다 나쁘다 — 에디터에서는 멀쩡해 보이다가 렌더러 단계에서 끊어진다.

Markdown 목차 만들기 →

왜 앵커는 표준화되지 않았나

Markdown 명세는 “제목 앵커”를 아예 다루지 않는다. CommonMark는 제목의 HTML 렌더링을 의도적으로 구현체에 맡겼고, “제목으로부터 slug를 만드는” 기능을 제공하는 모든 렌더러는 각자 알고리즘을 짰다. 방언 간 격차는 이미 한 TOC 생성기로 모든 곳을 커버할 수 없을 정도로 벌어져 있다 — 타깃을 하나 골라야 한다.

이 도구가 제공하는 네 가지 방언:

렌더러사용처앵커 스타일
GitHubgithub.com의 README, 이슈, 위키소문자 변환, -와 _ 외의 문장부호 제거, 공백을 하이픈으로, Unicode 문자 유지
GitLabgitlab.com, GitLab 셀프 매니지드GitHub과 같은 처리 후, 두 개 이상 이어진 하이픈을 하나로 합침
kramdown(input: kramdown)kramdown 자체 파서로 바꾼 Jekyll 사이트앞쪽의 영문자 아닌 문자 제거, ASCII 영문자·숫자·공백·하이픈만 남기고 소문자화. 비면 section
Bitbucket Cloudbitbucket.org모든 id에 markdown-header- 접두사, 중복은 _N

표에 대해 두 가지를 덧붙인다. Jekyll의 기본값은 GFM 파서(kramdown: input: GFM)이며, 제목 id가 GitHub 규칙을 따르고 Unicode를 유지한다(kramdown-parser-gfm). ASCII만 남기는 행은 input: kramdown으로 바꾼 사이트에만 해당한다. 또 이 도구의 Bitbucket 옵션은 현재 markdown- 접두사를 출력하지만, Bitbucket Cloud가 실제로 붙이는 id는 markdown-header-...이다. 도구가 고쳐질 때까지 생성된 링크의 markdown-을 markdown-header-로 바꿔 쓰자. Hugo(goldmark, v0.60부터 기본 렌더러)와 MkDocs는 각자의 id 규칙이 있다. 표에 없는 대상이라면 TOC를 커밋하기 전에 렌더링된 페이지에서 실제 id를 확인하자.

slugify 알고리즘, 네 가지 갈래

같은 제목을 네 방언에 통과시키면 서로 다른 앵커가 나온다:

제목: ## Quick Start: Setting up SSO (Auth 2.0)

스타일앵커
GitHubquick-start-setting-up-sso-auth-20
GitLabquick-start-setting-up-sso-auth-20
Jekyllquick-start-setting-up-sso-auth-20
Bitbucket Cloudmarkdown-header-quick-start-setting-up-sso-auth-20

여기까지는 같다. 이제 Unicode 제목: ## 빠른 시작

스타일앵커
GitHub빠른-시작
GitLab빠른-시작
kramdown(input: kramdown)section(ASCII가 하나도 남지 않음)
Bitbucket Cloud여기서는 생략: Python-Markdown slug 처리가 비 ASCII 문자를 지우므로 렌더링된 id를 확인할 것

kramdown 자체 파서는 여기서 무너진다. ASCII 영문자가 없는 제목은 모두 section, section-1…이 된다(kramdown auto_ids). 이 도구의 Jekyll 옵션은 이런 앵커를 비워 두므로 어느 쪽이든 링크가 이동하지 않는다. Jekyll 사이트에 비 ASCII 제목이 있다면 기본값인 input: GFM을 쓰고 TOC는 GitHub 스타일로 생성하자.

또 하나의 경우 — 중복된 제목:

## Examples
### Curl
## Examples
### Python
스타일앵커
GitHub / GitLab / kramdownexamples, curl, examples-1, python
Bitbucket Cloudmarkdown-header-examples, markdown-header-curl, markdown-header-examples_1, markdown-header-python

Bitbucket이 유일한 예외다. 중복 카운터를 하이픈이 아니라 밑줄로 붙인다. 다른 주요 렌더러는 모두 -N이다.

마커 모드: git 잡음 없이 TOC를 최신 상태로

TOC에서 흔한 함정이 “stale TOC” 차이다. 2,000줄짜리 운영 매뉴얼에 섹션을 하나 추가했는데 상단 TOC가 따라오지 않아, 리뷰어가 두 PR을 거친 뒤에야 알아차리는 식이다. 두 가지 출구가 있다:

  1. 주석 마커로 생성기에 TOC를 위임. <!-- toc --> … <!-- /toc --> 로 감싸 두고, 변경할 때마다 생성기를 돌린다. 마커 자체는 파일에 남고, 그 사이의 본문만 갱신된다.
  2. pre-commit 훅. 위와 같은 일을 커밋 직전에 자동으로.

이 도구의 marker mode는 패턴 1의 구현이다. Markdown을 붙여넣고 Marker mode를 켜면, 마커 사이에 새로 만든 TOC가 든 전체 Markdown을 받게 된다. 아직 마커가 없는 문서라면, 첫 제목 바로 앞에 새 마커 블록이 삽입되므로 한 번 커밋하면 그 다음부터는 마커 흐름으로 운영된다.

<!-- toc --> / <!-- /toc -->는 npm 패키지 markdown-toc가 쓰는 마커이므로, 여기서 준비한 파일은 나중에 markdown-toc -i로 관리할 수 있다. GitHub과 GitLab은 HTML 주석을 표시하지 않는다. Bitbucket Cloud는 원시 HTML을 이스케이프하므로 마커가 텍스트로 보인다.

결국 마주칠 다섯 가지 함정

실제 프로젝트에서 자주 만나는 순으로:

  1. 코드 블록 안의 # 주석. Bash, Ruby, Python 주석은 모두 #으로 시작한다. 단순한 제목 파서는 펜스드 코드 블록 안의 # This is a comment를 H1으로 읽는다. 쓸 만한 TOC 생성기는 펜스드 코드 블록을 건너뛴다. 이 도구는 ```과 ~~~ 양쪽을 모두 올바로 처리한다. 직접 짠다면 이 케이스를 조심하라.

  2. Setext 제목. Markdown은 ATX(# Title)와 setext(Title\n=====) 두 가지 제목 형식을 모두 지원한다. 오래된 README는 H1, H2를 setext로 쓴다. ATX만 처리하는 생성기는 이를 조용히 빠뜨린다.

  3. 제목 안의 인라인 서식. ## **Important**: Backups의 TOC 라벨은 굵게를 포함해야 하고 앵커는 평문을 써야 한다. 이를 잘못 다루면 \code“를 앵커의 일부로 끌어들이게 된다. slug용으로는 인라인 syntax를 벗기고, 라벨에는 원본 Markdown을 남기는 게 정답이다.

  4. H 레벨을 가로지르는 중복 제목. ## Examples와 ### Examples는 둘 다 examples로 slug되고, 둘째는 examples-1이 되어야 한다. 렌더러는 TOC에 넣지 않은 제목까지 문서의 모든 제목을 세지만, 이 도구는 선택한 레벨 범위 안의 제목만 센다. H1을 제외했고 문서에 # Examples와 ## Examples가 있으면 도구는 #examples로 링크하고, GitHub은 이 H2에 examples-1을 붙인다.

  5. 이미 마커가 있는 경우. 문서에 이미 <!-- toc --> … <!-- /toc -->이 있는데 단순히 “삽입”하면 TOC가 두 개가 된다. 올바른 동작은 기존 마커를 감지해 그 사이 내용을 교체하는 것이지 append가 아니다.

코드 레시피

pre-commit 훅: 커밋 시 TOC 재생성

TOC를 항상 동기화하는 가장 깔끔한 방법은 커밋 시 오래된 TOC면 fail하게 하고 원-키 수정을 두는 것이다. markdown-toc(npm) + pre-commit 훅(스크립트는 GNU md5sum을 쓴다. macOS에서는 md5 -q로 바꾼다):

#!/bin/sh
# .git/hooks/pre-commit
for f in $(git diff --cached --name-only --diff-filter=AM | grep '\.md$'); do
  before=$(md5sum "$f")
  npx markdown-toc -i "$f"
  after=$(md5sum "$f")
  if [ "$before" != "$after" ]; then
    echo "TOC out of date: $f — staged the regenerated version."
    git add "$f"
  fi
done

npx markdown-toc -i는 원하는 생성기로 바꿔도 된다. 계약은 파일을 in-place로 수정하고 마커 블록을 인식한다는 것이다.

GitHub Actions: PR에서 TOC 드리프트 검출

name: TOC drift
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npx markdown-toc -i README.md
      - run: |
          if ! git diff --quiet README.md; then
            echo "::error::README.md TOC is out of date. Run 'npx markdown-toc -i README.md' locally and commit."
            exit 1
          fi

Jekyll: slugger를 TOC에 맞추기

Jekyll 사이트에 비 ASCII 제목이 있다면 Jekyll 기본값인 GFM 파서를 쓴다(바꿨다면 되돌린다):

# _config.yml
kramdown:
  input: GFM

그런 다음 GitHub 스타일로 TOC를 만들면 결과 앵커와 일치한다.

이 도구의 자리

TOC 생성기를 고를 때 체감 차이가 큰 항목과 이 도구의 구현:

동작이 도구
펜스드 코드 블록(``` 및 ~~~) 건너뛰기한다
ATX와 setext 제목 모두둘 다
기존 <!-- toc --> 블록 in-place 교체한다
한 페이지에 네 가지 앵커 스타일GitHub / GitLab / Jekyll(kramdown 파서) / Bitbucket(접두사에 header- 추가 필요, 위 참고)
Bitbucket _N 중복 처리한다
레벨 범위 밖 제목도 중복 판정에 셈안 한다(함정 4 참고)
앵커는 인라인 Markdown 제거, 라벨은 보존한다
제목 레벨 필터 + H1 토글있다
브라우저에서 실행한다

워크플로가 서버 사이드 위주이고 CLI가 필요하다면, markdown-toc(npm, GitHub 스타일)이 같은 마커를 쓴다. 웹에서 붙여넣고 복사하는 용도라면, 여기의 네 가지 스타일 지원과 마커 인식 교체로 대상이 GitHub이 아닐 때의 재작업을 줄일 수 있다.

더 읽기