Migrate ArticleTitle, TagList, PageTitle, Darkmode, ReaderMode, ContentMeta, and Footer from internal components to community plugins. Update layout to use Plugin.X() pattern, remove internal component files and their styles/scripts. Add MIGRATION_TASKS.md documenting the full migration roadmap.
58 KiB
Quartz v5 Migration Tasks
Roadmap for migrating internal components to community plugins and adopting
@quartz-community/typesin core.
Table of Contents
- Plan Overview
- Architecture Summary
- Component Analysis
- Migration Plan
- Execution Order
- Migration Checklist Templates
Plan Overview
Quartz v5 is transitioning from a monolithic component architecture to a plugin-first model where community plugins are first-class citizens. This migration has four phases:
- Phase A — Delete Internal Duplicates: Remove internal components that already have community plugin equivalents.
- Phase B — Migrate Feature Components: Extract additional feature components to community plugins.
- Phase C — Type Unification: Adopt
@quartz-community/typesin Quartz core'squartz/components/types.ts, eliminatingas QuartzComponentcasts inquartz.layout.ts. - Phase D — PageType System: Introduce PageType as a new first-class plugin category. Reclassify current emitters into PageTypes, FileEmitters, and StaticFiles. Migrate all three page-rendering emitters (Content, Folder, Tags) to community plugins. Keep 404 as a core default fallback.
Current State
- 10 community plugins exist across
quartz-community/*repos:explorer,graph,search,table-of-contents,backlinks,comments,breadcrumbs,recent-notes,latex, and theplugin-template. - 6 of these have corresponding internal duplicates still in
quartz/components/that should be removed. quartz.layout.tsusesas QuartzComponentcasts to bridge type incompatibility between core and community types.- All community plugins import from
@quartz-community/types(viagithub:quartz-community/types).
Goal State
- Internal duplicates deleted from
quartz/components/. - Additional feature components migrated to community plugins.
- Core
quartz/components/types.tsre-exports from@quartz-community/types(or aligns structurally), eliminating the need foras QuartzComponentcasts. quartz.layout.tsuses plugin components without type casts.- PageType introduced as a new first-class plugin category — page-rendering emitters replaced by declarative PageType plugins.
- All three page types (Content, Folder, Tags) migrated to community plugins. 404 stays in core as a default fallback.
- Current emitters reclassified into PageTypes, FileEmitters, StaticFiles, and core infrastructure.
- Layout configuration restructured to
layout.defaults(universal) +layout.byPageType(per-page-type slots).
Architecture Summary
How Components Are Used
quartz.config.ts → declares externalPlugins (community plugin sources)
quartz.layout.ts → composes components into page layouts
quartz/components/ → internal component definitions
.quartz/plugins/ → installed community plugins (git-cloned)
Emitters (contentPage, tagPage, folderPage, 404) receive layouts and call:
renderPage(cfg, slug, componentData, layout, resources)
├─ Head (from sharedPageComponents.head — hardcoded in layout)
├─ Body (hardcoded in renderPage.tsx as BodyConstructor)
├─ Header (hardcoded in renderPage.tsx as HeaderConstructor)
├─ beforeBody (from layout — configurable)
├─ left (from layout — configurable)
├─ pageBody (from emitter — Content, TagContent, FolderContent, NotFound)
├─ right (from layout — configurable)
├─ afterBody (from layout — configurable)
└─ Footer (from sharedPageComponents.footer — hardcoded in layout)
The Type Cast Problem
Core defines QuartzComponent as:
// quartz/components/types.ts
type QuartzComponent = ComponentType<QuartzComponentProps> & {
css?: StringResource
beforeDOMLoaded?: StringResource
afterDOMLoaded?: StringResource
}
Community types define it as:
// @quartz-community/types
type QuartzComponent = ((props: QuartzComponentProps) => unknown) & {
css?: string | string[] | undefined
beforeDOMLoaded?: string | string[] | undefined
afterDOMLoaded?: string | string[] | undefined
}
The difference: core uses ComponentType<P> (preact-specific, returns VNode | null), community uses (props) => unknown (preact-agnostic). Because community plugins have their own node_modules with separate preact/hast types, TypeScript sees them as distinct types even though they're structurally compatible at runtime.
This is why quartz.layout.ts currently needs:
const searchComponent = Plugin.Search() as QuartzComponent
Eliminating these casts is Phase B of this migration.
Component Analysis
Classification Legend
| Category | Meaning |
|---|---|
| 🔴 Core Infrastructure | Must stay in core — hardcoded in rendering pipeline or emitters |
| 🟡 Core Utility | Layout wrappers / composition — must stay, used to arrange other components |
| 🟢 Already Migrated | Community plugin exists, internal copy is a duplicate to be deleted |
| 🔵 Migration Candidate | Feature component that can be extracted to a community plugin |
| ⚪ Supporting | Helper component, not a QuartzComponent — used internally by others |
🔴 Core Infrastructure (MUST stay in core)
These are hardcoded in emitters or renderPage.tsx and are fundamental to the build pipeline.
| Component | File | Used By | Why Core |
|---|---|---|---|
| Head | Head.tsx |
All emitters via sharedPageComponents.head |
Renders <head> with meta, SEO, styles, scripts. Required for every page. |
| Body | Body.tsx |
renderPage.tsx (as BodyConstructor) |
Page <body> wrapper. Includes clipboard script. Hardcoded in render pipeline. |
| Header | Header.tsx |
renderPage.tsx (as HeaderConstructor) |
Page header wrapper. Hardcoded in render pipeline. |
| Content | pages/Content.tsx |
contentPage.tsx emitter |
Renders article HTML content. Tightly coupled to emitter. |
| TagContent | pages/TagContent.tsx |
tagPage.tsx emitter |
Renders tag listing page. Depends on PageList. Tightly coupled to emitter. |
| FolderContent | pages/FolderContent.tsx |
folderPage.tsx emitter |
Renders folder listing page. Depends on PageList. Tightly coupled to emitter. |
| NotFound | pages/404.tsx |
404.tsx emitter |
404 page content. Tightly coupled to emitter. |
| registry.ts | registry.ts |
external.ts, componentResources.ts emitter |
Component registration system for dynamic plugin lookup. |
| external.ts | external.ts |
quartz.layout.ts (via External()) |
External component loader. Required for plugin system. |
| types.ts | types.ts |
Every component | Type definitions (QuartzComponent, QuartzComponentProps, QuartzComponentConstructor). |
| index.ts | index.ts |
Everywhere | Barrel exports for all built-in components. |
| renderPage.tsx | renderPage.tsx |
All page emitters | Core rendering engine. Composes layouts, handles transclusions, injects resources. |
🟡 Core Utility (Layout wrappers — stay in core)
These are composition utilities that take QuartzComponent as arguments. They must remain in core to work with both internal and external components.
| Component | File | Dependencies | Purpose |
|---|---|---|---|
| DesktopOnly | DesktopOnly.tsx |
Component types | Wraps a component to show only on desktop. |
| MobileOnly | MobileOnly.tsx |
Component types | Wraps a component to show only on mobile. |
| Flex | Flex.tsx |
Component types, resources | Arranges multiple components in a flex row. |
| ConditionalRender | ConditionalRender.tsx |
Component types | Renders a component only when a condition is met. |
| Spacer | Spacer.tsx |
lang utility |
Simple layout spacer element. |
🟢 Already Migrated (Delete internal duplicates)
These components have community plugin equivalents in quartz-community/* repos. The internal copies are dead code and should be removed along with their associated styles and scripts.
| Component | Internal File | Community Plugin | Associated Styles | Associated Scripts |
|---|---|---|---|---|
| Backlinks | Backlinks.tsx |
github:quartz-community/backlinks |
styles/backlinks.scss |
(uses OverflowList) |
| Breadcrumbs | Breadcrumbs.tsx |
github:quartz-community/breadcrumbs |
styles/breadcrumbs.scss |
— |
| RecentNotes | RecentNotes.tsx |
github:quartz-community/recent-notes |
styles/recentNotes.scss |
— |
| Search | Search.tsx |
github:quartz-community/search |
styles/search.scss |
scripts/search.inline.ts |
| TableOfContents | TableOfContents.tsx |
github:quartz-community/table-of-contents |
styles/toc.scss, styles/legacyToc.scss |
scripts/toc.inline.ts |
| Comments | Comments.tsx |
github:quartz-community/comments |
— | scripts/comments.inline.ts |
Note: OverflowList.tsx is a helper used by the internal Backlinks.tsx and TableOfContents.tsx. After deleting these internal duplicates, verify whether OverflowList.tsx is still imported by any remaining core component. If not, it can also be removed.
🔵 Migration Candidates (Feature components)
These are user-facing, swappable components with no hard coupling to the build pipeline. They can be extracted to community plugins.
| Component | File | Dependencies | Scripts | Styles | Complexity | Notes |
|---|---|---|---|---|---|---|
| Darkmode | Darkmode.tsx |
i18n, lang |
scripts/darkmode.inline.ts |
styles/darkmode.scss |
Low | Theme toggle button. Self-contained. Interacts with CSS variables. |
| ReaderMode | ReaderMode.tsx |
i18n, lang |
scripts/readermode.inline.ts |
styles/readermode.scss |
Low | Reader mode toggle. Self-contained. Hides sidebars. |
| PageTitle | PageTitle.tsx |
path, i18n, lang |
— | Inline CSS | Low | Site title in sidebar. Uses pathToRoot for link. |
| ArticleTitle | ArticleTitle.tsx |
lang |
— | Inline CSS | Very Low | Renders frontmatter title. Minimal logic. |
| ContentMeta | ContentMeta.tsx |
Date component, i18n, lang |
— | styles/contentMeta.scss |
Low | Shows date and reading time. Uses the Date utility. |
| TagList | TagList.tsx |
path, lang |
— | Inline CSS | Low | Renders tag links. Uses resolveRelative for URLs. |
| Footer | Footer.tsx |
i18n |
— | styles/footer.scss |
Low | Shows version and links. Commonly customized. |
⚪ Supporting Components (NOT QuartzComponents)
These are helper components or utilities used internally by other components. They don't follow the QuartzComponentConstructor factory pattern.
| Component | File | Used By | Migration Impact |
|---|---|---|---|
| Date | Date.tsx |
ContentMeta, RecentNotes (community), PageList |
Stays in core. Community plugins that need date formatting bundle their own. |
| PageList | PageList.tsx |
TagContent, FolderContent |
Stays in core. Used by page-type components which are core. |
| OverflowList | OverflowList.tsx |
Backlinks (internal), TableOfContents (internal) |
Review after deleting internal duplicates. May become unused. |
Scripts Inventory
| Script | File | Used By | Category |
|---|---|---|---|
clipboard.inline.ts |
scripts/ |
Body.tsx |
🔴 Core |
spa.inline.ts |
scripts/ |
componentResources.ts emitter |
🔴 Core |
popover.inline.ts |
scripts/ |
componentResources.ts emitter |
🔴 Core |
callout.inline.ts |
scripts/ |
Transformer (not a component) | 🔴 Core |
checkbox.inline.ts |
scripts/ |
Transformer (not a component) | 🔴 Core |
mermaid.inline.ts |
scripts/ |
Transformer (not a component) | 🔴 Core |
darkmode.inline.ts |
scripts/ |
Darkmode.tsx |
🔵 Migrate with Darkmode |
readermode.inline.ts |
scripts/ |
ReaderMode.tsx |
🔵 Migrate with ReaderMode |
search.inline.ts |
scripts/ |
Search.tsx (internal duplicate) |
🟢 Delete |
toc.inline.ts |
scripts/ |
TableOfContents.tsx (internal duplicate) |
🟢 Delete |
comments.inline.ts |
scripts/ |
Comments.tsx (internal duplicate) |
🟢 Delete |
Styles Inventory
| Style | File | Used By | Category |
|---|---|---|---|
clipboard.scss |
styles/ |
Body.tsx |
🔴 Core |
popover.scss |
styles/ |
componentResources.ts emitter |
🔴 Core |
listPage.scss |
styles/ |
TagContent, FolderContent |
🔴 Core |
mermaid.inline.scss |
styles/ |
Transformer (not a component) | 🔴 Core |
backlinks.scss |
styles/ |
Backlinks.tsx (internal duplicate) |
🟢 Delete |
breadcrumbs.scss |
styles/ |
Breadcrumbs.tsx (internal duplicate) |
🟢 Delete |
recentNotes.scss |
styles/ |
RecentNotes.tsx (internal duplicate) |
🟢 Delete |
search.scss |
styles/ |
Search.tsx (internal duplicate) |
🟢 Delete |
toc.scss |
styles/ |
TableOfContents.tsx (internal duplicate) |
🟢 Delete |
legacyToc.scss |
styles/ |
TableOfContents.tsx (internal duplicate) |
🟢 Delete |
darkmode.scss |
styles/ |
Darkmode.tsx |
🔵 Migrate with Darkmode |
readermode.scss |
styles/ |
ReaderMode.tsx |
🔵 Migrate with ReaderMode |
contentMeta.scss |
styles/ |
ContentMeta.tsx |
🔵 Migrate with ContentMeta |
footer.scss |
styles/ |
Footer.tsx |
🔵 Migrate with Footer |
Migration Plan
Phase A: Delete Internal Duplicates
Goal: Remove the 6 internal components that already have community plugin equivalents.
What to delete:
-
Component files:
quartz/components/Backlinks.tsxquartz/components/Breadcrumbs.tsxquartz/components/RecentNotes.tsxquartz/components/Search.tsxquartz/components/TableOfContents.tsxquartz/components/Comments.tsx
-
Associated styles:
quartz/components/styles/backlinks.scssquartz/components/styles/breadcrumbs.scssquartz/components/styles/recentNotes.scssquartz/components/styles/search.scssquartz/components/styles/toc.scssquartz/components/styles/legacyToc.scss
-
Associated scripts:
quartz/components/scripts/search.inline.tsquartz/components/scripts/toc.inline.tsquartz/components/scripts/comments.inline.ts
-
Update barrel exports in
quartz/components/index.ts— remove the deleted components from the export list. -
Verify
OverflowList.tsx— check if it's still imported by any remaining component. If not, delete it too.
Validation: Run tsc --noEmit and npx quartz build to ensure nothing breaks.
Phase B: Migrate Feature Components (Optional)
Goal: Extract additional feature components to community plugins using the established plugin template pattern.
Recommended migration order (by independence and complexity):
| Priority | Component | Reason |
|---|---|---|
| 1 | ArticleTitle | Simplest component. Zero dependencies beyond lang. Good proof of concept. |
| 2 | TagList | Simple. Only needs path utilities. |
| 3 | PageTitle | Simple. Needs pathToRoot utility. |
| 4 | Darkmode | Self-contained with script. Needs inline script bundling. |
| 5 | ReaderMode | Same pattern as Darkmode. |
| 6 | ContentMeta | Slightly more complex — uses Date helper component. Plugin must include its own date formatting. |
| 7 | Footer | Simple, but commonly customized. Consider keeping in core as a default. |
For each migration, follow the Migration Checklist Template below.
After migrating each component:
- Delete internal component file + its styles/scripts.
- Add the new community plugin to
externalPluginsinquartz.config.ts. - Update
quartz.layout.tsto use the plugin component. - Remove the component from
quartz/components/index.ts. - Run
tsc --noEmitandnpx quartz build.
Phase C: Adopt @quartz-community/types in Core
Goal: Unify the type definitions so community plugin components are directly assignable to QuartzComponent without casts.
Prerequisite: Phase A must be complete. Phase B is recommended but not required.
Strategy:
There are two approaches, in order of preference:
Option 1: Re-export from community types (Recommended)
Make core's quartz/components/types.ts re-export from @quartz-community/types:
// quartz/components/types.ts
export type {
QuartzComponent,
QuartzComponentConstructor,
QuartzComponentProps,
StringResource,
} from "@quartz-community/types"
Pros: Single source of truth. Eliminates type mismatch entirely.
Cons: Core loses preact-specific return type (VNode | null → unknown). Internal components would still work at runtime, but TypeScript wouldn't catch cases where a component returns a non-JSX value.
Mitigation: Internal components already return JSX correctly. The looser return type only matters for new component development, and the factory pattern enforced by satisfies QuartzComponentConstructor provides a guardrail.
Option 2: Structural alignment
Keep separate type definitions but make them structurally compatible:
// quartz/components/types.ts
export type QuartzComponent = ((props: QuartzComponentProps) => unknown) & {
css?: string | string[]
beforeDOMLoaded?: string | string[]
afterDOMLoaded?: string | string[]
}
Pros: Core controls its own types. Can be stricter internally.
Cons: Still separate type identities. May not resolve as QuartzComponent casts if QuartzComponentProps also differs.
Steps for Option 1
-
Add
@quartz-community/typesas a dependency inpackage.json:"dependencies": { "@quartz-community/types": "github:quartz-community/types" } -
Update
quartz/components/types.tsto re-export:export type { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps, StringResource, } from "@quartz-community/types" -
Resolve any type conflicts in internal components:
- Internal components use concrete preact types (
JSX.Element,VNode). - These are assignable to
unknown, so they should work. - Run
tsc --noEmitto verify.
- Internal components use concrete preact types (
-
Update
quartz.layout.ts:- Remove all
as QuartzComponentcasts. - Remove the
import { QuartzComponent } from "./quartz/components/types"line.
- Remove all
-
Verify:
tsc --noEmitpasses.npx quartz buildsucceeds.- Site renders correctly.
Phase D: PageType System
Goal: Introduce PageType as a new first-class plugin category (alongside transformers, filters, emitters, and components), reclassify all current emitters into purpose-specific categories, and migrate all three page-rendering emitters to community plugins.
Prerequisites: Phase A and Phase C must be complete. Phase B is recommended.
D.1: Design Overview
Currently, every HTML-rendering emitter (ContentPage, FolderPage, TagPage, NotFoundPage) follows identical boilerplate:
- Filter content files to find matching pages
- Merge shared + page layout into
FullPageLayout - Instantiate Header and Body constructors
- Build
componentData - Call
renderPage(cfg, slug, componentData, layout, resources) - Write the resulting HTML to output
The only differences between emitters are which files they match (step 1) and what body component they render (the pageBody slot). This means the rendering pipeline is duplicated four times with only the filtering and body varying.
PageType extracts this pattern into a declarative plugin interface:
interface PageTypePlugin {
name: string
priority?: number // higher wins conflicts, default 0
match: PageMatcher // which source files this page type owns
generate?: PageGenerator // create virtual pages (e.g., tag index, folder index)
layout: string // layout key (references layout.byPageType)
body: QuartzComponentConstructor // the page body component
}
A new PageType dispatcher in core replaces all individual page-emitting emitters. It iterates over registered PageTypes, runs their matchers and generators, resolves layout, and calls renderPage once per page.
D.2: Emitter Reclassification
Every current emitter is reclassified into one of four categories:
| Current Emitter | New Category | New Location | Rationale |
|---|---|---|---|
contentPage |
PageType plugin | github:quartz-community/content-page |
Renders individual content pages. Match: all .md files. Body: Content component. |
folderPage |
PageType plugin | github:quartz-community/folder-page |
Generates folder index pages. Match: none (virtual). Generate: one page per folder. Body: FolderContent. |
tagPage |
PageType plugin | github:quartz-community/tag-page |
Generates tag index pages. Match: none (virtual). Generate: one page per tag + tag listing. Body: TagContent. |
404 |
PageType (core) | quartz/plugins/pageTypes/404.ts |
Default 404 fallback. Stays in core. Match: none. Generate: single /404 page. |
componentResources |
Core infra | quartz/plugins/emitters/componentResources.ts (unchanged) |
Aggregates CSS/JS from all components, handles analytics, SPA router, font loading. Cannot be a plugin — it needs access to all registered components. |
contentIndex |
FileEmitter | quartz/plugins/emitters/contentIndex.tsx (stays, reclassified) |
Generates sitemap.xml, RSS feed, contentIndex.json. Cross-page aggregation — not a page type. |
aliases |
FileEmitter | quartz/plugins/emitters/aliases.tsx (stays, reclassified) |
Generates HTML redirect stubs from frontmatter aliases. Bulk generation, not a page type. |
ogImages |
FileEmitter | quartz/plugins/emitters/ogImages.tsx (stays, reclassified) |
Generates OG image binaries. Asset generation, not a page type. |
assets |
StaticFiles | quartz/plugins/emitters/assets.ts (stays, reclassified) |
Copies non-markdown files from content directory verbatim. |
static |
StaticFiles | quartz/plugins/emitters/static.ts (stays, reclassified) |
Copies quartz/static/ directory verbatim. |
favicon |
StaticFiles | quartz/plugins/emitters/favicon.tsx (stays, reclassified) |
Generates or copies favicon files. |
cname |
StaticFiles | quartz/plugins/emitters/cname.ts (stays, reclassified) |
Writes CNAME file for custom domains. |
Category definitions:
- PageType: Owns a set of routes, provides a body component and layout reference, produces rendered HTML pages.
- FileEmitter: Produces non-HTML output files (XML, JSON, binary) by aggregating data across all content. Keeps the existing
QuartzEmitterPlugininterface. - StaticFiles: Copies or generates files that don't depend on content processing. Keeps the existing
QuartzEmitterPlugininterface. - Core infrastructure:
ComponentResources— must remain in core as it aggregates resources from all registered components.
Note
: FileEmitter and StaticFiles are conceptual reclassifications for clarity. They continue to use the existing
QuartzEmitterPlugininterface and"emitter"category in the plugin manifest. No code changes are required for these — only the page-rendering emitters are replaced by PageTypes.
D.3: Composable Matchers
PageTypes use composable matcher functions to declare which source files they own:
// Primitive matchers
match.ext(".md") // match by file extension
match.ext(".canvas") // for future Canvas page type
match.slugPrefix("tags/") // match by URL prefix
match.frontmatter("type", (v) => v === "map") // match by frontmatter field
// Combinators
match.and(
match.ext(".md"),
match.frontmatter("draft", (v) => !v),
)
match.or(match.ext(".md"), match.ext(".mdx"))
match.not(match.frontmatter("draft", (v) => v === true))
// Convenience
match.all() // matches everything (default content)
match.none() // matches nothing (virtual-only page types)
Conflict resolution: When multiple PageTypes match the same file, the one with the highest priority wins. If priorities are equal, the most recently registered PageType wins (community plugins override core defaults).
Matcher function signature:
type PageMatcher = (args: {
slug: FullSlug
fileData: QuartzPluginData
cfg: GlobalConfiguration
}) => boolean
// Helper namespace
const match = {
ext: (extension: string) => PageMatcher,
slugPrefix: (prefix: string) => PageMatcher,
frontmatter: (key: string, predicate: (value: unknown) => boolean) => PageMatcher,
and: (...matchers: PageMatcher[]) => PageMatcher,
or: (...matchers: PageMatcher[]) => PageMatcher,
not: (matcher: PageMatcher) => PageMatcher,
all: () => PageMatcher,
none: () => PageMatcher,
}
D.4: Virtual Page Generation
Some page types don't match source files — they generate "virtual" pages from aggregated data:
type PageGenerator = (args: {
content: ProcessedContent[] // all processed source files
cfg: GlobalConfiguration
ctx: BuildCtx
}) => VirtualPage[]
interface VirtualPage {
slug: FullSlug // the URL this page will be served at
title: string // page title
data: Partial<QuartzPluginData> // synthetic file data for the page
}
Examples:
- FolderPage: Generates one virtual page per unique folder in the content tree.
- TagPage: Generates one virtual page per unique tag found in frontmatter, plus a
/tagsindex page listing all tags. - 404: Generates a single virtual page at
/404.
A PageType can use both match and generate — for example, a future "Collection" page type might match .collection files AND generate index pages.
D.5: Layout Configuration
The current layout model uses sharedPageComponents + pageLayouts (one per page type) which are merged by each emitter. The new model makes this explicit and extensible:
// quartz.layout.ts (new format)
export const layout = {
// Truly universal slots — applied to EVERY page type.
// Keep this minimal. Only things that genuinely belong on every page.
defaults: {
head: Component.Head(),
},
// Per-page-type slot configuration.
// Each key corresponds to a PageType plugin's `layout` field.
byPageType: {
content: {
beforeBody: [
Component.Breadcrumbs(),
Component.ArticleTitle(),
Component.ContentMeta(),
Component.TagList(),
],
left: [
Component.PageTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
Component.Explorer(),
],
right: [Component.Graph(), Component.TableOfContents(), Component.Backlinks()],
afterBody: [Component.Comments()],
footer: Component.Footer(),
},
folder: {
beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()],
left: [
Component.PageTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
],
right: [],
afterBody: [],
footer: Component.Footer(),
},
tag: {
beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()],
left: [
Component.PageTitle(),
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
],
right: [],
afterBody: [],
footer: Component.Footer(),
},
// A future Canvas page type — no footer, no sidebars, full-width body.
// canvas: {
// beforeBody: [],
// left: [],
// right: [],
// afterBody: [],
// },
},
}
Key design decisions:
layout.defaultscontains only truly universal slots — things that belong on literally every page. Currently that's justhead. Footer is NOT in defaults because some page types (e.g., Canvas) may not want it.layout.byPageTypemaps PageType names to their slot configuration. Each PageType plugin declares alayout: stringfield that references a key here.- If a PageType references a layout key that doesn't exist in
byPageType, the dispatcher falls back todefaultsonly (head, empty slots). This allows community PageTypes to work without requiring layout configuration — they'll render with just a head and body. - The
footerslot is per-page-type, not a global shared slot.
Layout resolution (performed by the PageType dispatcher):
finalLayout = {
...layout.defaults, // start with universal defaults (head)
...layout.byPageType[pageType.layout], // overlay page-type-specific slots
}
D.6: Core Infrastructure Changes
D.6.1: Plugin System Extensions
The plugin loader must recognize "pageType" as a new plugin category:
quartz/plugins/loader/types.ts:
// Current
export type PluginCategory = "transformer" | "filter" | "emitter"
// New
export type PluginCategory = "transformer" | "filter" | "emitter" | "pageType"
quartz/plugins/types.ts — add the QuartzPageTypePlugin interface:
export interface QuartzPageTypePlugin {
name: string
priority?: number
match: PageMatcher
generate?: PageGenerator
layout: string
body: QuartzComponentConstructor
}
export interface PluginTypes {
transformers: QuartzTransformerPlugin[]
filters: QuartzFilterPlugin[]
emitters: QuartzEmitterPlugin[]
pageTypes: QuartzPageTypePlugin[] // new
}
quartz/plugins/loader/index.ts:
- Update
detectPluginType()to recognize PageType exports (looks formatch,body,layoutexports). - Update
extractPluginFactory()to handle PageType plugins. - Update
loadExternalPlugins()to sort loaded plugins into thepageTypesarray.
D.6.2: PageType Dispatcher
A new core module replaces the individual page-rendering emitters:
quartz/plugins/pageTypes/dispatcher.ts:
The dispatcher is a QuartzEmitterPlugin (it plugs into the existing emit pipeline) that:
- Collects all registered
QuartzPageTypePlugininstances (from core + community plugins). - For each source file in
content:- Iterates page types by descending
priority. - Finds the first PageType whose
match()returnstrue. - Records the assignment:
file → pageType.
- Iterates page types by descending
- For each PageType with a
generate()function:- Calls
generate()with all content. - Records the virtual pages:
virtualPage → pageType.
- Calls
- For each assigned page (source + virtual):
- Resolves layout:
layout.defaultsmerged withlayout.byPageType[pageType.layout]. - Instantiates
pageType.bodyas thepageBodycomponent. - Calls
renderPage()with the resolved layout. - Writes HTML to output.
- Resolves layout:
// Pseudocode
const PageTypeDispatcher: QuartzEmitterPlugin = {
name: "PageTypeDispatcher",
async emit(ctx, content, resources) {
const pageTypes = ctx.cfg.plugins.pageTypes // sorted by priority desc
// Phase 1: Match source files
const assignments = new Map<FullSlug, QuartzPageTypePlugin>()
for (const [tree, fileData] of content) {
for (const pt of pageTypes) {
if (pt.match({ slug: fileData.slug!, fileData, cfg: ctx.cfg.configuration })) {
assignments.set(fileData.slug!, pt)
break // first match wins (highest priority)
}
}
}
// Phase 2: Generate virtual pages
const virtualPages: Array<{ page: VirtualPage; pageType: QuartzPageTypePlugin }> = []
for (const pt of pageTypes) {
if (pt.generate) {
const pages = pt.generate({ content, cfg: ctx.cfg.configuration, ctx })
for (const page of pages) {
virtualPages.push({ page, pageType: pt })
}
}
}
// Phase 3: Render all pages
const fps: FilePath[] = []
// ... render source pages from assignments
// ... render virtual pages from virtualPages
// ... each using resolved layout + pageType.body
return fps
},
}
D.6.3: renderPage.tsx Refactoring
renderPage.tsx currently hardcodes HeaderConstructor and BodyConstructor imports. These must become parameters:
Current (simplified):
import HeaderConstructor from "./Header"
import BodyConstructor from "./Body"
export function renderPage(cfg, slug, componentData, components, resources) {
const Header = HeaderConstructor()
const Body = BodyConstructor()
// ... render using Header, Body, and components
}
New:
export function renderPage(cfg, slug, componentData, components, resources) {
// Header and Body are now part of the components/layout passed in,
// OR are hardcoded as core structural components (they're not swappable).
// The key change: pageBody comes from the PageType, not from the emitter.
}
Design note: Header and Body are structural wrappers (
<header>,<body>HTML elements), not content components. They can remain hardcoded inrenderPage. The important refactoring is thatpageBody— the actual page content component — comes from the PageType plugin rather than being hardcoded per emitter.
D.6.4: Configuration Changes
quartz.config.ts — PageType plugins are loaded via externalPlugins just like other plugins:
externalPlugins: [
// Components
{ source: "github:quartz-community/search" },
{ source: "github:quartz-community/explorer" },
{ source: "github:quartz-community/backlinks" },
// ...
// Page Types
{ source: "github:quartz-community/content-page" },
{ source: "github:quartz-community/folder-page" },
{ source: "github:quartz-community/tag-page" },
],
The plugin loader auto-detects the plugin type. No separate pageTypes array is needed in config — detection is based on the plugin's exports (match, body, layout fields identify a PageType plugin).
D.7: Community Plugin Migrations
Three new community plugin repositories:
github:quartz-community/content-page
- Match:
match.ext(".md")(ormatch.all()with low priority as a catch-all) - Generate: none (source files only)
- Layout:
"content" - Body:
Contentcomponent (moved fromquartz/components/pages/Content.tsx) - Priority:
0(default — other page types with higher priority can override for specific files)
github:quartz-community/folder-page
- Match:
match.none()(virtual pages only) - Generate: Scans all content files, extracts unique folder paths, generates one virtual page per folder.
- Layout:
"folder" - Body:
FolderContentcomponent (moved fromquartz/components/pages/FolderContent.tsx) - Dependencies:
PageListhelper component. The plugin must bundle its own copy or import from a shared utils package.
github:quartz-community/tag-page
- Match:
match.none()(virtual pages only) - Generate: Scans all content files, extracts unique tags from frontmatter, generates one virtual page per tag plus a
/tagsindex page. - Layout:
"tag" - Body:
TagContentcomponent (moved fromquartz/components/pages/TagContent.tsx) - Dependencies:
PageListhelper component. Same bundling consideration as FolderPage.
Core: quartz/plugins/pageTypes/404.ts
- Match:
match.none()(virtual page only) - Generate: Produces a single virtual page at slug
/404. - Layout: falls back to
defaultsonly (justhead) - Body:
NotFoundcomponent (stays inquartz/components/pages/404.tsx) - Priority:
-1(lowest — never conflicts with other page types)
D.8: Type Updates (@quartz-community/types)
Add the following types to ~/Repos/types/src/index.ts:
// --- PageType Plugin Types ---
/** Matcher function: determines if a source file belongs to this page type. */
export type PageMatcher = (args: {
slug: string
fileData: Record<string, unknown>
cfg: Record<string, unknown>
}) => boolean
/** Virtual page descriptor for page types that generate pages from aggregated data. */
export interface VirtualPage {
slug: string
title: string
data: Record<string, unknown>
}
/** Generator function: produces virtual pages from all processed content. */
export type PageGenerator = (args: {
content: Array<[unknown, Record<string, unknown>]>
cfg: Record<string, unknown>
ctx: Record<string, unknown>
}) => VirtualPage[]
/** A PageType plugin definition. */
export interface QuartzPageTypePlugin {
name: string
priority?: number
match: PageMatcher
generate?: PageGenerator
layout: string
body: QuartzComponentConstructor
}
Note
: The community types package uses
Record<string, unknown>for core-specific types (QuartzPluginData,GlobalConfiguration,BuildCtx) because community plugins don't have access to Quartz core's internal type definitions. At runtime, the actual Quartz types are passed in — theRecordtypes are a structural stand-in that avoids coupling community types to core internals.
D.9: Plugin Template Updates
Update ~/Repos/plugin-template to include a PageType example:
src/index.ts — add a PageType export example (commented out):
// --- PageType Example ---
// Uncomment and modify to create a PageType plugin instead of a component plugin.
//
// import { MyPageBody } from "./pages/MyPageBody"
// import type { QuartzPageTypePlugin, PageMatcher } from "@quartz-community/types"
//
// const matchMyPages: PageMatcher = ({ fileData }) => {
// return fileData.frontmatter?.type === "my-custom-type"
// }
//
// export const myPageType: QuartzPageTypePlugin = {
// name: "my-page-type",
// priority: 10,
// match: matchMyPages,
// layout: "my-page-type",
// body: MyPageBody,
// }
src/types.ts — add re-exports for PageType types:
export type {
PageMatcher,
PageGenerator,
VirtualPage,
QuartzPageTypePlugin,
} from "@quartz-community/types"
D.10: Files to Create
| File | Purpose |
|---|---|
quartz/plugins/pageTypes/dispatcher.ts |
Core PageType dispatcher (replaces individual page emitters) |
quartz/plugins/pageTypes/404.ts |
Core 404 page type (default fallback) |
quartz/plugins/pageTypes/matchers.ts |
Composable matcher helpers (match.ext(), match.and(), etc.) |
quartz/plugins/pageTypes/index.ts |
Barrel exports for the pageTypes module |
D.11: Files to Modify
| File | Change |
|---|---|
quartz/plugins/types.ts |
Add QuartzPageTypePlugin, update PluginTypes |
quartz/plugins/loader/types.ts |
Add "pageType" to PluginCategory |
quartz/plugins/loader/index.ts |
Update detection, extraction, and loading for PageType plugins |
quartz/cfg.ts |
Update FullPageLayout or add layout resolution types |
quartz/components/renderPage.tsx |
Accept pageBody as parameter instead of hardcoding per-emitter |
quartz.config.ts |
Add content-page, folder-page, tag-page to externalPlugins |
quartz.layout.ts |
Restructure to layout.defaults + layout.byPageType format |
~/Repos/types/src/index.ts |
Add PageType-related types |
~/Repos/plugin-template/src/index.ts |
Add PageType example |
~/Repos/plugin-template/src/types.ts |
Add PageType type re-exports |
D.12: Files to Delete (after migration)
| File | Replaced By |
|---|---|
quartz/plugins/emitters/contentPage.tsx |
github:quartz-community/content-page + dispatcher |
quartz/plugins/emitters/folderPage.tsx |
github:quartz-community/folder-page + dispatcher |
quartz/plugins/emitters/tagPage.tsx |
github:quartz-community/tag-page + dispatcher |
quartz/plugins/emitters/404.tsx |
quartz/plugins/pageTypes/404.ts |
quartz/components/pages/Content.tsx |
Bundled into content-page community plugin |
quartz/components/pages/FolderContent.tsx |
Bundled into folder-page community plugin |
quartz/components/pages/TagContent.tsx |
Bundled into tag-page community plugin |
Note
:
quartz/components/pages/404.tsx(the NotFound body component) stays in core since the 404 PageType stays in core.
D.13: Key Design Decisions
| Decision | Rationale |
|---|---|
| PageType is a new plugin category, not a subtype of emitter | Clean separation of concerns. Emitters are a low-level escape hatch; PageTypes are a high-level declarative abstraction. |
| 404 stays in core | Every site needs a 404 page. It's a sensible default that shouldn't require installing a community plugin. |
| All three page types migrate to community plugins | The v5 plugin system is the core vision. Partial migration would be inconsistent and confusing. |
layout.defaults is minimal (just head) |
Not all page types want the same chrome. Canvas pages may want no footer, no sidebars. Only truly universal elements belong in defaults. |
| Footer is per-page-type, not in defaults | Explicitly requested: Canvas pages should not have footer forced on them. |
| FileEmitter/StaticFiles are conceptual, not new interfaces | Avoids unnecessary API churn. The existing QuartzEmitterPlugin interface works fine for these. The reclassification is for documentation and mental model clarity. |
| Matchers are composable functions, not config objects | Functions are more flexible and can express arbitrary logic. The match.* helpers provide convenience without limiting power. |
| Plugin loader auto-detects PageType | Consistent with existing auto-detection for transformers/filters/emitters. No separate config array needed. |
PageList helper is bundled into page type plugins |
Avoids creating a shared utils dependency just for one helper. Each plugin is self-contained. |
Execution Order
Step 1: Delete 6 internal duplicates (Phase A)
├─ Delete component files, styles, scripts
├─ Update quartz/components/index.ts
├─ Check OverflowList.tsx usage
└─ Verify: tsc --noEmit, npx quartz build
Step 2: Migrate feature components (Phase B) — one at a time
├─ Create community plugin repo from template
├─ Migrate component code (see checklist)
├─ Add to externalPlugins in quartz.config.ts
├─ Update quartz.layout.ts
├─ Delete internal copy
└─ Verify: tsc --noEmit, npx quartz build
Step 3: Adopt @quartz-community/types in core (Phase C)
├─ Add types dependency
├─ Update quartz/components/types.ts
├─ Remove as QuartzComponent casts from quartz.layout.ts
└─ Verify: tsc --noEmit, npx quartz build
Step 4: Build PageType infrastructure (Phase D — core)
├─ Add PageType types to @quartz-community/types
├─ Add QuartzPageTypePlugin to quartz/plugins/types.ts
├─ Update PluginCategory in quartz/plugins/loader/types.ts
├─ Create quartz/plugins/pageTypes/matchers.ts (composable matchers)
├─ Create quartz/plugins/pageTypes/dispatcher.ts (core dispatcher)
├─ Create quartz/plugins/pageTypes/404.ts (core 404 page type)
├─ Update quartz/plugins/loader/index.ts (detection + loading)
├─ Refactor quartz/components/renderPage.tsx (accept pageBody as parameter)
└─ Verify: tsc --noEmit
Step 5: Migrate page types to community plugins (Phase D — plugins)
├─ Create github:quartz-community/content-page
│ ├─ Move Content component + styles
│ ├─ Define match, layout, body
│ └─ Build + verify
├─ Create github:quartz-community/folder-page
│ ├─ Move FolderContent component + PageList helper + styles
│ ├─ Define generate, layout, body
│ └─ Build + verify
├─ Create github:quartz-community/tag-page
│ ├─ Move TagContent component + PageList helper + styles
│ ├─ Define generate, layout, body
│ └─ Build + verify
└─ Verify: all three plugins build independently
Step 6: Integrate and cut over (Phase D — integration)
├─ Add content-page, folder-page, tag-page to externalPlugins
├─ Restructure quartz.layout.ts to defaults + byPageType format
├─ Delete old emitters: contentPage.tsx, folderPage.tsx, tagPage.tsx, 404.tsx
├─ Delete old page components: Content.tsx, FolderContent.tsx, TagContent.tsx
├─ Update quartz/components/index.ts
├─ Update plugin-template with PageType example
└─ Verify: tsc --noEmit, npx quartz build, site renders correctly
Migration Checklist Templates
Component Migration Checklist
Use this checklist for each component being migrated to a community plugin.
Setup
- Create new repository at
github.com/quartz-community/{component-name} - Clone
plugin-templateas starting point - Update
package.json:- Set
nameto@quartz-community/{component-name} - Set
repositoryURL - Verify
@quartz-community/typesdependency - Verify
@quartz-community/utilsdependency (if needed)
- Set
Component Migration
- Copy component file to
src/components/{ComponentName}.tsx - Convert to factory function pattern with
satisfies QuartzComponentConstructor - Replace imports:
"./types"→"@quartz-community/types""../util/path"→ localsrc/util/path.tsor@quartz-community/utils"../i18n"→ localsrc/i18n/with required locale strings"../util/lang"→ localsrc/util/lang.ts
- Extract SCSS to
src/components/styles/{name}.scss - Extract inline scripts (if any) to
src/components/scripts/{name}.inline.ts - Add type stubs:
src/components/styles.d.ts,src/components/scripts.d.ts - Attach resources:
Component.css = style,Component.afterDOMLoaded = script
Exports
- Create
src/components/index.tswith default export - Create
src/index.tsre-exporting from components - Verify
tsup.config.tsentry points matchpackage.jsonexports
Build & Test
- Run
npm run build— verifydist/output - Run
npm run check— typecheck, lint, format - Install in Quartz: add to
externalPluginsinquartz.config.ts - Use in layout: update
quartz.layout.ts - Run
tsc --noEmitin Quartz - Run
npx quartz buildand verify site renders
Cleanup (in Quartz core)
- Delete internal component file
- Delete associated style file(s)
- Delete associated script file(s)
- Remove from
quartz/components/index.ts - Verify no remaining imports of deleted files
Publish
- Commit and push community plugin repo
- Verify Quartz build with
github:quartz-community/{component-name}specifier
PageType Migration Checklist
Use this checklist for each page type being migrated to a community plugin.
Setup
- Create new repository at
github.com/quartz-community/{page-type-name} - Clone
plugin-templateas starting point - Update
package.json:- Set
nameto@quartz-community/{page-type-name} - Set
repositoryURL - Verify
@quartz-community/typesdependency
- Set
Page Body Component
- Copy page body component to
src/pages/{PageBody}.tsx- ContentPage:
Content.tsxfromquartz/components/pages/Content.tsx - FolderPage:
FolderContent.tsxfromquartz/components/pages/FolderContent.tsx - TagPage:
TagContent.tsxfromquartz/components/pages/TagContent.tsx
- ContentPage:
- Copy helper components if needed (
PageList.tsxfor folder/tag page types) - Replace imports:
"../types"→"@quartz-community/types""../../util/path"→ localsrc/util/path.tsor@quartz-community/utils"../../i18n"→ localsrc/i18n/with required locale strings
- Extract associated styles to
src/pages/styles/ - Add type stubs for styles if needed
PageType Definition
- Create
src/index.tswith PageType export:- Define
matchusing composable matchers (ormatch.none()for virtual-only) - Define
generatefunction (for folder/tag types that create virtual pages) - Set
layoutkey (must match a key inlayout.byPageType) - Set
bodyto the page body component constructor - Set
nameandpriority
- Define
- Verify export satisfies
QuartzPageTypePlugintype
Virtual Page Generation (if applicable)
- Implement
generate()function:- Scan all content for relevant data (folders, tags, etc.)
- Return
VirtualPage[]with correct slugs, titles, and data
- Verify generated slugs don't conflict with source file slugs
Build & Test
- Run
npm run build— verifydist/output - Run
npm run check— typecheck, lint, format - Install in Quartz: add to
externalPluginsinquartz.config.ts - Add layout entry in
quartz.layout.tsunderlayout.byPageType - Run
tsc --noEmitin Quartz - Run
npx quartz buildand verify pages render correctly
Cleanup (in Quartz core)
- Delete old emitter file (
quartz/plugins/emitters/{name}.tsx) - Delete old page body component (
quartz/components/pages/{Name}.tsx) - Update emitter barrel exports
- Update component barrel exports
- Verify no remaining imports of deleted files
Publish
- Commit and push community plugin repo
- Verify Quartz build with
github:quartz-community/{page-type-name}specifier