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:
| Plugin | Responsibility |
|---|---|
FrontMatter | Parses YAML frontmatter into fileData.frontmatter |
CreatedModifiedDate | Attaches created and modified dates from frontmatter or git |
ObsidianFlavoredMarkdown | Resolves [[wikilinks]], callouts, Mermaid, and ![[embeds]] |
GitHubFlavoredMarkdown | Tables, strikethrough, task lists, autolinks |
SyntaxHighlighting | Shiki-based code block highlighting |
CrawlLinks | Rewrites internal links to routed paths and records link graph |
TableOfContents | Extracts headings and attaches the ToC tree to fileData |
Latex | Renders 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.
| Plugin | Output |
|---|---|
ContentPage | One HTML file per content page |
FolderPage | Index pages for each folder |
TagPage | Index pages for each tag |
ContentIndex | /sitemap.xml and /index.xml (RSS) |
Assets | Copies content/ static assets |
Static | Copies quartz/static/ assets |
CNAME | Writes 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:
FrontMatteris always first. Every other plugin that reads frontmatter fields fails silently if it runs beforeFrontMatter.ObsidianFlavoredMarkdownmust run beforeCrawlLinks.CrawlLinksresolves links thatObsidianFlavoredMarkdownhas already normalized to standard markdown syntax. Swapping them leaves wikilinks unresolved.SyntaxHighlightingruns afterGitHubFlavoredMarkdown. GFM wraps fenced code blocks in the right AST nodes; syntax highlighting colors them. The reverse order colors nothing.TableOfContentsshould run afterObsidianFlavoredMarkdownso 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" })