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.
This commit is contained in:
saberzero1 2026-02-28 20:42:16 +01:00
parent 88147be600
commit ad617ac4d6
No known key found for this signature in database
5 changed files with 121 additions and 5 deletions

View File

@ -2,11 +2,14 @@ import { PageFrame } from "./types"
import { DefaultFrame } from "./DefaultFrame" import { DefaultFrame } from "./DefaultFrame"
import { FullWidthFrame } from "./FullWidthFrame" import { FullWidthFrame } from "./FullWidthFrame"
import { MinimalFrame } from "./MinimalFrame" import { MinimalFrame } from "./MinimalFrame"
import { frameRegistry } from "./registry"
export type { PageFrame, PageFrameProps } from "./types" export type { PageFrame, PageFrameProps } from "./types"
export { DefaultFrame } from "./DefaultFrame" export { DefaultFrame } from "./DefaultFrame"
export { FullWidthFrame } from "./FullWidthFrame" export { FullWidthFrame } from "./FullWidthFrame"
export { MinimalFrame } from "./MinimalFrame" 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 * Registry of built-in page frames. Page types can reference these by name
@ -22,17 +25,29 @@ const builtinFrames: Record<string, PageFrame> = {
} }
/** /**
* Resolve a frame by name. Returns the DefaultFrame if the name is not found, * Resolve a frame by name. Checks plugin-registered frames first,
* logging a warning for unknown frame names. * then built-in frames, then falls back to DefaultFrame.
*/ */
export function resolveFrame(name: string | undefined): PageFrame { export function resolveFrame(name: string | undefined): PageFrame {
if (!name || name === "default") { if (!name || name === "default") {
return DefaultFrame 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] const frame = builtinFrames[name]
if (!frame) { if (!frame) {
const allFrameNames = [
...Object.keys(builtinFrames),
...[...frameRegistry.getAll().keys()],
]
console.warn( 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 return DefaultFrame
} }

View File

@ -0,0 +1,34 @@
import { PageFrame } from "./types"
export interface RegisteredFrame {
frame: PageFrame
source: string
}
class FrameRegistry {
private frames = new Map<string, RegisteredFrame>()
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<string, RegisteredFrame> {
return new Map(this.frames)
}
has(name: string): boolean {
return this.frames.has(name)
}
}
export const frameRegistry = new FrameRegistry()

View File

@ -15,6 +15,7 @@ import {
} from "./types" } from "./types"
import { parsePluginSource, installPlugin, getPluginEntryPoint, toFileUrl } from "./gitLoader" import { parsePluginSource, installPlugin, getPluginEntryPoint, toFileUrl } from "./gitLoader"
import { loadComponentsFromPackage } from "./componentLoader" import { loadComponentsFromPackage } from "./componentLoader"
import { loadFramesFromPackage } from "./frameLoader"
import { componentRegistry } from "../../components/registry" import { componentRegistry } from "../../components/registry"
import { getCondition } from "./conditions" import { getCondition } from "./conditions"
@ -190,6 +191,7 @@ async function readManifestFromPackageJson(source: string): Promise<PluginManife
defaultOptions: q.defaultOptions, defaultOptions: q.defaultOptions,
configSchema: q.configSchema, configSchema: q.configSchema,
components: q.components, components: q.components,
frames: q.frames,
} }
} catch { } catch {
return null return null
@ -297,6 +299,9 @@ export async function loadQuartzConfig(
if (manifest?.components && Object.keys(manifest.components).length > 0) { if (manifest?.components && Object.keys(manifest.components).length > 0) {
await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir)
} }
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir)
}
break break
} }
const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir)
@ -313,6 +318,9 @@ export async function loadQuartzConfig(
target.push({ entry, manifest }) target.push({ entry, manifest })
} else if (manifest?.components && Object.keys(manifest.components).length > 0) { } else if (manifest?.components && Object.keys(manifest.components).length > 0) {
await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir)
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir)
}
} else { } else {
console.warn( console.warn(
styleText("yellow", ``) + styleText("yellow", ``) +
@ -320,9 +328,15 @@ export async function loadQuartzConfig(
) )
} }
} catch { } 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) await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir)
} else { }
if (hasFrames) {
await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir)
}
if (!hasComponents && !hasFrames) {
console.warn( console.warn(
styleText("yellow", ``) + styleText("yellow", ``) +
` Could not load plugin "${extractPluginName(entry.source)}" to detect category. Skipping.`, ` 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) { if (manifest?.components && Object.keys(manifest.components).length > 0) {
await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) 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) const factory = findFactory(module, expectedCategory)
if (!factory) { if (!factory) {

View File

@ -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<void> {
if (!manifest?.frames) return
try {
const framesPath = getPluginSubpathEntry(pluginName, "./frames", subdir)
let framesModule: Record<string, unknown>
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`)
}
}
}

View File

@ -63,6 +63,8 @@ export interface PluginManifest {
configSchema?: object configSchema?: object
/** Components provided by this plugin, keyed by component export name */ /** Components provided by this plugin, keyed by component export name */
components?: Record<string, ComponentManifest & ComponentLayoutDefaults> components?: Record<string, ComponentManifest & ComponentLayoutDefaults>
/** Page frames provided by this plugin, keyed by export name. Each entry maps to a PageFrame object. */
frames?: Record<string, { exportName: string }>
} }
/** /**