배경

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

문제는 이 문법이 Obsidian 전용이라는 점이다. Quartz로 빌드하면 :LiSmilePlus:는 그냥 텍스트로 남는다. Obsidian에서 보던 아이콘이 배포된 블로그에서는 전혀 보이지 않는 것이다.

목표는 명확했다.

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

시스템 구조

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

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

l88_ 접두사 컨벤션

커스텀 파일에 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 정사각형에 맞춰 표시한다.

마무리

이 시스템을 통해 Obsidian에서 :LiSmilePlus: 같은 아이콘 문법으로 작성한 문서가, Quartz 빌드 후에도 동일한 SVG 아이콘으로 렌더링된다. 에디터와 배포 사이의 시각적 일관성이 유지되는 셈이다.

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

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