Summary

Quartz 플러그인 대부분은 npm 패키지 래퍼였다. GFM, LaTeX, SyntaxHighlighting은 직접 써도 동일하다. 진짜 Quartz의 자산은 OFM — wikilink, callout, tag 파싱 로직을 직접 구현한 플러그인이다. externalResources는 Quartz 자체 런타임에 의존해서 Next.js에서 쓸 수 없고, React 컴포넌트로 대체하면 된다.

이전 글

sync-quartz 전략이 왜 막혔는지는 첫 번째 글에서 다뤘다. 이 글은 그 원인을 파악하기 위해 Quartz 플러그인 구조를 직접 해부한 기록이다.


플러그인 인터페이스

Quartz의 Transformer 플러그인은 4가지 메서드로 구성된다:

// quartz/plugins/types.ts
type QuartzTransformerPluginInstance = {
  name: string
  textTransform?: (ctx: BuildCtx, src: string) => string     // 파싱 전 텍스트 변환
  markdownPlugins?: (ctx: BuildCtx) => PluggableList          // remark 플러그인 목록 반환
  htmlPlugins?: (ctx: BuildCtx) => PluggableList              // rehype 플러그인 목록 반환
  externalResources?: (ctx: BuildCtx) => { js, css }         // 브라우저 런타임 리소스
}

핵심은 markdownPluginshtmlPlugins가 결국 remark/rehype 플러그인 배열을 반환한다는 점이다. Quartz는 unified 파이프라인 위에서 동작한다.


대부분은 npm 패키지 래퍼

플러그인들을 하나씩 열어보면 실체가 보인다.

GFM (GitHub Flavored Markdown):

// quartz/plugins/transformers/gfm.ts
markdownPlugins() {
  return [remarkGfm, smartypants]  // 그냥 npm 패키지 반환
}
htmlPlugins() {
  return [rehypeSlug, rehypeAutolinkHeadings]  // 그냥 npm 패키지 반환
}

Latex:

// quartz/plugins/transformers/latex.ts
import remarkMath from "remark-math"
import rehypeKatex from "rehype-katex"
 
markdownPlugins() { return [remarkMath] }
htmlPlugins() { return [[rehypeKatex, { output: "html" }]] }

SyntaxHighlighting:

// quartz/plugins/transformers/syntax.ts
import rehypePrettyCode from "rehype-pretty-code"
 
htmlPlugins() { return [[rehypePrettyCode, opts]] }

이 세 개는 Quartz 래퍼 없이 npm 패키지를 직접 써도 완전히 동일하다.


진짜 핵심: OFM

ObsidianFlavoredMarkdown (OFM) 만이 진짜 Quartz의 자산이다.

wikilink, callout, highlight(==text==), block reference(^id), tag 파싱 — 이 로직들을 Quartz 개발자들이 직접 구현했다.

// quartz/plugins/transformers/ofm.ts - 직접 구현한 정규식들
export const wikilinkRegex = new RegExp(
  /!?\[\[([^\[\]\|\#\\]+)?(#+[^\[\]\|\#\\]+)?(\\?\|[^\[\]\#]*)?\]\]/g
)
export const calloutRegex = new RegExp(/^\[\!([\w-]+)\|?(.+?)?\]([+-]?)/)
export const tagRegex = new RegExp(
  /(?<=^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/gu
)

실제 callout 파싱도 직접 구현했다:

markdownPlugins(ctx) {
  plugins.push(() => {
    return (tree: Root, _file) => {
      visit(tree, "blockquote", (node) => {
        const match = firstLine.match(calloutRegex)
        if (match) {
          node.data = {
            hProperties: {
              className: ["callout", calloutType],
              "data-callout": calloutType,
            }
          }
        }
      })
    }
  })
}

정리하면:

플러그인출처
Latexnpm 래퍼 (remark-math, rehype-katex)
SyntaxHighlightingnpm 래퍼 (rehype-pretty-code)
GFMnpm 래퍼 (remark-gfm, rehype-slug)
FrontMatternpm 래퍼 (gray-matter, remark-frontmatter)
OFMQuartz 자체 구현 ← 진짜 가치
TOC, Links부분 자체 구현

externalResources란?

inline script 이슈의 실체가 바로 externalResources다.

동작 방식

externalResources()는 플러그인이 브라우저에서 실행될 JS/CSS를 선언하는 메서드다.

// ofm.ts
externalResources() {
  return {
    js: [
      { script: calloutScript, loadTime: "afterDOMReady", contentType: "inline" },
      { script: checkboxScript, loadTime: "afterDOMReady", contentType: "inline" },
      { script: mermaidScript, loadTime: "afterDOMReady", moduleType: "module" },
    ]
  }
}

Quartz 빌드 파이프라인은 이걸 수집해서 생성된 HTML의 <head><script> 태그로 주입한다.

[플러그인 externalResources()]
    ↓ getStaticResourcesFromPlugins(ctx)
    ↓ emitter.emit(ctx, content, staticResources)
[생성된 HTML에 <script> 주입]
    ↓ 브라우저에서 실행
[callout 접기/펼치기, mermaid 렌더링 등]

왜 Next.js에서 안 되나

Quartz의 스크립트들은 Quartz 고유 런타임에 의존한다.

// callout.inline.ts
document.addEventListener("nav", setupCallout)
//                         ↑
//          Quartz SPA 라우터가 dispatch하는 커스텀 이벤트
//          Next.js에는 이 이벤트가 없음

Quartz는 자체 SPA 라우터(spa.inline.ts)가 있고, 페이지 이동 시마다 nav 이벤트를 dispatch한다. 스크립트들은 이 이벤트를 들어서 재초기화한다.

static site라서 필요한 것

Quartz는 완전한 .html 파일을 생성하는 정적 사이트 생성기다. React 런타임이 없으니 인터랙션을 <script> 태그로 직접 주입해야 한다.

Next.js에서는 같은 기능을 React 컴포넌트로 구현하면 된다.

// Quartz 방식
- document.addEventListener("nav", () => {
-   document.querySelectorAll(".callout-title").forEach(el => {
-     el.addEventListener("click", toggle)
-   })
- })
 
// Next.js 방식
+ function Callout({ type, title, children }) {
+   const [collapsed, setCollapsed] = useState(false)
+   return (
+     <div onClick={() => setCollapsed(!collapsed)}>{title}</div>
+   )
+ }

결국 externalResources()는 Quartz가 자체 런타임을 가지고 있기 때문에 필요한 개념이다. Next.js에서는 React 컴포넌트로 대체하면 그만이다.


결론: 무엇을 가져올 것인가

분석을 통해 명확해진 것:

  • Quartz 플러그인의 markdownPlugins() / htmlPlugins()재사용 가능하다
  • externalResources()무시하면 된다 (React로 대체)
  • Latex, GFM, Syntax는 npm 패키지 직접 사용이 더 단순하다

OFM 플러그인을 직접 가져오는 방식:

import { ObsidianFlavoredMarkdown } from "../quartz/plugins/transformers/ofm"
 
const ofm = ObsidianFlavoredMarkdown({ wikilinks: true, callouts: true })
 
const processor = unified()
  .use(remarkParse)
  .use(ofm.markdownPlugins({ allSlugs: [] }))  // wikilink, callout, tag 파싱
  .use(remarkRehype)
  .use(ofm.htmlPlugins())                       // block reference, YouTube embed
  .use(rehypeStringify)

ctx.allSlugs는 broken wikilink 감지 옵션에서만 쓰이고, 나머지는 ctx 의존성이 거의 없다. 빈 배열로 넘겨도 동작한다.

sync-quartz는 이 목적으로는 유효하다

OFM 파일 하나만 복사해서 쓰는 용도라면 sync-quartz 전략은 여전히 의미가 있다. 문제는 Quartz 전체를 래핑하려 했던 초기 설계였다.

수정된 전략:
upstream/quartz/quartz/plugins/transformers/ofm.ts  ← 이것만 가져옴
나머지는 npm 패키지 직접 사용