배경
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할 때 파일명 충돌을 원천적으로 방지한다.
흐름은 다음과 같다.
- 빌드 시작 - Quartz가 마크다운을 파싱하여 HAST(HTML Abstract Syntax Tree)를 생성
- InlineIcons 트랜스포머 - HAST 트리를 순회하며 텍스트 노드에서
:IconName:패턴을 탐색 - iconize.ts - 아이콘 키에서 패키지명과 파일명을 추출하고, ZIP에서 SVG를 로드
- SVG 치환 - 매칭된 패턴을
<span class="inline-article-icon">+ 인라인 SVG로 교체 - ArticleIcon - frontmatter
icon필드가 있으면 같은 경로로 SVG를 가져와 제목 위에 렌더링
핵심은 iconize.ts가 아이콘 키 해석과 ZIP 추출이라는 두 가지 관심사를 공유 유틸리티로 분리하고, 트랜스포머와 컴포넌트 양쪽에서 이를 재사용한다는 점이다.
아이콘 키 체계
아이콘 키는 접두사 + PascalCase 아이콘명으로 구성된다. 접두사가 패키지를 결정하고, 나머지가 파일명으로 변환된다.
| 접두사 | 패키지 | 예시 |
|---|---|---|
Li | lucide-icons | LiDatabaseZap |
Si | simple-icons | SiTypescript |
Fi | feather-icons | FiGithub |
Fas | font-awesome-solid | FasCircle |
Fab | font-awesome-brands | FabGithub |
Ti | tabler-icons | TiBrandGithub |
총 14개의 아이콘 팩을 지원한다.
getIconDetails 함수가 이 변환을 담당한다.
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}가장 긴 접두사 우선 매칭 -Fas와Fa가 모두 매칭될 때, 길이가 긴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 기반으로 전환한 뒤에는 패키지당 파일 하나만 관리하면 된다.
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 트리를 순회한다.
export const InlineIcons: QuartzTransformerPlugin = () => {
return {
name: "InlineIcons",
htmlPlugins(ctx) {
return [() => transformIcon(ctx)]
},
}
}핵심인 visitElement 함수는 재귀적으로 HAST 노드를 방문하면서 텍스트 노드에서 아이콘 패턴을 찾는다.
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 필드도 처리한다.
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줄 미만으로 간결하다.
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 QuartzComponentConstructorInlineIcons 트랜스포머가 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 파일을 넣기만 하면 된다.