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; - } - } -}