배경

Obsidian에는 Iconize라는 플러그인이 있습니다. 마크다운 본문에 :LiSmilePlus: 같은 패턴을 쓰면, 이렇게 에디터 상에서 해당 위치에 인라인 SVG 아이콘이 렌더링됩니다. 글 제목이나 폴더에도 아이콘을 붙일 수 있어서, 노트 정리에 시각적 단서를 더해주는 유용한 기능입니다.

물론 이건 Obsidian 전용이기 때문에 Quartz로 빌드하면 :LiSmilePlus:는 그냥 텍스트로 남습니다.

이 플러그인은 이미 제 제텔카스텐에서 2023년부터 사용하던 플러그인이기 때문에 절대 포기할 수 없습니다. 요구사항을 정리하고 구현에 들어갑니다:

  • Obsidian에서 작성한 :IconName: 문법이 Quartz 빌드 시에도 동일한 SVG 아이콘으로 렌더링될 것
  • 별도의 수동 변환 과정 없이, 빌드 파이프라인에서 자동으로 처리될 것
  • frontmatter의 icon 필드로 글 제목 위에 대형 아이콘도 표시할 수 있을 것

시스템 구조

전체 시스템은 세 파일로 구성됩니다.

quartz/
├── util/
│   └── iconize.ts              # 아이콘 키 파싱, ZIP 추출, 캐싱
├── plugins/transformers/
│   └── l88_inlineicons.ts      # HAST 트리 워킹 + SVG 치환
└── components/
    └── l88_ArticleIcon.tsx      # 글 제목 위 아이콘 렌더링

커스텀 파일에 l88_ 접두사를 붙이는 이유는 두 가지입니다. 첫째, IDE의 파일 탐색기에서 커스텀 코드와 upstream 코드를 즉시 구분할 수 있습니다. 둘째, upstream(jackyzha0/quartz)과 merge할 때 파일명 충돌을 원천적으로 방지합니다.

흐름은 다음과 같습니다.

  1. 빌드 시작 - Quartz가 마크다운을 파싱하여 HAST(HTML Abstract Syntax Tree)를 생성
  2. InlineIcons 트랜스포머 - HAST 트리를 순회하며 텍스트 노드에서 :IconName: 패턴을 탐색
  3. iconize.ts - 아이콘 키에서 패키지명과 파일명을 추출하고, ZIP에서 SVG를 로드
  4. SVG 치환 - 매칭된 패턴을 <span class="inline-article-icon"> + 인라인 SVG로 교체
  5. ArticleIcon - frontmatter icon 필드가 있으면 같은 경로로 SVG를 가져와 제목 위에 렌더링

핵심은 iconize.ts가 아이콘 키 해석과 ZIP 추출이라는 두 가지 관심사를 공유 유틸리티로 분리하고, 트랜스포머와 컴포넌트 양쪽에서 이를 재사용한다는 점입니다.

아이콘 키 체계

아이콘 키는 접두사 + PascalCase 아이콘명으로 구성됩니다. 접두사가 패키지를 결정하고, 나머지가 파일명으로 변환됩니다.

접두사패키지예시
Lilucide-iconsLiDatabaseZap
Sisimple-iconsSiTypescript
Fifeather-iconsFiGithub
Fasfont-awesome-solidFasCircle
Fabfont-awesome-brandsFabGithub
Titabler-iconsTiBrandGithub

총 14개의 아이콘 팩을 지원합니다.

getIconDetails 함수가 이 변환을 담당합니다.

quartz/util/iconize.ts
export function getIconDetails(iconKey: string): IconDetails {
  const packageNameMap: { [key: string]: string } = {
    Bo: "boxicons",
    Co: "coolicons",
    Cu: "customicons",
    Fi: "feather-icons",
    Fab: "font-awesome-brands",
    Far: "font-awesome-regular",
    Fas: "font-awesome-solid",
    Ib: "icon-brew",
    Li: "lucide-icons",
    Oc: "octicons",
    Ri: "remix-icons",
    Ra: "rpg-awesome",
    Si: "simple-icons",
    Ti: "tabler-icons",
  }
 
  let prefix: string | undefined
  for (const key in packageNameMap) {
    if (iconKey.toString().startsWith(key)) {
      if (!prefix || key.length > prefix.length) {  // {4}
        prefix = key
      }
    }
  }
 
  if (!prefix) {
    throw new Error("Invalid icon package prefix")
  }
 
  const iconName = iconKey.toString().substring(prefix.length) // {1}
  const fileName = iconName // {2}
    .replace(/[A-Z]/g, (letter) => "-" + letter.toLowerCase())
    .slice(1) + ".svg"
  const packageName = packageNameMap[prefix] // {3}
 
  return { packageName, iconName, fileName }
}

핵심 로직을 정리하면:

  • {1} 접두사를 잘라내어 순수 아이콘명 추출 - LiDatabaseZap DatabaseZap
  • {2} PascalCase를 kebab-case로 변환 - DatabaseZap database-zap.svg
  • {3} 접두사로 패키지명 결정 - Li lucide-icons
  • {4} 가장 긴 접두사 우선 매칭 - FasFa가 모두 매칭될 때, 길이가 긴 Fas를 선택
    • 가장 긴 접두사 우선 매칭이 필요한 이유는 Fab, Far, Fas 같은 Font Awesome 계열 접두사들이 모두 Fa로 시작하기 때문입니다. 단순히 첫 매칭을 사용하면 잘못된 패키지에 매핑될 수 있습니다.

ZIP 추출과 캐싱

아이콘 SVG 파일들은 패키지별로 ZIP 아카이브에 묶여 있습니다.

__RES/icons/
├── lucide-icons.zip
├── simple-icons.zip
├── feather-icons.zip
└── ...

초기에는 ZIP을 미리 풀어서 개별 SVG 파일로 저장하는 방식이었습니다. 하지만 아이콘 팩 하나에 수천 개의 SVG가 들어있다 보니, Git 관리도 번거롭고 디렉터리 구조도 지저분했습니다. ZIP 기반으로 전환한 뒤에는 패키지당 파일 하나만 관리하면 됩니다.

quartz/util/iconize.ts
const zipCache = new Map<string, JSZip>()
 
export async function getSvgFromZip(
  iconKey: string,
  directory: string,
): Promise<string> {
  const details = getIconDetails(iconKey)
  const rootDir = path.resolve()
  const zipPath = joinSegments(
    rootDir,
    `/${directory}/__RES/icons/${details.packageName}.zip`,
  )
 
  let zip = zipCache.get(zipPath) // {1}
  if (!zip) {
    const data = await fs.readFile(zipPath)
    zip = await JSZip.loadAsync(data) // {2}
    zipCache.set(zipPath, zip)
  }
 
  const file = zip.file(new RegExp(`${details.fileName}$`))[0] // {3}
  if (!file) {
    throw new Error(`SVG not found in ZIP: ${details.fileName}`)
  }
 
  return file.async("string") // {4}
}
  • {1} Map<string, JSZip> 기반 메모리 캐시. 한 번 파싱한 ZIP 객체를 재사용하여, 동일 패키지의 아이콘을 여러 번 요청해도 ZIP 파일을 다시 읽지 않습니다.
  • {2} JSZip으로 바이너리 데이터를 비동기 로드
  • {3} 정규식으로 ZIP 내부에서 파일명 매칭. ZIP 내부 경로에 하위 디렉터리가 있을 수 있으므로 $ 앵커로 끝부분만 매칭합니다.
  • {4} 매칭된 파일을 문자열(SVG 마크업)로 추출

이 캐싱 전략 덕분에 빌드 중 수백 개의 아이콘을 처리하더라도, 실제 디스크 I/O는 패키지당 한 번뿐입니다.

HAST 트리 워킹

InlineIcons는 Quartz의 트랜스포머 플러그인으로, HTML 변환 단계에서 HAST 트리를 순회합니다.

quartz/plugins/transformers/l88_inlineicons.ts
export const InlineIcons: QuartzTransformerPlugin = () => {
  return {
    name: "InlineIcons",
    htmlPlugins(ctx) {
      return [() => transformIcon(ctx)]
    },
  }
}

핵심인 visitElement 함수는 재귀적으로 HAST 노드를 방문하면서 텍스트 노드에서 아이콘 패턴을 찾습니다.

quartz/plugins/transformers/l88_inlineicons.ts
const transformIcon = (ctx: BuildCtx) => {
  async function visitElement(node: any) {
    for (let index = 0; index < node.children.length; index++) {
      const child = node.children[index]
      if (child.type === "element") {
        if (child.tagName !== "code" && child.tagName !== "pre") { // {1}
          await visitElement(child)
        }
      } else if (child.type === "text") {
        const text = child.value
        const iconRegex = /:(\w+):/g // {2}
        let match
        const newChildren = []
        let lastEnd = 0
 
        while ((match = iconRegex.exec(text))) {
          if (lastEnd !== match.index) {
            newChildren.push({ // {3}
              type: "text",
              value: text.substring(lastEnd, match.index),
            })
          }
 
          const iconDetails = getIconDetails(match[1])
          try {
            const svgContent = await getSvgFromZip(
              match[1], ctx.argv.directory,
            )
            newChildren.push({ // {4}
              type: "element",
              tagName: "span",
              properties: {
                class: "inline-article-icon",
                data: iconDetails.iconName,
              },
              children: [{ type: "raw", value: svgContent }],
            })
          } catch (e) {
            console.error(
              `Error loading icon: Skipping ${iconDetails.iconName}`, e,
            )
          }
 
          lastEnd = match.index + match[0].length
        }
 
        if (lastEnd < text.length) {
          newChildren.push({
            type: "text",
            value: text.substring(lastEnd),
          })
        }
 
        node.children.splice(index, 1, ...newChildren) // {5}
      }
    }
  }
 
  return async (tree: HtmlRoot, file: any) => {
    // ... article icon + tree walking
  }
}

단계별로 보면:

  • {1} code, pre 태그는 건너뜀. 코드 블록 안에 :IconName: 패턴이 있어도 치환하지 않습니다.
  • {2} /:(\w+):/g 정규식으로 콜론 감싸기 패턴 매칭
  • {3} 아이콘 앞의 일반 텍스트를 텍스트 노드로 보존
  • {4} 매칭된 패턴을 <span class="inline-article-icon"> 엘리먼트 + 인라인 SVG로 교체
  • {5} 원래의 텍스트 노드를 새로운 노드 배열로 교체 (splice)

하나의 텍스트 노드에 여러 아이콘이 섞여 있을 수 있으므로, while 루프로 모든 매칭을 처리하고, 텍스트-아이콘-텍스트-아이콘 순서의 노드 배열을 만들어 한 번에 교체합니다.

트랜스포머의 진입점에서는 본문 아이콘 치환과 함께, frontmatter icon 필드도 처리합니다.

quartz/plugins/transformers/l88_inlineicons.ts
return async (tree: HtmlRoot, file: any) => {
  const iconKey = file.data.frontmatter?.icon as string | undefined
  if (iconKey) {
    try {
      file.data.iconSvg = await getSvgFromZip(iconKey, ctx.argv.directory)
    } catch (e) {
      console.error(`Error loading article icon: ${iconKey}`, e)
    }
  }
 
  await Promise.all(
    tree.children.map(async (node) => {
      if (node.type === "element") {
        await visitElement(node)
      }
    }),
  )
}

file.data.iconSvg에 SVG 문자열을 저장해두면, 이후 ArticleIcon 컴포넌트가 이를 읽어서 렌더링합니다.

ArticleIcon 컴포넌트

글 제목 위에 표시되는 아이콘은 ArticleIcon 컴포넌트가 담당합니다. 전체 코드가 30줄 미만으로 간결합니다.

quartz/components/l88_ArticleIcon.tsx
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 
function ArticleIcon({ fileData, displayClass }: QuartzComponentProps) {
  const svgContent = fileData.iconSvg
 
  if (svgContent) {
    return (
      <div
        class={`article-icon ${displayClass ?? ""}`}
        dangerouslySetInnerHTML={{ __html: svgContent }}
      />
    )
  } else {
    return null
  }
}
 
ArticleIcon.css = `
.article-icon {
  margin: 5px 0;
  width: 4rem;
  height: 4rem;
  -webkit-user-select: none;
  -moz-user-select: none;
  user-select: none;
}
.article-icon svg {
  width: 100%;
  height: 100%;
}
`
 
export default (() => ArticleIcon) satisfies QuartzComponentConstructor

InlineIcons 트랜스포머가 file.data.iconSvg에 저장한 SVG 문자열을 dangerouslySetInnerHTML로 렌더링합니다. SVG가 없으면 null을 반환하여 아무것도 표시하지 않습니다.

CSS에서 user-select: none으로 아이콘 텍스트 선택을 방지하고, SVG를 4rem 정사각형에 맞춰 표시합니다.

여담

구현 과정에서 한 가지 진화가 있었습니다. 초기에는 ZIP을 미리 풀어서 개별 SVG 파일들을 디렉터리에 저장하는 방식(getExtractedIconPath)을 사용했습니다. 하지만 아이콘 팩 하나에 파일이 수천 개씩 들어있다 보니, Git에서 관리하기가 곤란했습니다. ZIP 기반으로 전환한 뒤에는 패키지당 .zip 파일 하나만 관리하면 되고, JSZip의 메모리 캐싱 덕분에 빌드 성능도 문제없습니다.

구조적으로도 깔끔합니다. 아이콘 키 해석과 ZIP 추출이라는 공통 로직은 iconize.ts에 집중되어 있고, 트랜스포머와 컴포넌트는 각자의 관심사(본문 치환, 제목 아이콘 렌더링)에만 집중합니다. 새로운 아이콘 팩을 추가하려면 packageNameMap에 접두사를 등록하고 ZIP 파일을 넣기만 하면 됩니다.

여기 시행착오를 적어놓지는 않았지만, transformer 제작 과정에서 회귀가 정말 많이 발생해서 고생했습니다. 그래서 이것 저것 수정을 가하다 보니 다른 기능들에 비해서는 아직 복잡도가 높다는 생각이 듭니다. 제가 Typescript 실력이 늘면 좀 더 좋은 방향성이 떠오르지 않을까요? 개선된다면 업데이트하고 Quartz 커뮤니티에도 언젠가 공개할 수 있으면 좋겠습니다!