Ghost 블로그에 떠다니는 목차 붙이기

Ghost 테마에 목차를 직접 붙이려다, 테마에 묶이지 않는 위젯으로, 끝내 누구나 한 줄로 쓰는 오픈소스 플러그인까지 만든 과정을 정리했습니다.

Share
Ghost 블로그에 떠다니는 목차 붙이기
Photo by Brett Jordan / Unsplash

긴 글을 읽다 보면 "지금 전체에서 어디쯤 읽고 있는지"와 "원하는 곳으로 바로 가기"가 늘 아쉬웠습니다. 우리가 쓰는 Ghost 기본 테마(Source)에는 본문 옆에 떠다니는 목차가 없었고, 그래서 직접 붙여보기로 했습니다. 처음 생각은 단순했습니다. "테마만 조금 고치면 되겠지."

그런데 막상 손을 대보니 방향이 점점 바뀌었습니다. 테마에 박았던 목차를 도로 떼어내 독립된 위젯으로 만들고, 그것을 CDN으로 배포하고, 설정용 데모 사이트까지 곁들여, 결국 누구나 가져다 쓸 수 있는 오픈소스로 공개하게 됐습니다. 작은 기능 하나였는데, 정작 시간을 잡아먹은 건 "이걸 어디에 둘 것인가"라는 판단이었습니다.

이 글은 그 과정을 테마 직접 수정부터 분리된 위젯, CDN 배포, 데모 사이트, 오픈소스 공개까지 순서대로 정리한 기록입니다. 명령이나 코드보다는 길목마다 내린 판단에 무게를 두었습니다. 비슷하게 자기 블로그에 작은 기능을 직접 붙여보려는 분들께 참고가 되었으면 합니다.

전체 그림 한눈에 보기

먼저 큰 그림입니다. 최종 구조는 단순합니다. 위젯 본체(toc.jstoc.css)는 GitHub 저장소에 있고, 이것을 jsDelivr가 CDN으로 그대로 서빙합니다. 사용자는 자기 블로그의 Code Injection에 코드 한 줄을 붙여 그 CDN 파일을 불러 쓰기만 하면 됩니다. 같은 저장소에 있는 설정용 데모 페이지는 GitHub Pages로 공개되어, 옵션을 눌러보고 붙여넣을 코드를 복사해 가는 곳이 됩니다.

정리하면 한쪽에는 "테마와 무관한 독립 위젯"이, 다른 쪽에는 "그 위젯을 한 줄로 불러 쓰는 블로그"가 있고, 둘은 CDN으로만 연결됩니다. 이 구조가 이후의 거의 모든 결정을 정해주었습니다.

flowchart LR
    subgraph repo["GitHub 저장소: ghost-toc-plugin"]
        src["toc.js · toc.css<br/>(위젯 소스)"]
        demo["index.html<br/>(데모 · 설정 도구)"]
    end

    src -->|"vX.Y.Z 태그 릴리스"| cdn["jsDelivr CDN<br/>@1 = 최신 v1.x"]
    repo -->|"GitHub Pages 배포"| pages["데모 사이트<br/>ghost-toc-plugin.greedylabs.kr"]
    cdn -->|"Code Injection 한 줄"| blog["내 Ghost 블로그<br/>toc.min.js + toc.css"]

왜 테마에서 떼어냈나

가장 직관적인 방법부터 해봤습니다. 테마(우리는 Ghost 공식 Source 테마의 포크를 씁니다)의 템플릿과 스타일에 목차를 직접 박는 것입니다. 본문의 h2·h3를 훑어 헤딩마다 ID를 만들고(한글 제목도 깨지지 않도록 유니코드 슬러그로 처리했습니다), 그 목록으로 목차를 그려 본문 오른쪽 여백에 position: sticky로 띄웠습니다. 동작은 금방 됐습니다.

문제는 동작이 아니라 그 다음이었습니다. 목차 코드가 post.hbsscreen.css에 섞여 들어가니, 업스트림 Source 테마를 따라 올리거나 리베이스할 때마다 충돌이 났습니다. 다른 사이트로 가져갈 수도 없었습니다. 색상 하나를 바꾸려 해도 테마를 다시 빌드해 zip으로 업로드해야 했습니다. 기능은 같은데 유지보수 비용만 테마에 그대로 얹힌 셈이었습니다.

여기서 판단이 굳어졌습니다. 목차는 테마의 일부가 아니라 테마 밖의 부품이어야 한다는 것입니다. 그래서 테마에서 목차 관련 커밋을 들어내고, 별도 저장소를 새로 만들었습니다. 기준은 세 가지로 잡았습니다. 어떤 테마에서도 동작할 것, 테마 파일을 건드리지 않고 코드 한 줄로 설치할 것, 그리고 우리 블로그든 남의 블로그든 같은 파일 하나를 바라볼 것입니다. 재사용은 코드를 잘 짜는 문제이기 이전에, 무엇에 의존하지 않을지를 정하는 문제였습니다.

충돌 없이 얹히게 만들기

위젯이 남의 페이지에 얹히는 순간 가장 먼저 걱정되는 것은 이름 충돌입니다. 흔한 클래스명을 썼다가 호스트 테마의 스타일과 부딪히면, 내 위젯이 깨지거나 반대로 남의 페이지를 깨뜨립니다. 그래서 모든 클래스와 CSS 변수를 고유한 접두사(greedylabs-ghost-toc)로 감쌌습니다. 길지만, 길수록 안전합니다. 이름이 흔할수록 충돌 확률이 올라가므로, 여기서는 간결함보다 충돌 회피를 택했습니다.

색상은 조금 다른 고민이었습니다. 목차가 "원래 테마에 있던 것처럼" 보이려면 블로그의 강조색을 따라가야 하는데, 특정 테마의 변수에 강하게 의존하면 비종속 원칙이 깨집니다. 그래서 변수 폴백 체인으로 풀었습니다. 활성 항목의 색은 이렇게 결정됩니다.

.greedylabs-ghost-toc__link--active {
  /* 사용자 지정 → Ghost 표준 강조색(자동) → 마지막 기본값 */
  color: var(--greedylabs-ghost-toc-accent, var(--ghost-accent-color, #1a73e8));
}

--ghost-accent-color는 특정 테마가 아니라 Ghost 플랫폼이 모든 사이트에 주입하는 표준 변수입니다. 덕분에 이 한 줄로 "별도 설정 없이도 블로그 강조색을 자동으로 따라가되, Ghost가 아닌 환경에서는 무난한 기본값으로 떨어지는" 동작을 동시에 얻습니다. 정리하면, 특정 테마가 아니라 플랫폼 표준에만 기대는 것이 비종속과 자동 어울림을 양립시키는 지점이었습니다. 설치하는 쪽이 만질 값들은 전부 data-* 속성으로 노출했습니다. 결국 사용자가 만지는 것은 코드가 아니라 변수입니다.

위치를 잡고 현재 섹션을 가리키기

만들면서 가장 손이 많이 간 건 정작 "어디에 놓고, 무엇을 가리킬까"였습니다. 처음엔 본문 컨테이너(.gh-content)의 오른쪽 끝에 패널을 붙였는데, 목차가 화면 밖으로 밀려 보이지 않았습니다. 알고 보니 Ghost Source에서 .gh-content는 글이 들어가는 720px 읽기 칼럼이 아니라 양옆 여백까지 포함한 풀폭 그리드여서, "본문 오른쪽 끝"이 사실상 "페이지 끝"이었습니다. 그래서 컨테이너 대신 헤딩 요소의 가로 위치를 측정해 그 옆에 붙이는 방식으로 바꿨습니다. 헤딩이 곧 읽기 칼럼에 놓이기 때문입니다. 세로로는 본문이 시작되는 지점부터 목차가 시작되고, 스크롤을 따라오다 본문이 끝나면 함께 위로 빠지도록 했습니다.

"지금 읽는 섹션 강조"도 몇 번을 고쳤습니다. 처음엔 화면 상단의 넓은 띠 안에 들어온 헤딩을 활성으로 봤는데, 띠가 화면 중간쯤에 걸쳐 강조가 실제 읽는 위치보다 늦거나 일렀습니다. 그래서 기준을 하나의 선(상단에서 일정 거리)으로 좁혔더니 전환은 정확해졌지만, 글 맨 끝의 짧은 섹션들은 더 내려갈 스크롤이 없어 그 선까지 닿지 못해 끝내 강조되지 않았습니다. 마지막엔 본문 끝 구간에서만 기준선이 화면 아래로 서서히 내려가도록 해, 남은 꼬리 섹션들도 차례로 강조되게 절충했습니다. 본문 아래 댓글이나 푸터가 충분하면 기준선은 고정된 채 정확히 동작하고, 글이 짧을 때만 이 보정이 켜집니다.

빌드 없이 배포하기

배포에는 별도 서버나 빌드 파이프라인을 두고 싶지 않았습니다. 그래서 GitHub 저장소를 그대로 서빙해 주는 jsDelivr를 썼습니다. 저장소에 파일을 올리면 곧 CDN URL로 접근할 수 있고, 미니파이된 버전(toc.min.js)도 jsDelivr가 알아서 만들어 줍니다. 빌드 산출물은 저장소에서 아예 빼버려, 소스만 관리하면 되도록 했습니다.

flowchart LR
    tag["git tag vX.Y.Z<br/>push"] --> gha["GitHub Actions<br/>릴리스 워크플로우"]
    gha --> rel["GitHub Release 생성"]
    gha --> purge["jsDelivr 캐시 purge<br/>@1 · @latest · 해당 버전"]
    purge --> cdn["jsDelivr @1<br/>= 최신 v1.x"]
    cdn --> blogs["@1 을 쓰는 블로그들<br/>자동 반영"]

여기에도 주의사항이 있습니다. 개발 중에 v1.0.0 태그를 옮겨가며 테스트했는데, 분명히 새 코드를 올렸음에도 CDN은 계속 옛 내용을 돌려주었습니다. 원인은 jsDelivr가 버전 태그를 불변(immutable)으로 간주해 영구 캐싱한다는 데 있었습니다. CDN에서 버전은 일종의 약속입니다. 한 번 내보낸 버전의 내용은 다시 바꾸지 않는다는 약속입니다. 그 약속을 어기고 태그를 옮기니, 캐시는 옛 내용을 그대로 붙들고 있었습니다. 교훈은 분명했습니다. 태그는 옮기지 않고, 바뀔 때마다 새 버전을 올린다는 것입니다.

대신 사용자가 매번 상세 버전을 신경 쓰지 않아도 되도록, 임베드에는 버전 범위 @1을 씁니다. jsDelivr가 @1을 최신 v1.x.x로 해석해 주므로, 우리가 새 버전을 릴리스하면 사용자 쪽 코드는 그대로 두어도 자동으로 따라옵니다. 릴리스는 GitHub Actions로 자동화해서, vX.Y.Z 태그를 올리면 GitHub 릴리스가 만들어지고 jsDelivr의 @1·@latest·해당 버전 캐시가 즉시 갱신됩니다. 덕분에 태그 하나만 올리면 배포가 끝나고, 앞의 캐싱 문제도 자동으로 우회됩니다.

데모와 설정 도구

플러그인을 쓰려는 사람에게 어떤 옵션을 어떻게 넣어야 하는지 보여줄 곳이 필요했습니다. 단순 미리보기로 둘 수도 있었지만, 기왕이면 옵션을 직접 만지면 결과가 즉시 보이고 붙여넣을 코드까지 나오는 도구로 만들었습니다. GitHub Pages에 올리고 커스텀 도메인을 붙였습니다.

ghost-toc-plugin — Floating table of contents for Ghost
Add a floating table of contents to your Ghost blog with one line of code. Tweak options, preview live, and copy the embed snippet. Open source (MIT).

위치(좌·우), 헤딩 범위, 제목, 강조색, 너비 같은 값을 바꾸면 목차 미리보기가 그 자리에서 다시 그려지고, 동시에 붙여넣을 임베드 코드가 갱신되어 복사 버튼 하나로 가져갈 수 있습니다. 이것을 구현하려고 위젯에 자동 초기화 외에 프로그래매틱 API를 더했는데, 결과적으로 데모 페이지가 사용자가 받아갈 바로 그 CDN 파일을 변수만 바꿔가며 구동하는 그림이 됐습니다. 데모와 실제 사용이 같은 소스를 보도록 맞춘 셈입니다.

오픈소스로 공개하기

이왕 테마에서 떼어내 일반화했으니, MIT 라이선스로 공개했습니다. 우리만 쓰기엔 아까웠고, 같은 불편을 겪는 Ghost 사용자가 분명 있을 것이기 때문입니다. 설치는 한 세트면 끝납니다. Ghost 관리자에서 Settings → Code injection → Site Footer에 다음을 붙입니다.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/GreedyLabs/ghost-toc-plugin@1/toc.css">
<script src="https://cdn.jsdelivr.net/gh/GreedyLabs/ghost-toc-plugin@1/toc.min.js"
        data-content=".gh-content"
        data-headings="h2,h3"
        data-position="right"
        data-title="목차"></script>

강조색은 테마 색을 자동으로 따라가고, 화면이 좁으면 알아서 숨고, 클래스는 고유 접두사라 테마와 부딪히지 않습니다. 옵션을 바꾸고 싶으면 데모 사이트에서 눌러보고 생성된 코드만 복사해 가면 됩니다. 앞에서 정한 세 기준, 즉 비종속과 한 줄 설치와 한 소스가 그대로 사용 경험으로 이어진 셈입니다.

GitHub - GreedyLabs/ghost-toc-plugin: Portable, dependency-free floating table of contents (CDN-ready)
Portable, dependency-free floating table of contents (CDN-ready) - GreedyLabs/ghost-toc-plugin

마치며

처음엔 우리 블로그에 목차 하나 붙이려던 일이었습니다. 그러다 테마에서 떼어내 독립 위젯으로 만들고, jsDelivr로 배포하고, 옵션을 눌러볼 수 있는 데모 사이트까지 붙여, 지금은 우리 블로그 모든 글에 목차가 떠 있습니다. 글이 길어도 어디쯤 읽고 있는지 한눈에 들어오고, 보고 싶은 곳으로 바로 갈 수 있게 됐습니다.

기분 좋은 건 이게 우리만의 도구로 끝나지 않았다는 점입니다. 코드 한 줄이면 누구나 자기 Ghost 블로그에 똑같이 붙일 수 있도록 MIT로 공개했고, 같은 불편을 겪던 분들이 가져다 쓸 수 있게 됐습니다. 작게 만든 것이 우리 손을 떠나 다른 사람의 블로그에서도 돌아간다는 건, 생각보다 훨씬 즐거운 일이었습니다.

써보시고 불편하거나 더 좋은 아이디어가 있으면 이슈나 PR로 편하게 알려주세요. 함께 다듬어 가면 더 좋겠습니다.