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 { 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<string, PageFrame> = {
}
/**
* 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
}

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"
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<PluginManife
defaultOptions: q.defaultOptions,
configSchema: q.configSchema,
components: q.components,
frames: q.frames,
}
} catch {
return null
@ -297,6 +299,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)
}
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) {

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
/** Components provided by this plugin, keyed by component export name */
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 }>
}
/**