From 264bb7cfca2406dd11cceef1724e98477f2e4ac4 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Fri, 13 Feb 2026 17:17:51 +0100 Subject: [PATCH] feat: migrate 7 feature components to community plugins (Phase B) 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. --- MIGRATION_TASKS.md | 1116 +++++++++++++++++ quartz.config.ts | 7 + quartz.layout.ts | 28 +- quartz/components/ArticleTitle.tsx | 19 - quartz/components/ContentMeta.tsx | 58 - quartz/components/Darkmode.tsx | 48 - quartz/components/Footer.tsx | 33 - quartz/components/PageTitle.tsx | 24 - quartz/components/ReaderMode.tsx | 38 - quartz/components/TagList.tsx | 56 - quartz/components/index.ts | 14 - quartz/components/scripts/darkmode.inline.ts | 37 - .../components/scripts/readermode.inline.ts | 25 - quartz/components/styles/contentMeta.scss | 14 - quartz/components/styles/darkmode.scss | 47 - quartz/components/styles/footer.scss | 15 - quartz/components/styles/readermode.scss | 34 - 17 files changed, 1140 insertions(+), 473 deletions(-) create mode 100644 MIGRATION_TASKS.md delete mode 100644 quartz/components/ArticleTitle.tsx delete mode 100644 quartz/components/ContentMeta.tsx delete mode 100644 quartz/components/Darkmode.tsx delete mode 100644 quartz/components/Footer.tsx delete mode 100644 quartz/components/PageTitle.tsx delete mode 100644 quartz/components/ReaderMode.tsx delete mode 100644 quartz/components/TagList.tsx delete mode 100644 quartz/components/scripts/darkmode.inline.ts delete mode 100644 quartz/components/scripts/readermode.inline.ts delete mode 100644 quartz/components/styles/contentMeta.scss delete mode 100644 quartz/components/styles/darkmode.scss delete mode 100644 quartz/components/styles/footer.scss delete mode 100644 quartz/components/styles/readermode.scss diff --git a/MIGRATION_TASKS.md b/MIGRATION_TASKS.md new file mode 100644 index 000000000..15c99ff8e --- /dev/null +++ b/MIGRATION_TASKS.md @@ -0,0 +1,1116 @@ +# Quartz v5 Migration Tasks + +> Roadmap for migrating internal components to community plugins and adopting `@quartz-community/types` in core. + +## Table of Contents + +1. [Plan Overview](#plan-overview) +2. [Architecture Summary](#architecture-summary) +3. [Component Analysis](#component-analysis) +4. [Migration Plan](#migration-plan) + - [Phase A: Delete Internal Duplicates](#phase-a-delete-internal-duplicates) + - [Phase B: Migrate Feature Components](#phase-b-migrate-feature-components-optional) + - [Phase C: Adopt @quartz-community/types in Core](#phase-c-adopt-quartz-communitytypes-in-core) + - [Phase D: PageType System](#phase-d-pagetype-system) +5. [Execution Order](#execution-order) +6. [Migration Checklist Templates](#migration-checklist-templates) + - [Component Migration Checklist](#component-migration-checklist) + - [PageType Migration Checklist](#pagetype-migration-checklist) + +--- + +## 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: + +1. **Phase A — Delete Internal Duplicates**: Remove internal components that already have community plugin equivalents. +2. **Phase B — Migrate Feature Components**: Extract additional feature components to community plugins. +3. **Phase C — Type Unification**: Adopt `@quartz-community/types` in Quartz core's `quartz/components/types.ts`, eliminating `as QuartzComponent` casts in `quartz.layout.ts`. +4. **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 the `plugin-template`. +- **6 of these** have corresponding internal duplicates still in `quartz/components/` that should be removed. +- **`quartz.layout.ts`** uses `as QuartzComponent` casts to bridge type incompatibility between core and community types. +- All community plugins import from `@quartz-community/types` (via `github:quartz-community/types`). + +### Goal State + +- Internal duplicates deleted from `quartz/components/`. +- Additional feature components migrated to community plugins. +- Core `quartz/components/types.ts` re-exports from `@quartz-community/types` (or aligns structurally), eliminating the need for `as QuartzComponent` casts. +- `quartz.layout.ts` uses 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: + +```typescript +// quartz/components/types.ts +type QuartzComponent = ComponentType & { + css?: StringResource + beforeDOMLoaded?: StringResource + afterDOMLoaded?: StringResource +} +``` + +Community types define it as: + +```typescript +// @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

` (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: + +```typescript +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 `` with meta, SEO, styles, scripts. Required for every page. | +| **Body** | `Body.tsx` | `renderPage.tsx` (as `BodyConstructor`) | Page `` 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**: + +1. **Component files**: + - `quartz/components/Backlinks.tsx` + - `quartz/components/Breadcrumbs.tsx` + - `quartz/components/RecentNotes.tsx` + - `quartz/components/Search.tsx` + - `quartz/components/TableOfContents.tsx` + - `quartz/components/Comments.tsx` + +2. **Associated styles**: + - `quartz/components/styles/backlinks.scss` + - `quartz/components/styles/breadcrumbs.scss` + - `quartz/components/styles/recentNotes.scss` + - `quartz/components/styles/search.scss` + - `quartz/components/styles/toc.scss` + - `quartz/components/styles/legacyToc.scss` + +3. **Associated scripts**: + - `quartz/components/scripts/search.inline.ts` + - `quartz/components/scripts/toc.inline.ts` + - `quartz/components/scripts/comments.inline.ts` + +4. **Update barrel exports** in `quartz/components/index.ts` — remove the deleted components from the export list. + +5. **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](#migration-checklist-template) below. + +**After migrating each component**: + +1. Delete internal component file + its styles/scripts. +2. Add the new community plugin to `externalPlugins` in `quartz.config.ts`. +3. Update `quartz.layout.ts` to use the plugin component. +4. Remove the component from `quartz/components/index.ts`. +5. Run `tsc --noEmit` and `npx 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`: + +```typescript +// 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: + +```typescript +// 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 + +1. Add `@quartz-community/types` as a dependency in `package.json`: + + ```json + "dependencies": { + "@quartz-community/types": "github:quartz-community/types" + } + ``` + +2. Update `quartz/components/types.ts` to re-export: + + ```typescript + export type { + QuartzComponent, + QuartzComponentConstructor, + QuartzComponentProps, + StringResource, + } from "@quartz-community/types" + ``` + +3. 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 --noEmit` to verify. + +4. Update `quartz.layout.ts`: + - Remove all `as QuartzComponent` casts. + - Remove the `import { QuartzComponent } from "./quartz/components/types"` line. + +5. Verify: + - `tsc --noEmit` passes. + - `npx quartz build` succeeds. + - 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: + +1. Filter content files to find matching pages +2. Merge shared + page layout into `FullPageLayout` +3. Instantiate Header and Body constructors +4. Build `componentData` +5. Call `renderPage(cfg, slug, componentData, layout, resources)` +6. 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: + +```typescript +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 `QuartzEmitterPlugin` interface. +- **StaticFiles**: Copies or generates files that don't depend on content processing. Keeps the existing `QuartzEmitterPlugin` interface. +- **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 `QuartzEmitterPlugin` interface 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: + +```typescript +// 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**: + +```typescript +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: + +```typescript +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 // 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 `/tags` index 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: + +```typescript +// 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.defaults` contains **only truly universal slots** — things that belong on literally every page. Currently that's just `head`. Footer is NOT in defaults because some page types (e.g., Canvas) may not want it. +- `layout.byPageType` maps PageType names to their slot configuration. Each PageType plugin declares a `layout: string` field that references a key here. +- If a PageType references a layout key that doesn't exist in `byPageType`, the dispatcher falls back to `defaults` only (head, empty slots). This allows community PageTypes to work without requiring layout configuration — they'll render with just a head and body. +- The `footer` slot 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`**: + +```typescript +// Current +export type PluginCategory = "transformer" | "filter" | "emitter" + +// New +export type PluginCategory = "transformer" | "filter" | "emitter" | "pageType" +``` + +**`quartz/plugins/types.ts`** — add the `QuartzPageTypePlugin` interface: + +```typescript +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 for `match`, `body`, `layout` exports). +- Update `extractPluginFactory()` to handle PageType plugins. +- Update `loadExternalPlugins()` to sort loaded plugins into the `pageTypes` array. + +##### 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: + +1. Collects all registered `QuartzPageTypePlugin` instances (from core + community plugins). +2. For each source file in `content`: + - Iterates page types by descending `priority`. + - Finds the first PageType whose `match()` returns `true`. + - Records the assignment: `file → pageType`. +3. For each PageType with a `generate()` function: + - Calls `generate()` with all content. + - Records the virtual pages: `virtualPage → pageType`. +4. For each assigned page (source + virtual): + - Resolves layout: `layout.defaults` merged with `layout.byPageType[pageType.layout]`. + - Instantiates `pageType.body` as the `pageBody` component. + - Calls `renderPage()` with the resolved layout. + - Writes HTML to output. + +```typescript +// 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() + 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): + +```typescript +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**: + +```typescript +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 (`

`, `` HTML elements), not content components. They can remain hardcoded in `renderPage`. The important refactoring is that `pageBody` — 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: + +```typescript +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")` (or `match.all()` with low priority as a catch-all) +- **Generate**: none (source files only) +- **Layout**: `"content"` +- **Body**: `Content` component (moved from `quartz/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**: `FolderContent` component (moved from `quartz/components/pages/FolderContent.tsx`) +- **Dependencies**: `PageList` helper 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 `/tags` index page. +- **Layout**: `"tag"` +- **Body**: `TagContent` component (moved from `quartz/components/pages/TagContent.tsx`) +- **Dependencies**: `PageList` helper 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 `defaults` only (just `head`) +- **Body**: `NotFound` component (stays in `quartz/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`: + +```typescript +// --- PageType Plugin Types --- + +/** Matcher function: determines if a source file belongs to this page type. */ +export type PageMatcher = (args: { + slug: string + fileData: Record + cfg: Record +}) => boolean + +/** Virtual page descriptor for page types that generate pages from aggregated data. */ +export interface VirtualPage { + slug: string + title: string + data: Record +} + +/** Generator function: produces virtual pages from all processed content. */ +export type PageGenerator = (args: { + content: Array<[unknown, Record]> + cfg: Record + ctx: Record +}) => 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` 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 — the `Record` types 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): + +```typescript +// --- 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: + +```typescript +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-template` as starting point +- [ ] Update `package.json`: + - [ ] Set `name` to `@quartz-community/{component-name}` + - [ ] Set `repository` URL + - [ ] Verify `@quartz-community/types` dependency + - [ ] Verify `@quartz-community/utils` dependency (if needed) + +#### 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"` → local `src/util/path.ts` or `@quartz-community/utils` + - [ ] `"../i18n"` → local `src/i18n/` with required locale strings + - [ ] `"../util/lang"` → local `src/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.ts` with default export +- [ ] Create `src/index.ts` re-exporting from components +- [ ] Verify `tsup.config.ts` entry points match `package.json` exports + +#### Build & Test + +- [ ] Run `npm run build` — verify `dist/` output +- [ ] Run `npm run check` — typecheck, lint, format +- [ ] Install in Quartz: add to `externalPlugins` in `quartz.config.ts` +- [ ] Use in layout: update `quartz.layout.ts` +- [ ] Run `tsc --noEmit` in Quartz +- [ ] Run `npx quartz build` and 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-template` as starting point +- [ ] Update `package.json`: + - [ ] Set `name` to `@quartz-community/{page-type-name}` + - [ ] Set `repository` URL + - [ ] Verify `@quartz-community/types` dependency + +#### Page Body Component + +- [ ] Copy page body component to `src/pages/{PageBody}.tsx` + - ContentPage: `Content.tsx` from `quartz/components/pages/Content.tsx` + - FolderPage: `FolderContent.tsx` from `quartz/components/pages/FolderContent.tsx` + - TagPage: `TagContent.tsx` from `quartz/components/pages/TagContent.tsx` +- [ ] Copy helper components if needed (`PageList.tsx` for folder/tag page types) +- [ ] Replace imports: + - [ ] `"../types"` → `"@quartz-community/types"` + - [ ] `"../../util/path"` → local `src/util/path.ts` or `@quartz-community/utils` + - [ ] `"../../i18n"` → local `src/i18n/` with required locale strings +- [ ] Extract associated styles to `src/pages/styles/` +- [ ] Add type stubs for styles if needed + +#### PageType Definition + +- [ ] Create `src/index.ts` with PageType export: + - [ ] Define `match` using composable matchers (or `match.none()` for virtual-only) + - [ ] Define `generate` function (for folder/tag types that create virtual pages) + - [ ] Set `layout` key (must match a key in `layout.byPageType`) + - [ ] Set `body` to the page body component constructor + - [ ] Set `name` and `priority` +- [ ] Verify export satisfies `QuartzPageTypePlugin` type + +#### 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` — verify `dist/` output +- [ ] Run `npm run check` — typecheck, lint, format +- [ ] Install in Quartz: add to `externalPlugins` in `quartz.config.ts` +- [ ] Add layout entry in `quartz.layout.ts` under `layout.byPageType` +- [ ] Run `tsc --noEmit` in Quartz +- [ ] Run `npx quartz build` and 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 diff --git a/quartz.config.ts b/quartz.config.ts index 428e8126f..8ee93dd97 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -99,6 +99,13 @@ const config: QuartzConfig = { "github:quartz-community/table-of-contents", "github:quartz-community/backlinks", "github:quartz-community/comments", + "github:quartz-community/article-title", + "github:quartz-community/tag-list", + "github:quartz-community/page-title", + "github:quartz-community/darkmode", + "github:quartz-community/reader-mode", + "github:quartz-community/content-meta", + "github:quartz-community/footer", ], } diff --git a/quartz.layout.ts b/quartz.layout.ts index 4688401d8..71c0885c6 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -9,6 +9,12 @@ const graphComponent = Plugin.Graph() as QuartzComponent const searchComponent = Plugin.Search() as QuartzComponent const backlinksComponent = Plugin.Backlinks() as QuartzComponent const tocComponent = Plugin.TableOfContents() as QuartzComponent +const articleTitleComponent = Plugin.ArticleTitle() as QuartzComponent +const contentMetaComponent = Plugin.ContentMeta() as QuartzComponent +const tagListComponent = Plugin.TagList() as QuartzComponent +const pageTitleComponent = Plugin.PageTitle() as QuartzComponent +const darkmodeComponent = Plugin.Darkmode() as QuartzComponent +const readerModeComponent = Plugin.ReaderMode() as QuartzComponent // components shared across all pages export const sharedPageComponents: SharedLayout = { @@ -19,12 +25,12 @@ export const sharedPageComponents: SharedLayout = { // provider: "giscus", // options: {}) as QuartzComponent, ], - footer: Component.Footer({ + footer: Plugin.Footer({ links: { GitHub: "https://github.com/jackyzha0/quartz", "Discord Community": "https://discord.gg/cRFFHYye7t", }, - }), + }) as QuartzComponent, } // components for pages that display a single page (e.g. a single note) @@ -34,12 +40,12 @@ export const defaultContentPageLayout: PageLayout = { component: Component.Breadcrumbs(), condition: (page) => page.fileData.slug !== "index", }), - Component.ArticleTitle(), - Component.ContentMeta(), - Component.TagList(), + articleTitleComponent, + contentMetaComponent, + tagListComponent, ], left: [ - Component.PageTitle(), + pageTitleComponent, Component.MobileOnly(Component.Spacer()), Component.Flex({ components: [ @@ -47,8 +53,8 @@ export const defaultContentPageLayout: PageLayout = { Component: searchComponent, grow: true, }, - { Component: Component.Darkmode() }, - { Component: Component.ReaderMode() }, + { Component: darkmodeComponent }, + { Component: readerModeComponent }, ], }), explorerComponent, @@ -58,9 +64,9 @@ export const defaultContentPageLayout: PageLayout = { // components for pages that display lists of pages (e.g. tags or folders) export const defaultListPageLayout: PageLayout = { - beforeBody: [Component.Breadcrumbs(), Component.ArticleTitle(), Component.ContentMeta()], + beforeBody: [Component.Breadcrumbs(), articleTitleComponent, contentMetaComponent], left: [ - Component.PageTitle(), + pageTitleComponent, Component.MobileOnly(Component.Spacer()), Component.Flex({ components: [ @@ -68,7 +74,7 @@ export const defaultListPageLayout: PageLayout = { Component: searchComponent, grow: true, }, - { Component: Component.Darkmode() }, + { Component: darkmodeComponent }, ], }), explorerComponent, diff --git a/quartz/components/ArticleTitle.tsx b/quartz/components/ArticleTitle.tsx deleted file mode 100644 index 318aeb24e..000000000 --- a/quartz/components/ArticleTitle.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" -import { classNames } from "../util/lang" - -const ArticleTitle: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => { - const title = fileData.frontmatter?.title - if (title) { - return

{title}

- } else { - return null - } -} - -ArticleTitle.css = ` -.article-title { - margin: 2rem 0 0 0; -} -` - -export default (() => ArticleTitle) satisfies QuartzComponentConstructor diff --git a/quartz/components/ContentMeta.tsx b/quartz/components/ContentMeta.tsx deleted file mode 100644 index e378bccee..000000000 --- a/quartz/components/ContentMeta.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Date, getDate } from "./Date" -import { QuartzComponentConstructor, QuartzComponentProps } from "./types" -import readingTime from "reading-time" -import { classNames } from "../util/lang" -import { i18n } from "../i18n" -import { JSX } from "preact" -import style from "./styles/contentMeta.scss" - -interface ContentMetaOptions { - /** - * Whether to display reading time - */ - showReadingTime: boolean - showComma: boolean -} - -const defaultOptions: ContentMetaOptions = { - showReadingTime: true, - showComma: true, -} - -export default ((opts?: Partial) => { - // Merge options with defaults - const options: ContentMetaOptions = { ...defaultOptions, ...opts } - - function ContentMetadata({ cfg, fileData, displayClass }: QuartzComponentProps) { - const text = fileData.text - - if (text) { - const segments: (string | JSX.Element)[] = [] - - if (fileData.dates) { - segments.push() - } - - // Display reading time if enabled - if (options.showReadingTime) { - const { minutes, words: _words } = readingTime(text) - const displayedTime = i18n(cfg.locale).components.contentMeta.readingTime({ - minutes: Math.ceil(minutes), - }) - segments.push({displayedTime}) - } - - return ( -

- {segments} -

- ) - } else { - return null - } - } - - ContentMetadata.css = style - - return ContentMetadata -}) satisfies QuartzComponentConstructor diff --git a/quartz/components/Darkmode.tsx b/quartz/components/Darkmode.tsx deleted file mode 100644 index afc23d758..000000000 --- a/quartz/components/Darkmode.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// @ts-ignore -import darkmodeScript from "./scripts/darkmode.inline" -import styles from "./styles/darkmode.scss" -import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" -import { i18n } from "../i18n" -import { classNames } from "../util/lang" - -const Darkmode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => { - return ( - - ) -} - -Darkmode.beforeDOMLoaded = darkmodeScript -Darkmode.css = styles - -export default (() => Darkmode) satisfies QuartzComponentConstructor diff --git a/quartz/components/Footer.tsx b/quartz/components/Footer.tsx deleted file mode 100644 index cff28cbb9..000000000 --- a/quartz/components/Footer.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" -import style from "./styles/footer.scss" -import { version } from "../../package.json" -import { i18n } from "../i18n" - -interface Options { - links: Record -} - -export default ((opts?: Options) => { - const Footer: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => { - const year = new Date().getFullYear() - const links = opts?.links ?? [] - return ( -
-

- {i18n(cfg.locale).components.footer.createdWith}{" "} - Quartz v{version} © {year} -

-
    - {Object.entries(links).map(([text, link]) => ( -
  • - {text} -
  • - ))} -
-
- ) - } - - Footer.css = style - return Footer -}) satisfies QuartzComponentConstructor diff --git a/quartz/components/PageTitle.tsx b/quartz/components/PageTitle.tsx deleted file mode 100644 index 53ee8240a..000000000 --- a/quartz/components/PageTitle.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { pathToRoot } from "../util/path" -import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" -import { classNames } from "../util/lang" -import { i18n } from "../i18n" - -const PageTitle: QuartzComponent = ({ fileData, cfg, displayClass }: QuartzComponentProps) => { - const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title - const baseDir = pathToRoot(fileData.slug!) - return ( -

- {title} -

- ) -} - -PageTitle.css = ` -.page-title { - font-size: 1.75rem; - margin: 0; - font-family: var(--titleFont); -} -` - -export default (() => PageTitle) satisfies QuartzComponentConstructor diff --git a/quartz/components/ReaderMode.tsx b/quartz/components/ReaderMode.tsx deleted file mode 100644 index 4b3165e6e..000000000 --- a/quartz/components/ReaderMode.tsx +++ /dev/null @@ -1,38 +0,0 @@ -// @ts-ignore -import readerModeScript from "./scripts/readermode.inline" -import styles from "./styles/readermode.scss" -import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" -import { i18n } from "../i18n" -import { classNames } from "../util/lang" - -const ReaderMode: QuartzComponent = ({ displayClass, cfg }: QuartzComponentProps) => { - return ( - - ) -} - -ReaderMode.beforeDOMLoaded = readerModeScript -ReaderMode.css = styles - -export default (() => ReaderMode) satisfies QuartzComponentConstructor diff --git a/quartz/components/TagList.tsx b/quartz/components/TagList.tsx deleted file mode 100644 index c73ed392a..000000000 --- a/quartz/components/TagList.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { FullSlug, resolveRelative } from "../util/path" -import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" -import { classNames } from "../util/lang" - -const TagList: QuartzComponent = ({ fileData, displayClass }: QuartzComponentProps) => { - const tags = fileData.frontmatter?.tags - if (tags && tags.length > 0) { - return ( -
    - {tags.map((tag) => { - const linkDest = resolveRelative(fileData.slug!, `tags/${tag}` as FullSlug) - return ( -
  • - - {tag} - -
  • - ) - })} -
- ) - } else { - return null - } -} - -TagList.css = ` -.tags { - list-style: none; - display: flex; - padding-left: 0; - gap: 0.4rem; - margin: 1rem 0; - flex-wrap: wrap; -} - -.section-li > .section > .tags { - justify-content: flex-end; -} - -.tags > li { - display: inline-block; - white-space: nowrap; - margin: 0; - overflow-wrap: normal; -} - -a.internal.tag-link { - border-radius: 8px; - background-color: var(--highlight); - padding: 0.2rem 0.4rem; - margin: 0 0.1rem; -} -` - -export default (() => TagList) satisfies QuartzComponentConstructor diff --git a/quartz/components/index.ts b/quartz/components/index.ts index a9eb199bd..164baa5e5 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -2,18 +2,11 @@ import Content from "./pages/Content" import TagContent from "./pages/TagContent" import FolderContent from "./pages/FolderContent" import NotFound from "./pages/404" -import ArticleTitle from "./ArticleTitle" -import Darkmode from "./Darkmode" -import ReaderMode from "./ReaderMode" import Head from "./Head" -import PageTitle from "./PageTitle" -import ContentMeta from "./ContentMeta" import Spacer from "./Spacer" import TableOfContents from "./TableOfContents" -import TagList from "./TagList" import Backlinks from "./Backlinks" import Search from "./Search" -import Footer from "./Footer" import DesktopOnly from "./DesktopOnly" import MobileOnly from "./MobileOnly" import RecentNotes from "./RecentNotes" @@ -28,21 +21,14 @@ export type { ComponentManifest, RegisteredComponent } from "./registry" export type { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" export { - ArticleTitle, Content, TagContent, FolderContent, - Darkmode, - ReaderMode, Head, - PageTitle, - ContentMeta, Spacer, TableOfContents, - TagList, Backlinks, Search, - Footer, DesktopOnly, MobileOnly, RecentNotes, diff --git a/quartz/components/scripts/darkmode.inline.ts b/quartz/components/scripts/darkmode.inline.ts deleted file mode 100644 index d8dfee964..000000000 --- a/quartz/components/scripts/darkmode.inline.ts +++ /dev/null @@ -1,37 +0,0 @@ -const userPref = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark" -const currentTheme = localStorage.getItem("theme") ?? userPref -document.documentElement.setAttribute("saved-theme", currentTheme) - -const emitThemeChangeEvent = (theme: "light" | "dark") => { - const event: CustomEventMap["themechange"] = new CustomEvent("themechange", { - detail: { theme }, - }) - document.dispatchEvent(event) -} - -document.addEventListener("nav", () => { - const switchTheme = () => { - const newTheme = - document.documentElement.getAttribute("saved-theme") === "dark" ? "light" : "dark" - document.documentElement.setAttribute("saved-theme", newTheme) - localStorage.setItem("theme", newTheme) - emitThemeChangeEvent(newTheme) - } - - const themeChange = (e: MediaQueryListEvent) => { - const newTheme = e.matches ? "dark" : "light" - document.documentElement.setAttribute("saved-theme", newTheme) - localStorage.setItem("theme", newTheme) - emitThemeChangeEvent(newTheme) - } - - for (const darkmodeButton of document.getElementsByClassName("darkmode")) { - darkmodeButton.addEventListener("click", switchTheme) - window.addCleanup(() => darkmodeButton.removeEventListener("click", switchTheme)) - } - - // Listen for changes in prefers-color-scheme - const colorSchemeMediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - colorSchemeMediaQuery.addEventListener("change", themeChange) - window.addCleanup(() => colorSchemeMediaQuery.removeEventListener("change", themeChange)) -}) diff --git a/quartz/components/scripts/readermode.inline.ts b/quartz/components/scripts/readermode.inline.ts deleted file mode 100644 index 09f6a5f32..000000000 --- a/quartz/components/scripts/readermode.inline.ts +++ /dev/null @@ -1,25 +0,0 @@ -let isReaderMode = false - -const emitReaderModeChangeEvent = (mode: "on" | "off") => { - const event: CustomEventMap["readermodechange"] = new CustomEvent("readermodechange", { - detail: { mode }, - }) - document.dispatchEvent(event) -} - -document.addEventListener("nav", () => { - const switchReaderMode = () => { - isReaderMode = !isReaderMode - const newMode = isReaderMode ? "on" : "off" - document.documentElement.setAttribute("reader-mode", newMode) - emitReaderModeChangeEvent(newMode) - } - - for (const readerModeButton of document.getElementsByClassName("readermode")) { - readerModeButton.addEventListener("click", switchReaderMode) - window.addCleanup(() => readerModeButton.removeEventListener("click", switchReaderMode)) - } - - // Set initial state - document.documentElement.setAttribute("reader-mode", isReaderMode ? "on" : "off") -}) diff --git a/quartz/components/styles/contentMeta.scss b/quartz/components/styles/contentMeta.scss deleted file mode 100644 index 2da2d6a63..000000000 --- a/quartz/components/styles/contentMeta.scss +++ /dev/null @@ -1,14 +0,0 @@ -.content-meta { - margin-top: 0; - color: var(--darkgray); - - &[show-comma="true"] { - > *:not(:last-child) { - margin-right: 8px; - - &::after { - content: ","; - } - } - } -} diff --git a/quartz/components/styles/darkmode.scss b/quartz/components/styles/darkmode.scss deleted file mode 100644 index e0999c735..000000000 --- a/quartz/components/styles/darkmode.scss +++ /dev/null @@ -1,47 +0,0 @@ -.darkmode { - cursor: pointer; - padding: 0; - position: relative; - background: none; - border: none; - width: 20px; - height: 32px; - margin: 0; - text-align: inherit; - flex-shrink: 0; - - & svg { - position: absolute; - width: 20px; - height: 20px; - top: calc(50% - 10px); - fill: var(--darkgray); - transition: opacity 0.1s ease; - } -} - -:root[saved-theme="dark"] { - color-scheme: dark; -} - -:root[saved-theme="light"] { - color-scheme: light; -} - -:root[saved-theme="dark"] .darkmode { - & > .dayIcon { - display: none; - } - & > .nightIcon { - display: inline; - } -} - -:root .darkmode { - & > .dayIcon { - display: inline; - } - & > .nightIcon { - display: none; - } -} diff --git a/quartz/components/styles/footer.scss b/quartz/components/styles/footer.scss deleted file mode 100644 index 9c8dbf8c1..000000000 --- a/quartz/components/styles/footer.scss +++ /dev/null @@ -1,15 +0,0 @@ -footer { - text-align: left; - margin-bottom: 4rem; - opacity: 0.7; - - & ul { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: row; - gap: 1rem; - margin-top: -1rem; - } -} diff --git a/quartz/components/styles/readermode.scss b/quartz/components/styles/readermode.scss deleted file mode 100644 index e2df7c0d6..000000000 --- a/quartz/components/styles/readermode.scss +++ /dev/null @@ -1,34 +0,0 @@ -.readermode { - cursor: pointer; - padding: 0; - position: relative; - background: none; - border: none; - width: 20px; - height: 32px; - margin: 0; - text-align: inherit; - flex-shrink: 0; - - & svg { - position: absolute; - width: 20px; - height: 20px; - top: calc(50% - 10px); - fill: var(--darkgray); - stroke: var(--darkgray); - transition: opacity 0.1s ease; - } -} - -:root[reader-mode="on"] { - & .sidebar.left, - & .sidebar.right { - opacity: 0; - transition: opacity 0.2s ease; - - &:hover { - opacity: 1; - } - } -}