배경
Obsidian은 표준 마크다운 위에 다양한 확장 문법을 제공합니다. Wikilink, Callout, Embed 등 잘 알려진 것 외에도, 커뮤니티 테마나 플러그인이 추가하는 확장 문법이 존재합니다. 특히 Minimal Theme은 Alternate Checkbox나 이미지 태그 같은 기능을 제공하는데, 이는 Obsidian 자체 내장이 아니라 테마 수준의 확장입니다.
Quartz는 OFM(Obsidian Flavored Markdown) 트랜스포머를 통해 상당수의 Obsidian 확장 문법을 지원하지만, 테마 레벨의 확장까지 커버하지는 않습니다. 특히 에디터에서 작성한 결과물이 빌드 후에도 동일하게 보이는 것이 블로그 운영에서 중요한데, 지원되지 않는 문법이 있으면 이 일관성이 깨집니다.
이 글에서는 직접 구현하여 이식한 세 가지 기능을 다룹니다.
- Alternate Checkbox - 25여 종의 체크박스 변형
- Image Tags -
#invert,#ai태그를 통한 이미지 후처리
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 패턴이 하나 이상 반복되는 것을 전체 캡처 그룹으로 묶습니다.
솔직히 저는 정규표현식은 잘 몰라서 AI한테 부탁했습니다. 회귀가 발생할 수 있을까요? - 아직까지 해당 케이스는 발견되지 않았습니다.
이미지 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> 요소를 타겟으로 합니다.
결과물

위 사진은 ![[home_banner.png#ai#invert|400]] 가 빌드된 결과입니다. (다크모드에서도 정상적으로 invert되는 것을 볼 수 있습니다.)