diff --git a/quartz/build.ts b/quartz/build.ts index b98f4a8a0..05a0722ba 100644 --- a/quartz/build.ts +++ b/quartz/build.ts @@ -56,13 +56,14 @@ async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) { const output = argv.output const pluginCount = Object.values(cfg.plugins).flat().length - const pluginNames = (key: "transformers" | "filters" | "emitters") => - cfg.plugins[key].map((plugin) => plugin.name) + const pluginNames = (key: "transformers" | "filters" | "emitters" | "pageTypes") => + (cfg.plugins[key] ?? []).map((plugin) => plugin.name) if (argv.verbose) { console.log(`Loaded ${pluginCount} plugins`) console.log(` Transformers: ${pluginNames("transformers").join(", ")}`) console.log(` Filters: ${pluginNames("filters").join(", ")}`) console.log(` Emitters: ${pluginNames("emitters").join(", ")}`) + console.log(` PageTypes: ${pluginNames("pageTypes").join(", ")}`) } const release = await mut.acquire() diff --git a/quartz/plugins/config.ts b/quartz/plugins/config.ts index d6b1890a2..d19a738cd 100644 --- a/quartz/plugins/config.ts +++ b/quartz/plugins/config.ts @@ -2,6 +2,7 @@ import { QuartzTransformerPluginInstance, QuartzFilterPluginInstance, QuartzEmitterPluginInstance, + QuartzPageTypePluginInstance, } from "./types" import { LoadedPlugin } from "./loader/types" @@ -9,6 +10,7 @@ export interface PluginConfiguration { transformers: (QuartzTransformerPluginInstance | LoadedPlugin)[] filters: (QuartzFilterPluginInstance | LoadedPlugin)[] emitters: (QuartzEmitterPluginInstance | LoadedPlugin)[] + pageTypes?: (QuartzPageTypePluginInstance | LoadedPlugin)[] } export function isLoadedPlugin(plugin: unknown): plugin is LoadedPlugin { @@ -27,13 +29,22 @@ export function getPluginInstance( | QuartzTransformerPluginInstance | QuartzFilterPluginInstance | QuartzEmitterPluginInstance + | QuartzPageTypePluginInstance | LoadedPlugin, options?: T, -): QuartzTransformerPluginInstance | QuartzFilterPluginInstance | QuartzEmitterPluginInstance { +): + | QuartzTransformerPluginInstance + | QuartzFilterPluginInstance + | QuartzEmitterPluginInstance + | QuartzPageTypePluginInstance { if (isLoadedPlugin(plugin)) { const factory = plugin.plugin as ( opts?: T, - ) => QuartzTransformerPluginInstance | QuartzFilterPluginInstance | QuartzEmitterPluginInstance + ) => + | QuartzTransformerPluginInstance + | QuartzFilterPluginInstance + | QuartzEmitterPluginInstance + | QuartzPageTypePluginInstance return factory(options) } return plugin diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts index 041015054..c9956836e 100644 --- a/quartz/plugins/index.ts +++ b/quartz/plugins/index.ts @@ -47,6 +47,7 @@ export * from "./filters" export * from "./emitters" export * from "./types" export * from "./config" +export * as PageTypes from "./pageTypes" export * as PluginLoader from "./loader" declare module "vfile" { diff --git a/quartz/plugins/loader/index.ts b/quartz/plugins/loader/index.ts index b9f21e4fe..362037399 100644 --- a/quartz/plugins/loader/index.ts +++ b/quartz/plugins/loader/index.ts @@ -7,7 +7,12 @@ import { PluginResolutionOptions, PluginSpecifier, } from "./types" -import { QuartzTransformerPlugin, QuartzFilterPlugin, QuartzEmitterPlugin } from "../types" +import { + QuartzTransformerPlugin, + QuartzFilterPlugin, + QuartzEmitterPlugin, + QuartzPageTypePlugin, +} from "../types" import { parsePluginSource, installPlugin, getPluginEntryPoint } from "./gitLoader" 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 const mod = module as Record @@ -60,6 +67,8 @@ function detectPluginType(module: unknown): "transformer" | "filter" | "emitter" return null } + const hasPageTypeProps = ["match", "body", "layout"].every((key) => key in mod) + const hasTransformerProps = ["textTransform", "markdownPlugins", "htmlPlugins"].some( (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") + if (hasPageTypeProps) return "pageType" if (hasEmitterProps) return "emitter" if (hasFilterProps) return "filter" if (hasTransformerProps) return "transformer" @@ -79,8 +89,13 @@ function detectPluginType(module: unknown): "transformer" | "filter" | "emitter" function extractPluginFactory( module: unknown, - type: "transformer" | "filter" | "emitter", -): QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin | null { + type: "transformer" | "filter" | "emitter" | "pageType", +): + | QuartzTransformerPlugin + | QuartzFilterPlugin + | QuartzEmitterPlugin + | QuartzPageTypePlugin + | null { if (!module || typeof module !== "object") return null const mod = module as Record @@ -88,7 +103,11 @@ function extractPluginFactory( const factory = mod.default ?? mod[type] ?? mod.plugin ?? null if (typeof factory === "function") { - return factory as QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin + return factory as + | QuartzTransformerPlugin + | QuartzFilterPlugin + | QuartzEmitterPlugin + | QuartzPageTypePlugin } return null @@ -374,7 +393,7 @@ export async function resolvePlugins( console.log( 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`, ) } diff --git a/quartz/plugins/loader/types.ts b/quartz/plugins/loader/types.ts index 1b01d86e1..50491a3ae 100644 --- a/quartz/plugins/loader/types.ts +++ b/quartz/plugins/loader/types.ts @@ -1,4 +1,9 @@ -import { QuartzTransformerPlugin, QuartzFilterPlugin, QuartzEmitterPlugin } from "../types" +import { + QuartzTransformerPlugin, + QuartzFilterPlugin, + QuartzEmitterPlugin, + QuartzPageTypePlugin, +} from "../types" import { BuildCtx } from "../../util/ctx" /** @@ -25,7 +30,7 @@ export interface PluginManifest { author?: string homepage?: string keywords?: string[] - category?: "transformer" | "filter" | "emitter" + category?: "transformer" | "filter" | "emitter" | "pageType" quartzVersion?: string configSchema?: object /** Components provided by this plugin */ @@ -36,9 +41,9 @@ export interface PluginManifest { * Loaded plugin with metadata */ export interface LoadedPlugin { - plugin: QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin + plugin: QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin | QuartzPageTypePlugin manifest: PluginManifest - type: "transformer" | "filter" | "emitter" + type: "transformer" | "filter" | "emitter" | "pageType" source: string } diff --git a/quartz/plugins/pageTypes/404.ts b/quartz/plugins/pageTypes/404.ts new file mode 100644 index 000000000..c51f89c0f --- /dev/null +++ b/quartz/plugins/pageTypes/404.ts @@ -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, +}) diff --git a/quartz/plugins/pageTypes/dispatcher.ts b/quartz/plugins/pageTypes/dispatcher.ts new file mode 100644 index 000000000..5a89cf00f --- /dev/null +++ b/quartz/plugins/pageTypes/dispatcher.ts @@ -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, + byPageType: Record>, +): 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, + byPageType: Record>, +): QuartzComponent[] { + const seen = new Set() + 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 + byPageType: Record> +} + +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> = (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() + 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) + } + } + }, + } +} diff --git a/quartz/plugins/pageTypes/index.ts b/quartz/plugins/pageTypes/index.ts new file mode 100644 index 000000000..f62a7b20d --- /dev/null +++ b/quartz/plugins/pageTypes/index.ts @@ -0,0 +1,3 @@ +export { match } from "./matchers" +export { NotFoundPageType } from "./404" +export { PageTypeDispatcher } from "./dispatcher" diff --git a/quartz/plugins/pageTypes/matchers.ts b/quartz/plugins/pageTypes/matchers.ts new file mode 100644 index 000000000..0201ef8e6 --- /dev/null +++ b/quartz/plugins/pageTypes/matchers.ts @@ -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 | 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 + }, +} diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index 2a7c16c5d..6926d45eb 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -1,15 +1,17 @@ import { PluggableList } from "unified" import { StaticResources } from "../util/resources" -import { ProcessedContent } from "./vfile" -import { QuartzComponent } from "../components/types" -import { FilePath } from "../util/path" +import { ProcessedContent, QuartzPluginData } from "./vfile" +import { QuartzComponent, QuartzComponentConstructor } from "../components/types" +import { FilePath, FullSlug } from "../util/path" import { BuildCtx } from "../util/ctx" +import { GlobalConfiguration } from "../cfg" import { VFile } from "vfile" export interface PluginTypes { transformers: QuartzTransformerPluginInstance[] filters: QuartzFilterPluginInstance[] emitters: QuartzEmitterPluginInstance[] + pageTypes?: QuartzPageTypePluginInstance[] } type OptionType = object | undefined @@ -63,3 +65,67 @@ export type QuartzEmitterPluginInstance = { getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[] 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 +} + +/** + * 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 = ( + 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 +}