배경
Quartz v4의 기본 태그 시스템은 /tags/[tagname] 경로에서 해당 태그를 가진 글 목록을 보여주는 단순한 구조입니다. 글이 적을 때는 충분하지만, 태그가 수십 개로 늘어나면 문제가 생깁니다.
- 태그 간 계층 관계를 표현할 수 없습니다.
[primary]/[sub1],[primary]/[sub2]모두[primary]카테고리에 속하지만, 기본 태그 페이지에서는 이 관계가 드러나지 않습니다. - 특정 분야의 글만 빠르게 훑어보려면 태그 페이지를 하나씩 돌아다녀야 합니다.
- 한 페이지에서 태그를 선택하고, 필터링된 결과를 즉시 확인하는 인터랙션이 없습니다.
솔직히 정원에서는 필요 없는 기능이라 생각하지만, 개발/게임 코너는 학술적으로 정리된 공간이라는 특성도 버릴 수 없었기 때문에 이 기능을 구상하게 되었습니다.
그래서 계층적 태그 필터링이 가능한 인터랙티브 아티클 리스트의 제작을 시작했습니다. Primary tag를 선택하면 하위 sub-tag가 노출되고, 아티클 목록이 실시간으로 필터링되는 구조입니다.
이 기능은 조금씩 차이는 있지만 개발 블로그 (Tistory, Velog 등)에 보편적으로 존재하는 시스템입니다. 저는 이 중 우아한형제들 기술 블로그의 태그 필터링 UI를 레퍼런스로 참고했습니다.
코드 블록 트리거 방식
Quartz는 마크다운을 HAST(HTML Abstract Syntax Tree)로 변환한 뒤 JSX 컴포넌트로 렌더링합니다. 여기서 핵심 아이디어는 코드 블록의 language identifier를 트리거로 사용하는 것입니다. (Obsidian 플러그인 등 다수의 MD 확장 플러그인이 사용하는 방식)
마크다운에 다음과 같은 코드 블록을 삽입하면 컴포넌트가 활성화됩니다:
```article-list
```Quartz의 Rehype Pretty Code 플러그인이 이 코드 블록을 <pre data-language="article-list">로 변환하고, hasArticleListBlock() 함수가 HAST 트리를 순회하며 이 노드를 탐지합니다.
function hasArticleListBlock(tree: Root): boolean {
let found = false
visit(tree, "element", (node: Element) => {
if (
node.tagName === "pre" &&
node.properties?.dataLanguage === "article-list" // [!code highlight]
) {
found = true
}
})
return found
}이 함수가 true를 반환해야만 컴포넌트가 렌더링됩니다. false면 null을 반환하여 아무것도 출력하지 않습니다.
계층적 태그 구조
태그 계산 로직
빌드 시점에서 모든 페이지의 태그를 순회하며 세 가지 Map을 구축합니다.
// 1. 전체 태그별 글 개수
const tagCounts = new Map<string, number>()
// 2. Primary tag(첫 번째 세그먼트)별 글 개수
const primaryTagCounts = new Map<string, number>()
pages.forEach((page) => {
const tags = page.frontmatter?.tags ?? []
const seenPrimary = new Set<string>()
tags.forEach((tag) => {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)
const primary = tag.split("/")[0]
if (!seenPrimary.has(primary)) {
seenPrimary.add(primary)
primaryTagCounts.set(primary, (primaryTagCounts.get(primary) || 0) + 1)
}
})
})
// 3. Primary tag별 하위 sub-tag 목록
const subTagsByPrimary = new Map<string, string[]>()
primaryTags.forEach((primary) => {
const subs = [...tagCounts.keys()]
.filter((tag) => tag.startsWith(primary + "/"))
.sort((a, b) => (tagCounts.get(b) || 0) - (tagCounts.get(a) || 0))
if (subs.length > 0) {
subTagsByPrimary.set(primary, subs)
}
})seenPrimary Set은 한 페이지가 같은 primary를 가지는 sub-tag를 동시에 가질 때 primary의 카운트가 중복으로 올라가는 것을 방지합니다.
표시 태그 필터링
한 페이지에 [primary]와 [primary]/[sub]이 모두 태그되어 있다면, 아티클 카드에 [primary]를 표시할 필요는 없습니다. 자식 태그가 존재하면 부모 태그를 숨기는 getDisplayTags() 함수가 이를 처리합니다.
const getDisplayTags = (tags: string[]) =>
tags.filter(
(tag) => !tags.some((t) => t !== tag && t.startsWith(tag + "/")) // [!code highlight]
)tag + "/" 접두사를 가진 다른 태그가 존재하면 해당 태그는 필터링됩니다. 단순하지만 계층 구조에서 중복 표시를 효과적으로 제거합니다.
SSG + 클라이언트 사이드 하이드레이션
SSG: Hidden div 렌더링
SSG(Static Site Generation) 단계에서는 모든 태그 버튼, sub-tag 그룹, 아티클 카드를 display: none 상태의 컨테이너에 렌더링합니다.
<div id="customTagArticleList-data" style="display:none" data-items-per-page={opts.itemsPerPage}>
<div class="tagList">
<span class="tag-select-button all selected" data-tag="all">
All ({pages.length})
</span>
{primaryTags.map((tag) => (
<span class="tag-select-button" data-tag={tag}>
{tag} ({primaryTagCounts.get(tag)})
</span>
))}
</div>
{/* sub-tag groups, article cards, see more button */}
</div>SEO와 인터랙티브 UI를 동시에 확보하는 최소한의 구조입니다.
클라이언트: DOM 재배치와 인터랙션
클라이언트 스크립트는 nav 이벤트를 리스닝합니다. Quartz의 SPA 라우팅에서 페이지 전환 시마다 발생하는 이벤트입니다.
document.addEventListener("nav", () => {
const source = document.getElementById("customTagArticleList-data")
const pre = document.querySelector("pre[data-language='article-list']")
if (!source || !pre) return
// SSR 렌더링된 hidden div를 코드 블록 위치로 이동
const target = pre.closest("figure") || pre
source.removeAttribute("style")
source.id = "customTagArticleList"
target.replaceWith(source)
// ...
})핵심은 replaceWith 호출입니다. SSG에서 렌더링된 hidden div(#customTagArticleList-data)를 코드 블록이 있던 위치로 이동시킵니다. Rehype Pretty Code가 <pre> 태그를 <figure>로 감싸기 때문에 closest("figure")로 래퍼를 찾아 대체합니다.
태그 매칭 함수
matchesTag() 함수는 계층적 매칭을 수행합니다. Primary tag를 선택하면 모든 하위 태그를 가진 글이 포함됩니다.
function matchesTag(tags: string[], tag: string): boolean {
if (tag === "all") return true
return tags.some(
(t) => t === tag || t.startsWith(tag + "/")
)
}startsWith(tag + "/") 패턴으로 정확한 접두사 매칭을 보장합니다. cs를 선택했을 때 css가 포함되는 것을 / 구분자로 방지합니다.
Primary tag와 Sub-tag 선택 동작
selectTag() 함수는 primary tag 선택 시 호출됩니다. Sub-tag 그룹의 표시/숨김을 처리하고, 페이지네이션을 초기화합니다.
function selectTag(tag: string) {
activeTag = tag
visibleCount = itemsPerPage
// Primary tag에 해당하는 sub-tag 그룹만 표시
subTagGroups.forEach((g) => {
g.style.display = tag !== "all" && g.dataset.parent === tag ? "" : "none"
})
// Sub-tag 선택 상태 초기화
container
.querySelectorAll(".sub-tag-group .tag-select-button")
.forEach((b) => b.classList.remove("selected"))
updateDisplay()
}Sub-tag를 클릭하면 activeTag가 해당 sub-tag의 전체 경로(예: [primary]/[sub])로 바뀌어 정확한 매칭이 이루어집니다. 이미 선택된 sub-tag를 다시 클릭하면 primary tag로 복귀하는 토글 동작도 구현되어 있습니다.
페이지네이션과 애니메이션
Incremental 로딩
itemsPerPage 옵션(기본값 5)으로 한 번에 표시할 아티클 수를 제한합니다. “더보기” 버튼을 누르면 visibleCount가 itemsPerPage만큼 증가하고, 모든 매칭 아티클이 표시되면 버튼이 사라집니다.
if (seeMoreButton) {
const handler = () => {
visibleCount += itemsPerPage
updateDisplay()
}
seeMoreButton.addEventListener("click", handler)
window.addCleanup(() => seeMoreButton.removeEventListener("click", handler))
}window.addCleanup()은 Quartz의 SPA 라우팅에서 페이지 전환 시 이벤트 리스너를 정리하기 위한 유틸리티입니다. 메모리 누수를 방지합니다.
Staggered 애니메이션
updateDisplay() 함수는 각 카드에 순차적으로 animate 클래스를 추가하여 staggered 등장 효과를 만듭니다.
function updateDisplay() {
let matchIndex = 0
pageCards.forEach((card) => {
const tags: string[] = JSON.parse(card.dataset.tags || "[]")
const matches = matchesTag(tags, activeTag)
if (matches) {
matchIndex++
if (matchIndex <= visibleCount) {
card.style.display = ""
card.classList.remove("animate")
setTimeout(() => card.classList.add("animate"), (matchIndex - 1) * 200) // [!code highlight]
} else {
card.style.display = "none"
card.classList.remove("animate")
}
} else {
card.style.display = "none"
card.classList.remove("animate")
}
})
// 마지막으로 보이는 카드의 하단 border 제거
let lastVisible: HTMLElement | null = null
pageCards.forEach((card) => {
card.style.borderBottom = ""
if (card.style.display !== "none") lastVisible = card
})
if (lastVisible) (lastVisible as HTMLElement).style.borderBottom = "none"
// 더보기 버튼 - 모든 카드 애니메이션 이후 등장
if (seeMoreButton) {
const totalMatching = getMatchingCards().length
const hasMore = totalMatching > visibleCount
seeMoreButton.style.display = hasMore ? "block" : "none"
seeMoreButton.classList.remove("animate")
if (hasMore) {
setTimeout(
() => seeMoreButton.classList.add("animate"),
Math.min(matchIndex, itemsPerPage) * 200,
)
}
}
}카드마다 (matchIndex - 1) * 200ms 딜레이로 animate 클래스를 추가합니다. classList.remove("animate") 후 setTimeout으로 다시 추가하는 패턴은 CSS 애니메이션을 re-trigger하기 위한 것입니다.
“더보기” 버튼은 마지막 visible 카드의 애니메이션이 끝난 뒤에 등장하도록 Math.min(matchIndex, itemsPerPage) * 200만큼 딜레이됩니다.
마지막으로 보이는 카드의 borderBottom을 제거하여 리스트 하단이 깔끔하게 마무리되도록 합니다.