Overview

Quartz plugins sit in three categories that match three pipeline stages: transformers parse and enrich the content graph, filters remove pages before they emit, and emitters write files to the public/ directory. Every build runs the full pipeline in order. Understanding the stage boundaries prevents the most common configuration mistakes.

Transformers run first and mutate the content graph

A transformer receives every page’s hast tree and its associated QuartzPluginData after markdown parsing and before any output. Use transformers to extract frontmatter, resolve links, add syntax highlighting, and inject computed metadata.

Key built-in transformers and what they own:

PluginResponsibility
FrontMatterParses YAML frontmatter into fileData.frontmatter
CreatedModifiedDateAttaches created and modified dates from frontmatter or git
ObsidianFlavoredMarkdownResolves [[wikilinks]], callouts, Mermaid, and ![[embeds]]
GitHubFlavoredMarkdownTables, strikethrough, task lists, autolinks
SyntaxHighlightingShiki-based code block highlighting
CrawlLinksRewrites internal links to routed paths and records link graph
TableOfContentsExtracts headings and attaches the ToC tree to fileData
LatexRenders math blocks via KaTeX or MathJax

FrontMatter must be first in the transformers array. Every other transformer that reads fileData.frontmatter depends on it.

Filters run second and remove pages from the graph

A filter is a predicate that returns true to keep a page and false to drop it. Filtered pages do not appear in any emitter output, including the link graph and backlinks.

filters: [Plugin.RemoveDrafts()],

RemoveDrafts drops any page where frontmatter.draft === true or frontmatter.status === "draft" (depending on configuration). Add it in production. Remove it in development when reviewing draft pages is needed. A custom filter looks like:

// Drop any page tagged "internal"
const RemoveInternal: QuartzFilterPlugin = () => ({
  name: "RemoveInternal",
  shouldPublish(_ctx, [_tree, vfile]) {
    const tags: string[] = vfile.data.frontmatter?.tags ?? []
    return !tags.includes("internal")
  },
})

Emitters run last and write output files

An emitter takes the filtered graph and produces one or more files in public/. Each emitter owns a specific output type.

PluginOutput
ContentPageOne HTML file per content page
FolderPageIndex pages for each folder
TagPageIndex pages for each tag
ContentIndex/sitemap.xml and /index.xml (RSS)
AssetsCopies content/ static assets
StaticCopies quartz/static/ assets
CNAMEWrites public/CNAME from baseUrl

CNAME must be in the emitters list for custom-domain deploys on GitHub Pages. See quartz-deployment for the full deploy flow.

Write a custom transformer by implementing QuartzTransformerPlugin

A transformer plugin returns an object with optional remark and rehype hooks plus a name string.

import { QuartzTransformerPlugin } from "../types"
 
export const ReadingTimeEstimate: QuartzTransformerPlugin = () => ({
  name: "ReadingTimeEstimate",
  markdownPlugins() {
    return [
      () => (tree, vfile) => {
        const words = vfile.value.toString().split(/\s+/).length
        vfile.data.frontmatter ??= {}
        vfile.data.frontmatter.readingTime = Math.ceil(words / 200)
      },
    ]
  },
})

Return markdownPlugins() for remark (pre-hast) transforms and htmlPlugins() for rehype (post-hast) transforms. Both methods return arrays of unified plugins.

Respect transformer ordering or watch silently wrong output appear

Transformers run in declaration order. The rules:

  1. FrontMatter is always first. Every other plugin that reads frontmatter fields fails silently if it runs before FrontMatter.
  2. ObsidianFlavoredMarkdown must run before CrawlLinks. CrawlLinks resolves links that ObsidianFlavoredMarkdown has already normalized to standard markdown syntax. Swapping them leaves wikilinks unresolved.
  3. SyntaxHighlighting runs after GitHubFlavoredMarkdown. GFM wraps fenced code blocks in the right AST nodes; syntax highlighting colors them. The reverse order colors nothing.
  4. TableOfContents should run after ObsidianFlavoredMarkdown so that heading IDs account for any transformations applied to heading text.

See quartz-debugging for how to trace which transformer mutated a page.

Avoid the name collision pitfall in custom plugins

Each plugin’s name field must be unique across transformers, filters, and emitters. Quartz uses name to identify plugins in its cache and error messages. If two plugins share a name, the second one silently overwrites the cache entry of the first, producing stale output that persists across rebuilds until .quartz-cache/ is cleared.

// Wrong: generic name risks collision
const MyPlugin: QuartzTransformerPlugin = () => ({ name: "Plugin" })
 
// Correct: scoped, specific name
const MyPlugin: QuartzTransformerPlugin = () => ({ name: "InjectReadingTime" })