feat: add PageType plugin infrastructure (Phase D Step 4)

This commit is contained in:
saberzero1 2026-02-13 19:12:19 +01:00
parent f8a682ab45
commit 68f3c3fadd
No known key found for this signature in database
10 changed files with 376 additions and 17 deletions

View File

@ -56,13 +56,14 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
const output = argv.output const output = argv.output
const pluginCount = Object.values(cfg.plugins).flat().length const pluginCount = Object.values(cfg.plugins).flat().length
const pluginNames = (key: "transformers" | "filters" | "emitters") => const pluginNames = (key: "transformers" | "filters" | "emitters" | "pageTypes") =>
cfg.plugins[key].map((plugin) => plugin.name) (cfg.plugins[key] ?? []).map((plugin) => plugin.name)
if (argv.verbose) { if (argv.verbose) {
console.log(`Loaded ${pluginCount} plugins`) console.log(`Loaded ${pluginCount} plugins`)
console.log(` Transformers: ${pluginNames("transformers").join(", ")}`) console.log(` Transformers: ${pluginNames("transformers").join(", ")}`)
console.log(` Filters: ${pluginNames("filters").join(", ")}`) console.log(` Filters: ${pluginNames("filters").join(", ")}`)
console.log(` Emitters: ${pluginNames("emitters").join(", ")}`) console.log(` Emitters: ${pluginNames("emitters").join(", ")}`)
console.log(` PageTypes: ${pluginNames("pageTypes").join(", ")}`)
} }
const release = await mut.acquire() const release = await mut.acquire()

View File

@ -2,6 +2,7 @@ import {
QuartzTransformerPluginInstance, QuartzTransformerPluginInstance,
QuartzFilterPluginInstance, QuartzFilterPluginInstance,
QuartzEmitterPluginInstance, QuartzEmitterPluginInstance,
QuartzPageTypePluginInstance,
} from "./types" } from "./types"
import { LoadedPlugin } from "./loader/types" import { LoadedPlugin } from "./loader/types"
@ -9,6 +10,7 @@ export interface PluginConfiguration {
transformers: (QuartzTransformerPluginInstance | LoadedPlugin)[] transformers: (QuartzTransformerPluginInstance | LoadedPlugin)[]
filters: (QuartzFilterPluginInstance | LoadedPlugin)[] filters: (QuartzFilterPluginInstance | LoadedPlugin)[]
emitters: (QuartzEmitterPluginInstance | LoadedPlugin)[] emitters: (QuartzEmitterPluginInstance | LoadedPlugin)[]
pageTypes?: (QuartzPageTypePluginInstance | LoadedPlugin)[]
} }
export function isLoadedPlugin(plugin: unknown): plugin is LoadedPlugin { export function isLoadedPlugin(plugin: unknown): plugin is LoadedPlugin {
@ -27,13 +29,22 @@ export function getPluginInstance<T extends object | undefined>(
| QuartzTransformerPluginInstance | QuartzTransformerPluginInstance
| QuartzFilterPluginInstance | QuartzFilterPluginInstance
| QuartzEmitterPluginInstance | QuartzEmitterPluginInstance
| QuartzPageTypePluginInstance
| LoadedPlugin, | LoadedPlugin,
options?: T, options?: T,
): QuartzTransformerPluginInstance | QuartzFilterPluginInstance | QuartzEmitterPluginInstance { ):
| QuartzTransformerPluginInstance
| QuartzFilterPluginInstance
| QuartzEmitterPluginInstance
| QuartzPageTypePluginInstance {
if (isLoadedPlugin(plugin)) { if (isLoadedPlugin(plugin)) {
const factory = plugin.plugin as ( const factory = plugin.plugin as (
opts?: T, opts?: T,
) => QuartzTransformerPluginInstance | QuartzFilterPluginInstance | QuartzEmitterPluginInstance ) =>
| QuartzTransformerPluginInstance
| QuartzFilterPluginInstance
| QuartzEmitterPluginInstance
| QuartzPageTypePluginInstance
return factory(options) return factory(options)
} }
return plugin return plugin

View File

@ -47,6 +47,7 @@ export * from "./filters"
export * from "./emitters" export * from "./emitters"
export * from "./types" export * from "./types"
export * from "./config" export * from "./config"
export * as PageTypes from "./pageTypes"
export * as PluginLoader from "./loader" export * as PluginLoader from "./loader"
declare module "vfile" { declare module "vfile" {

View File

@ -7,7 +7,12 @@ import {
PluginResolutionOptions, PluginResolutionOptions,
PluginSpecifier, PluginSpecifier,
} from "./types" } from "./types"
import { QuartzTransformerPlugin, QuartzFilterPlugin, QuartzEmitterPlugin } from "../types" import {
QuartzTransformerPlugin,
QuartzFilterPlugin,
QuartzEmitterPlugin,
QuartzPageTypePlugin,
} from "../types"
import { parsePluginSource, installPlugin, getPluginEntryPoint } from "./gitLoader" import { parsePluginSource, installPlugin, getPluginEntryPoint } from "./gitLoader"
const MINIMUM_QUARTZ_VERSION = "4.5.0" const MINIMUM_QUARTZ_VERSION = "4.5.0"
@ -51,7 +56,9 @@ async function tryImportPlugin(packageName: string): Promise<{
} }
} }
function detectPluginType(module: unknown): "transformer" | "filter" | "emitter" | null { function detectPluginType(
module: unknown,
): "transformer" | "filter" | "emitter" | "pageType" | null {
if (!module || typeof module !== "object") return null if (!module || typeof module !== "object") return null
const mod = module as Record<string, unknown> const mod = module as Record<string, unknown>
@ -60,6 +67,8 @@ function detectPluginType(module: unknown): "transformer" | "filter" | "emitter"
return null return null
} }
const hasPageTypeProps = ["match", "body", "layout"].every((key) => key in mod)
const hasTransformerProps = ["textTransform", "markdownPlugins", "htmlPlugins"].some( const hasTransformerProps = ["textTransform", "markdownPlugins", "htmlPlugins"].some(
(key) => key in mod && (typeof mod[key] === "function" || mod[key] === undefined), (key) => key in mod && (typeof mod[key] === "function" || mod[key] === undefined),
) )
@ -70,6 +79,7 @@ function detectPluginType(module: unknown): "transformer" | "filter" | "emitter"
const hasEmitterProps = ["emit"].some((key) => key in mod && typeof mod[key] === "function") const hasEmitterProps = ["emit"].some((key) => key in mod && typeof mod[key] === "function")
if (hasPageTypeProps) return "pageType"
if (hasEmitterProps) return "emitter" if (hasEmitterProps) return "emitter"
if (hasFilterProps) return "filter" if (hasFilterProps) return "filter"
if (hasTransformerProps) return "transformer" if (hasTransformerProps) return "transformer"
@ -79,8 +89,13 @@ function detectPluginType(module: unknown): "transformer" | "filter" | "emitter"
function extractPluginFactory( function extractPluginFactory(
module: unknown, module: unknown,
type: "transformer" | "filter" | "emitter", type: "transformer" | "filter" | "emitter" | "pageType",
): QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin | null { ):
| QuartzTransformerPlugin
| QuartzFilterPlugin
| QuartzEmitterPlugin
| QuartzPageTypePlugin
| null {
if (!module || typeof module !== "object") return null if (!module || typeof module !== "object") return null
const mod = module as Record<string, unknown> const mod = module as Record<string, unknown>
@ -88,7 +103,11 @@ function extractPluginFactory(
const factory = mod.default ?? mod[type] ?? mod.plugin ?? null const factory = mod.default ?? mod[type] ?? mod.plugin ?? null
if (typeof factory === "function") { if (typeof factory === "function") {
return factory as QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin return factory as
| QuartzTransformerPlugin
| QuartzFilterPlugin
| QuartzEmitterPlugin
| QuartzPageTypePlugin
} }
return null return null
@ -374,7 +393,7 @@ export async function resolvePlugins(
console.log( console.log(
styleText("cyan", `External plugins loaded:`) + styleText("cyan", `External plugins loaded:`) +
` ${byType.transformer ?? 0} transformers, ${byType.filter ?? 0} filters, ${byType.emitter ?? 0} emitters`, ` ${byType.transformer ?? 0} transformers, ${byType.filter ?? 0} filters, ${byType.emitter ?? 0} emitters, ${byType.pageType ?? 0} pageTypes`,
) )
} }

View File

@ -1,4 +1,9 @@
import { QuartzTransformerPlugin, QuartzFilterPlugin, QuartzEmitterPlugin } from "../types" import {
QuartzTransformerPlugin,
QuartzFilterPlugin,
QuartzEmitterPlugin,
QuartzPageTypePlugin,
} from "../types"
import { BuildCtx } from "../../util/ctx" import { BuildCtx } from "../../util/ctx"
/** /**
@ -25,7 +30,7 @@ export interface PluginManifest {
author?: string author?: string
homepage?: string homepage?: string
keywords?: string[] keywords?: string[]
category?: "transformer" | "filter" | "emitter" category?: "transformer" | "filter" | "emitter" | "pageType"
quartzVersion?: string quartzVersion?: string
configSchema?: object configSchema?: object
/** Components provided by this plugin */ /** Components provided by this plugin */
@ -36,9 +41,9 @@ export interface PluginManifest {
* Loaded plugin with metadata * Loaded plugin with metadata
*/ */
export interface LoadedPlugin { export interface LoadedPlugin {
plugin: QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin plugin: QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin | QuartzPageTypePlugin
manifest: PluginManifest manifest: PluginManifest
type: "transformer" | "filter" | "emitter" type: "transformer" | "filter" | "emitter" | "pageType"
source: string source: string
} }

View File

@ -0,0 +1,32 @@
import { QuartzPageTypePlugin } from "../types"
import { match } from "./matchers"
import { NotFound } from "../../components"
import { defaultProcessedContent } from "../vfile"
import { i18n } from "../../i18n"
import { FullSlug } from "../../util/path"
export const NotFoundPageType: QuartzPageTypePlugin = () => ({
name: "404",
priority: -1,
match: match.none(),
generate({ cfg }) {
const notFound = i18n(cfg.locale).pages.error.title
const slug = "404" as FullSlug
const [, vfile] = defaultProcessedContent({
slug,
text: notFound,
description: notFound,
frontmatter: { title: notFound, tags: [] },
})
return [
{
slug,
title: notFound,
data: vfile.data,
},
]
},
layout: "404",
body: NotFound,
})

View File

@ -0,0 +1,182 @@
import { QuartzEmitterPlugin, QuartzPageTypePluginInstance } from "../types"
import { QuartzComponent, QuartzComponentProps } from "../../components/types"
import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg"
import { FullSlug, pathToRoot } from "../../util/path"
import { ProcessedContent, defaultProcessedContent } from "../vfile"
import { write } from "../emitters/helpers"
import { BuildCtx } from "../../util/ctx"
import { StaticResources } from "../../util/resources"
function resolveLayout(
pageType: QuartzPageTypePluginInstance,
sharedDefaults: Partial<FullPageLayout>,
byPageType: Record<string, Partial<FullPageLayout>>,
): FullPageLayout {
const overrides = byPageType[pageType.layout] ?? {}
return {
head: overrides.head ?? sharedDefaults.head!,
header: overrides.header ?? sharedDefaults.header ?? [],
beforeBody: overrides.beforeBody ?? sharedDefaults.beforeBody ?? [],
pageBody: pageType.body(undefined),
afterBody: overrides.afterBody ?? sharedDefaults.afterBody ?? [],
left: overrides.left ?? sharedDefaults.left ?? [],
right: overrides.right ?? sharedDefaults.right ?? [],
footer: overrides.footer ?? sharedDefaults.footer!,
}
}
function collectComponents(
pageTypes: QuartzPageTypePluginInstance[],
sharedDefaults: Partial<FullPageLayout>,
byPageType: Record<string, Partial<FullPageLayout>>,
): QuartzComponent[] {
const seen = new Set<QuartzComponent>()
for (const pt of pageTypes) {
const layout = resolveLayout(pt, sharedDefaults, byPageType)
const all = [
layout.head,
...layout.header,
...layout.beforeBody,
layout.pageBody,
...layout.afterBody,
...layout.left,
...layout.right,
layout.footer,
]
for (const c of all) {
seen.add(c)
}
}
return [...seen]
}
interface DispatcherOptions {
defaults: Partial<FullPageLayout>
byPageType: Record<string, Partial<FullPageLayout>>
}
async function emitPage(
ctx: BuildCtx,
slug: FullSlug,
tree: ProcessedContent[0],
fileData: ProcessedContent[1]["data"],
allFiles: ProcessedContent[1]["data"][],
layout: FullPageLayout,
resources: StaticResources,
) {
const cfg = ctx.cfg.configuration
const externalResources = pageResources(pathToRoot(slug), resources)
const componentData: QuartzComponentProps = {
ctx,
fileData,
externalResources,
cfg,
children: [],
tree,
allFiles,
}
return write({
ctx,
content: renderPage(cfg, slug, componentData, layout, externalResources),
slug,
ext: ".html",
})
}
export const PageTypeDispatcher: QuartzEmitterPlugin<Partial<DispatcherOptions>> = (userOpts) => {
const defaults = userOpts?.defaults ?? {}
const byPageType = userOpts?.byPageType ?? {}
return {
name: "PageTypeDispatcher",
getQuartzComponents(ctx) {
const pageTypes = ctx.cfg.plugins.pageTypes ?? []
return collectComponents(pageTypes, defaults, byPageType)
},
async *emit(ctx, content, resources) {
const pageTypes = [...(ctx.cfg.plugins.pageTypes ?? [])].sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
)
const cfg = ctx.cfg.configuration
const allFiles = content.map((c) => c[1].data)
for (const [tree, file] of content) {
const slug = file.data.slug!
const fileData = file.data
for (const pt of pageTypes) {
if (pt.match({ slug, fileData, cfg })) {
const layout = resolveLayout(pt, defaults, byPageType)
yield emitPage(ctx, slug, tree, fileData, allFiles, layout, resources)
break
}
}
}
for (const pt of pageTypes) {
if (!pt.generate) continue
const virtualPages = pt.generate({ content, cfg, ctx })
const layout = resolveLayout(pt, defaults, byPageType)
for (const vp of virtualPages) {
const [tree, vfile] = defaultProcessedContent({
slug: vp.slug,
frontmatter: { title: vp.title, tags: [] },
...vp.data,
})
yield emitPage(ctx, vp.slug, tree, vfile.data, allFiles, layout, resources)
}
}
},
async *partialEmit(ctx, content, resources, changeEvents) {
const pageTypes = [...(ctx.cfg.plugins.pageTypes ?? [])].sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
)
const cfg = ctx.cfg.configuration
const allFiles = content.map((c) => c[1].data)
const changedSlugs = new Set<string>()
for (const changeEvent of changeEvents) {
if (!changeEvent.file) continue
if (changeEvent.type === "add" || changeEvent.type === "change") {
changedSlugs.add(changeEvent.file.data.slug!)
}
}
for (const [tree, file] of content) {
const slug = file.data.slug!
if (!changedSlugs.has(slug)) continue
const fileData = file.data
for (const pt of pageTypes) {
if (pt.match({ slug, fileData, cfg })) {
const layout = resolveLayout(pt, defaults, byPageType)
yield emitPage(ctx, slug, tree, fileData, allFiles, layout, resources)
break
}
}
}
for (const pt of pageTypes) {
if (!pt.generate) continue
const virtualPages = pt.generate({ content, cfg, ctx })
const layout = resolveLayout(pt, defaults, byPageType)
for (const vp of virtualPages) {
const [tree, vfile] = defaultProcessedContent({
slug: vp.slug,
frontmatter: { title: vp.title, tags: [] },
...vp.data,
})
yield emitPage(ctx, vp.slug, tree, vfile.data, allFiles, layout, resources)
}
}
},
}
}

View File

@ -0,0 +1,3 @@
export { match } from "./matchers"
export { NotFoundPageType } from "./404"
export { PageTypeDispatcher } from "./dispatcher"

View File

@ -0,0 +1,39 @@
import { PageMatcher } from "../types"
export const match = {
ext: (extension: string): PageMatcher => {
const normalized = extension.startsWith(".") ? extension : `.${extension}`
return ({ slug }) => slug.endsWith(normalized) || !slug.includes(".")
},
slugPrefix: (prefix: string): PageMatcher => {
return ({ slug }) => slug.startsWith(prefix)
},
frontmatter: (key: string, predicate: (value: unknown) => boolean): PageMatcher => {
return ({ fileData }) => {
const fm = fileData.frontmatter as Record<string, unknown> | undefined
return fm ? predicate(fm[key]) : false
}
},
and: (...matchers: PageMatcher[]): PageMatcher => {
return (args) => matchers.every((m) => m(args))
},
or: (...matchers: PageMatcher[]): PageMatcher => {
return (args) => matchers.some((m) => m(args))
},
not: (matcher: PageMatcher): PageMatcher => {
return (args) => !matcher(args)
},
all: (): PageMatcher => {
return () => true
},
none: (): PageMatcher => {
return () => false
},
}

View File

@ -1,15 +1,17 @@
import { PluggableList } from "unified" import { PluggableList } from "unified"
import { StaticResources } from "../util/resources" import { StaticResources } from "../util/resources"
import { ProcessedContent } from "./vfile" import { ProcessedContent, QuartzPluginData } from "./vfile"
import { QuartzComponent } from "../components/types" import { QuartzComponent, QuartzComponentConstructor } from "../components/types"
import { FilePath } from "../util/path" import { FilePath, FullSlug } from "../util/path"
import { BuildCtx } from "../util/ctx" import { BuildCtx } from "../util/ctx"
import { GlobalConfiguration } from "../cfg"
import { VFile } from "vfile" import { VFile } from "vfile"
export interface PluginTypes { export interface PluginTypes {
transformers: QuartzTransformerPluginInstance[] transformers: QuartzTransformerPluginInstance[]
filters: QuartzFilterPluginInstance[] filters: QuartzFilterPluginInstance[]
emitters: QuartzEmitterPluginInstance[] emitters: QuartzEmitterPluginInstance[]
pageTypes?: QuartzPageTypePluginInstance[]
} }
type OptionType = object | undefined type OptionType = object | undefined
@ -63,3 +65,67 @@ export type QuartzEmitterPluginInstance = {
getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[] getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[]
externalResources?: ExternalResourcesFn externalResources?: ExternalResourcesFn
} }
// ============================================================================
// PageType Plugin Types
// ============================================================================
/**
* Matcher function: determines if a source file belongs to a page type.
* Returns true if the page type should own this file.
*/
export type PageMatcher = (args: {
slug: FullSlug
fileData: QuartzPluginData
cfg: GlobalConfiguration
}) => boolean
/**
* Virtual page descriptor for page types that generate pages
* from aggregated data (e.g., tag indexes, folder listings).
*/
export interface VirtualPage {
slug: FullSlug
title: string
data: Partial<QuartzPluginData>
}
/**
* Generator function: produces virtual pages from all processed content.
* Used by page types that don't match source files but instead create
* synthetic pages (e.g., one page per tag, one page per folder).
*/
export type PageGenerator = (args: {
content: ProcessedContent[]
cfg: GlobalConfiguration
ctx: BuildCtx
}) => VirtualPage[]
/**
* Factory function that creates a PageType plugin instance.
*/
export type QuartzPageTypePlugin<Options extends OptionType = undefined> = (
opts?: Options,
) => QuartzPageTypePluginInstance
/**
* A PageType plugin instance.
*
* PageTypes are a declarative abstraction over page-rendering emitters.
* Each PageType declares which files it owns (via `match`), optionally
* generates virtual pages (via `generate`), and provides a body component
* and layout reference for rendering.
*/
export type QuartzPageTypePluginInstance = {
name: string
/** Higher priority wins when multiple page types match the same file. Default: 0. */
priority?: number
/** Determines which source files this page type owns. */
match: PageMatcher
/** Produces virtual pages from aggregated content data. */
generate?: PageGenerator
/** Layout key — references a key in `layout.byPageType`. */
layout: string
/** The body component constructor for this page type. */
body: QuartzComponentConstructor
}