배경
Obsidian은 표준 마크다운 위에 다양한 확장 문법을 제공한다. Wikilink, Callout, Embed 등 잘 알려진 것 외에도, 커뮤니티 테마나 플러그인이 추가하는 확장 문법이 존재한다. 특히 Minimal Theme은 Alternate Checkbox나 이미지 태그 같은 기능을 제공하는데, 이는 Obsidian 자체 내장이 아니라 테마 수준의 확장이다.
Quartz는 OFM(Obsidian Flavored Markdown) 트랜스포머를 통해 상당수의 Obsidian 확장 문법을 지원하지만, 테마 레벨의 확장까지 커버하지는 않는다. 특히 에디터에서 작성한 결과물이 빌드 후에도 동일하게 보이는 것이 블로그 운영에서 중요한데, 지원되지 않는 문법이 있으면 이 일관성이 깨진다.
이 글에서는 직접 구현하여 이식한 세 가지 기능을 다룬다.
- Alternate Checkbox - 25여 종의 체크박스 변형
- Image Tags -
#invert,#ai태그를 통한 이미지 후처리 - CSS Classes - frontmatter에서 페이지 레벨 스타일 오버라이드
Alternate Checkbox
Obsidian의 Minimal Theme은 기본 체크박스(- [ ], - [x]) 외에 대괄호 안의 문자를 바꿔 다양한 상태를 표현하는 체크박스 변형 문법을 지원한다.
| 문법 | 의미 |
|---|---|
- [/] | In Progress |
- [!] | Important |
- [i] | Information |
- [?] | Question |
- [>] | Forward / Deferred |
위 표는 일부일 뿐이고, 실제로는 약 20종이 존재한다. Quartz의 기본 OFM 트랜스포머는 이 문법을 처리하지 않으므로, Rehype 플러그인으로 직접 구현했다.
구현 방식
핵심은 HAST(HTML Abstract Syntax Tree) 트리를 순회하면서 <li> 노드의 텍스트 내용이 [char] 패턴으로 시작하는지 검사하는 것이다. 패턴이 감지되면 data-task attribute에 해당 문자를 저장하고, 체크박스 <input> 요소를 주입한다.
// visit 함수로 패턴 감지, data-task attribute 부여, checkbox input 주입
const transformCheckbox = () => {
return (tree: HtmlRoot, _file: any) => {
visit(tree, "element", (node: any) => {
if (node.tagName === "li") {
const text = node.children[0]?.value
if (text && /^\[(.)]\s/.test(text)) {
const checkboxValue = text.match(/^\[(.)]\s/)?.[1]
if (checkboxValue) {
node.properties = {
...node.properties,
class: "task-list-item",
"data-task": checkboxValue
}
node.children[0].value = text.replace(/^\[(.)]\s/, " ")
const checkboxInput = {
type: "element",
tagName: "input",
properties: { type: "checkbox", checked: false, disabled: true },
children: []
}
node.children.unshift(checkboxInput)
}
}
}
})
}
}visit 함수는 unist-util-visit에서 가져온 것으로, 트리의 모든 노드를 재귀적으로 순회한다. data-task attribute에 저장된 문자 값은 CSS 쪽에서 아이콘 매핑의 키로 사용된다.
CSS 스타일링
각 data-task 값에 대해 고유한 색상과 mask-image(SVG data URI)를 지정한다. 예를 들어 [i](정보) 변형의 경우 다음과 같다.
li[data-task="i"] > input {
background-color: var(--color-blue);
border-color: var(--color-blue);
background-image: url('data:image/svg+xml,...'); // info icon SVG
}이런 식으로 25여 종의 변형 각각에 대해 색상과 아이콘을 매핑해 두면, Obsidian 에디터에서 보이는 것과 거의 동일한 체크박스가 빌드 결과물에도 렌더링된다.
Image Tags: #invert와 #ai
Obsidian의 wikilink 이미지 문법(![[image.png|500]])에 해시 태그를 추가하여 이미지에 메타데이터를 부여할 수 있다. Minimal Theme에서 #invert 태그를 지원하는 것에서 착안했으며, 이 블로그에서는 두 가지 태그를 지원한다.
![[image.png#invert|500]]- 다크모드 반전 처리![[image.png#ai|500]]- AI 생성 이미지 배지 표시
#invert - 다크모드 대응
학술 논문의 다이어그램, 수식 이미지, 흰 배경의 도표 등은 다크모드에서 눈에 거슬린다. #invert 태그를 붙이면 다크모드일 때 자동으로 색상이 반전되어 자연스럽게 표시된다.
OFM 트랜스포머 수정
기존 Quartz의 wikilink regex는 해시 태그를 하나만 캡처할 수 있었다. 여러 태그를 동시에 사용할 수 있도록(#invert#ai처럼) regex를 수정했다.
// Before: 해시 태그 하나만 캡처
/!?\[\[([^\[\]\|\#\\]+)?(#+[^\[\]\|\#\\]+)?(\\?\|[^\[\]\#]*)?\]\]/g
// After: 다중 해시 태그 캡처
/!?\[\[([^\[\]\|\#\\]+)?((?:#[^\[\]\|\#\\]+)+)?(\\?\|[^\[\]\#]*)?\]\]/g핵심 변경은 (#+[...]+)에서 ((?:#[...]+)+)로 바꾼 것이다. 비캡처 그룹 (?:...)+를 사용하여 #tag 패턴이 하나 이상 반복되는 것을 전체 캡처 그룹으로 묶는다.
이미지 attribute 추가
캡처된 태그 문자열에서 #invert와 #ai 포함 여부를 확인하고, 이미지 요소에 attribute로 추가한다.
const invert = value.includes("#invert") ? "true" : "false"
const ai = value.includes("#ai") ? "true" : "false"이 attribute는 이후 CSS 선택자의 타겟이 된다.
CSS 구현
라이트모드와 다크모드 각각에 대해 다른 처리를 적용한다. 다크모드에서는 filter: invert(1) hue-rotate(180deg)로 색상을 반전시키고, mix-blend-mode: screen으로 배경과 자연스럽게 합성한다.
:root[saved-theme="light"] {
p:has(> img[invert="true"]) {
background-color: var(--light);
img[invert="true"] { mix-blend-mode: multiply; }
}
}
:root[saved-theme="dark"] {
p:has(> img[invert="true"]) {
background-color: var(--light);
img[invert="true"] {
filter: invert(1) hue-rotate(180deg);
mix-blend-mode: screen;
}
}
}라이트모드에서도 mix-blend-mode: multiply를 적용하는 이유는, 이미지 배경이 순수 흰색이 아닌 경우(약간의 회색 등)에도 페이지 배경과 매끄럽게 합쳐지도록 하기 위함이다.
#ai - AI 생성 이미지 배지
AI로 생성된 이미지에는 출처를 명시하는 것이 좋다. #ai 태그를 붙이면 이미지 위에 “AI” 배지가 표시되고, hover 시 설명 텍스트로 확장된다.
p:has(> img[ai="true"]) {
position: relative;
width: fit-content;
&::after {
content: "AI";
// ...positioning, styling...
}
&:hover::after {
content: "이 이미지는 AI로 생성되었습니다";
}
}CSS pseudo-element(::after)만으로 구현되어 있어 DOM 구조를 건드리지 않는다. p:has(> img[ai="true"]) 선택자는 ai="true" attribute를 가진 <img>를 직계 자식으로 포함하는 <p> 요소를 타겟으로 한다.
CSS Classes
Obsidian은 frontmatter에 cssclasses 필드를 지정하여 페이지별로 다른 스타일을 적용할 수 있다. Quartz에서도 이를 지원하도록 구현했다.
현재 지원하는 클래스는 두 가지다.
hide-page-header- 페이지 상단의 헤더 영역을 숨김hide-toc- 목차(Table of Contents)를 숨김
사용법
frontmatter에 배열 형태로 클래스를 지정한다.
---
cssclasses:
- hide-toc
---SCSS 구현
CSS의 :has() 선택자를 활용하여, <article> 요소에 부여된 클래스를 기준으로 상위 또는 형제 요소의 스타일을 오버라이드한다.
#quartz-body .center:has(> article.hide-page-header) {
.page-header .popover-hint { display: none; }
}
#quartz-root:has(#quartz-body .center > article.hide-toc) {
.toc { display: none !important; }
}:has() 선택자의 강점은 하위 요소의 상태를 기준으로 상위 요소를 선택할 수 있다는 점이다. article.hide-toc이라는 하위 조건을 기반으로 #quartz-root 수준에서 .toc을 숨길 수 있어, JavaScript 없이 순수 CSS만으로 페이지 레벨 스타일링이 가능하다.
마무리
세 기능 모두 Obsidian에서 작성한 마크다운이 Quartz에서도 동일하게 렌더링되도록 하는 것이 핵심 목표다. 구현 패턴도 공통적이다.
- 마크다운 파싱 단계에서 패턴을 감지하고 HAST 노드에 attribute를 부여한다.
- CSS 선택자로 해당 attribute를 타겟팅하여 시각적 처리를 적용한다.
이 HAST 트리 조작과 CSS 선택자의 조합은 Quartz 커스터마이징의 기본 패턴이기도 하다. Rehype 플러그인으로 원하는 attribute를 심고, CSS로 렌더링을 제어하는 방식은 다른 Obsidian 확장 문법을 이식할 때도 동일하게 적용할 수 있다.