From ad617ac4d6383b08243597335fe62db84f48b244 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Sat, 28 Feb 2026 20:42:16 +0100 Subject: [PATCH] feat: add FrameRegistry for plugin-provided page frames Plugins can now register custom page frames via their manifest's 'frames' field. Frames are loaded alongside components during plugin initialization and resolved by name at render time with fallback to built-in frames. --- quartz/components/frames/index.ts | 21 +++++++++-- quartz/components/frames/registry.ts | 34 ++++++++++++++++++ quartz/plugins/loader/config-loader.ts | 21 +++++++++-- quartz/plugins/loader/frameLoader.ts | 48 ++++++++++++++++++++++++++ quartz/plugins/loader/types.ts | 2 ++ 5 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 quartz/components/frames/registry.ts create mode 100644 quartz/plugins/loader/frameLoader.ts diff --git a/quartz/components/frames/index.ts b/quartz/components/frames/index.ts index b772fdfba..b39d05697 100644 --- a/quartz/components/frames/index.ts +++ b/quartz/components/frames/index.ts @@ -2,11 +2,14 @@ import { PageFrame } from "./types" import { DefaultFrame } from "./DefaultFrame" import { FullWidthFrame } from "./FullWidthFrame" import { MinimalFrame } from "./MinimalFrame" +import { frameRegistry } from "./registry" export type { PageFrame, PageFrameProps } from "./types" export { DefaultFrame } from "./DefaultFrame" export { FullWidthFrame } from "./FullWidthFrame" export { MinimalFrame } from "./MinimalFrame" +export { frameRegistry } from "./registry" +export type { RegisteredFrame } from "./registry" /** * Registry of built-in page frames. Page types can reference these by name @@ -22,17 +25,29 @@ const builtinFrames: Record = { } /** - * Resolve a frame by name. Returns the DefaultFrame if the name is not found, - * logging a warning for unknown frame names. + * Resolve a frame by name. Checks plugin-registered frames first, + * then built-in frames, then falls back to DefaultFrame. */ export function resolveFrame(name: string | undefined): PageFrame { if (!name || name === "default") { return DefaultFrame } + + // Check plugin-registered frames first + const registered = frameRegistry.get(name) + if (registered) { + return registered.frame + } + + // Fall back to built-in frames const frame = builtinFrames[name] if (!frame) { + const allFrameNames = [ + ...Object.keys(builtinFrames), + ...[...frameRegistry.getAll().keys()], + ] console.warn( - `Unknown page frame "${name}", falling back to "default". Available frames: ${Object.keys(builtinFrames).join(", ")}`, + `Unknown page frame "${name}", falling back to "default". Available frames: ${allFrameNames.join(", ")}`, ) return DefaultFrame } diff --git a/quartz/components/frames/registry.ts b/quartz/components/frames/registry.ts new file mode 100644 index 000000000..12b867adf --- /dev/null +++ b/quartz/components/frames/registry.ts @@ -0,0 +1,34 @@ +import { PageFrame } from "./types" + +export interface RegisteredFrame { + frame: PageFrame + source: string +} + +class FrameRegistry { + private frames = new Map() + + register(name: string, frame: PageFrame, source: string): void { + const existing = this.frames.get(name) + if (existing && existing.source !== source) { + console.warn( + `Page frame "${name}" from ${source} is overwriting frame from ${existing.source}`, + ) + } + this.frames.set(name, { frame, source }) + } + + get(name: string): RegisteredFrame | undefined { + return this.frames.get(name) + } + + getAll(): Map { + return new Map(this.frames) + } + + has(name: string): boolean { + return this.frames.has(name) + } +} + +export const frameRegistry = new FrameRegistry() diff --git a/quartz/plugins/loader/config-loader.ts b/quartz/plugins/loader/config-loader.ts index 6e04a097e..a2c796f75 100644 --- a/quartz/plugins/loader/config-loader.ts +++ b/quartz/plugins/loader/config-loader.ts @@ -15,6 +15,7 @@ import { } from "./types" import { parsePluginSource, installPlugin, getPluginEntryPoint, toFileUrl } from "./gitLoader" import { loadComponentsFromPackage } from "./componentLoader" +import { loadFramesFromPackage } from "./frameLoader" import { componentRegistry } from "../../components/registry" import { getCondition } from "./conditions" @@ -190,6 +191,7 @@ async function readManifestFromPackageJson(source: string): Promise 0) { await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) } + if (manifest?.frames && Object.keys(manifest.frames).length > 0) { + await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) + } break } const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) @@ -313,6 +318,9 @@ export async function loadQuartzConfig( target.push({ entry, manifest }) } else if (manifest?.components && Object.keys(manifest.components).length > 0) { await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) + if (manifest?.frames && Object.keys(manifest.frames).length > 0) { + await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) + } } else { console.warn( styleText("yellow", `⚠`) + @@ -320,9 +328,15 @@ export async function loadQuartzConfig( ) } } catch { - if (manifest?.components && Object.keys(manifest.components).length > 0) { + const hasComponents = manifest?.components && Object.keys(manifest.components).length > 0 + const hasFrames = manifest?.frames && Object.keys(manifest.frames).length > 0 + if (hasComponents) { await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) - } else { + } + if (hasFrames) { + await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) + } + if (!hasComponents && !hasFrames) { console.warn( styleText("yellow", `⚠`) + ` Could not load plugin "${extractPluginName(entry.source)}" to detect category. Skipping.`, @@ -362,6 +376,9 @@ export async function loadQuartzConfig( if (manifest?.components && Object.keys(manifest.components).length > 0) { await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) } + if (manifest?.frames && Object.keys(manifest.frames).length > 0) { + await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) + } const factory = findFactory(module, expectedCategory) if (!factory) { diff --git a/quartz/plugins/loader/frameLoader.ts b/quartz/plugins/loader/frameLoader.ts new file mode 100644 index 000000000..746c824b5 --- /dev/null +++ b/quartz/plugins/loader/frameLoader.ts @@ -0,0 +1,48 @@ +import { frameRegistry } from "../../components/frames/registry" +import { PluginManifest } from "./types" +import { PageFrame } from "../../components/frames/types" +import { getPluginSubpathEntry, toFileUrl } from "./gitLoader" + +export async function loadFramesFromPackage( + pluginName: string, + manifest: PluginManifest | null, + subdir?: string, +): Promise { + if (!manifest?.frames) return + + try { + const framesPath = getPluginSubpathEntry(pluginName, "./frames", subdir) + + let framesModule: Record + if (framesPath) { + framesModule = await import(toFileUrl(framesPath)) + } else { + framesModule = await import(`${pluginName}/frames`) + } + + for (const [exportName, frameMeta] of Object.entries(manifest.frames)) { + const frame = framesModule[exportName] + if (!frame) { + console.warn( + `Frame "${exportName}" declared in manifest but not found in ${pluginName}/frames`, + ) + continue + } + + const pageFrame = frame as PageFrame + if (!pageFrame.name || typeof pageFrame.render !== "function") { + console.warn( + `Frame "${exportName}" from ${pluginName} is not a valid PageFrame (missing name or render)`, + ) + continue + } + + // Register under the frame's declared name + frameRegistry.register(pageFrame.name, pageFrame, pluginName) + } + } catch { + if (manifest.frames && Object.keys(manifest.frames).length > 0) { + console.warn(`Plugin "${pluginName}" declares frames but failed to load them`) + } + } +} diff --git a/quartz/plugins/loader/types.ts b/quartz/plugins/loader/types.ts index 0cb0432ec..9d456ef72 100644 --- a/quartz/plugins/loader/types.ts +++ b/quartz/plugins/loader/types.ts @@ -63,6 +63,8 @@ export interface PluginManifest { configSchema?: object /** Components provided by this plugin, keyed by component export name */ components?: Record + /** Page frames provided by this plugin, keyed by export name. Each entry maps to a PageFrame object. */ + frames?: Record } /**