diff --git a/quartz/bootstrap-cli.mjs b/quartz/bootstrap-cli.mjs index 8b0b9268f..e24ca1c81 100755 --- a/quartz/bootstrap-cli.mjs +++ b/quartz/bootstrap-cli.mjs @@ -8,7 +8,21 @@ import { handleRestore, handleSync, } from "./cli/handlers.js" -import { CommonArgv, BuildArgv, CreateArgv, SyncArgv } from "./cli/args.js" +import { + handlePluginInstall, + handlePluginList, + handlePluginSearch, + handlePluginUninstall, +} from "./cli/plugin-handlers.js" +import { + CommonArgv, + BuildArgv, + CreateArgv, + SyncArgv, + PluginInstallArgv, + PluginUninstallArgv, + PluginSearchArgv, +} from "./cli/args.js" import { version } from "./cli/constants.js" yargs(hideBin(process.argv)) @@ -35,6 +49,44 @@ yargs(hideBin(process.argv)) .command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => { await handleBuild(argv) }) + .command( + "plugin ", + "Manage Quartz plugins", + (yargs) => { + return yargs + .command( + "install ", + "Install Quartz plugins from npm", + PluginInstallArgv, + async (argv) => { + await handlePluginInstall(argv.packages) + }, + ) + .command( + "uninstall ", + "Uninstall Quartz plugins", + PluginUninstallArgv, + async (argv) => { + await handlePluginUninstall(argv.packages) + }, + ) + .command("list", "List installed Quartz plugins", CommonArgv, async () => { + await handlePluginList() + }) + .command( + "search [query]", + "Search for Quartz plugins on npm", + PluginSearchArgv, + async (argv) => { + await handlePluginSearch(argv.query) + }, + ) + .demandCommand(1, "Please specify a plugin subcommand") + }, + async () => { + // This handler is called when no subcommand is provided + }, + ) .showHelpOnFail(false) .help() .strict() diff --git a/quartz/cfg.ts b/quartz/cfg.ts index c97d613bb..e7de5b244 100644 --- a/quartz/cfg.ts +++ b/quartz/cfg.ts @@ -1,6 +1,7 @@ import { ValidDateType } from "./components/Date" import { QuartzComponent } from "./components/types" import { ValidLocale } from "./i18n" +import { PluginSpecifier } from "./plugins/loader/types" import { PluginTypes } from "./plugins/types" import { Theme } from "./util/theme" @@ -88,6 +89,7 @@ export interface GlobalConfiguration { export interface QuartzConfig { configuration: GlobalConfiguration plugins: PluginTypes + externalPlugins?: PluginSpecifier[] } export interface FullPageLayout { diff --git a/quartz/cli/args.js b/quartz/cli/args.js index d2408e94b..f590bb3a5 100644 --- a/quartz/cli/args.js +++ b/quartz/cli/args.js @@ -106,3 +106,30 @@ export const BuildArgv = { describe: "how many threads to use to parse notes", }, } + +export const PluginInstallArgv = { + ...CommonArgv, + _: { + type: "string", + demandOption: true, + describe: "package names to install", + }, +} + +export const PluginUninstallArgv = { + ...CommonArgv, + _: { + type: "string", + demandOption: true, + describe: "package names to uninstall", + }, +} + +export const PluginSearchArgv = { + ...CommonArgv, + query: { + string: true, + alias: ["q"], + describe: "search query for plugins", + }, +} diff --git a/quartz/cli/plugin-handlers.js b/quartz/cli/plugin-handlers.js new file mode 100644 index 000000000..7c13e3b00 --- /dev/null +++ b/quartz/cli/plugin-handlers.js @@ -0,0 +1,130 @@ +import { styleText } from "util" +import { execSync, spawnSync } from "child_process" +import fs from "fs" +import path from "path" + +export async function handlePluginInstall(packageNames) { + console.log(`\n${styleText(["bgGreen", "black"], " Quartz Plugin Manager ")}\n`) + + if (packageNames.length === 0) { + console.log(styleText("red", "Error: No package names provided")) + console.log("Usage: npx quartz plugin install [package-name...]") + process.exit(1) + } + + console.log(`Installing ${packageNames.length} plugin(s)...`) + + const npmArgs = ["install", ...packageNames] + const result = spawnSync("npm", npmArgs, { stdio: "inherit" }) + + if (result.status !== 0) { + console.log(styleText("red", "Failed to install plugins")) + process.exit(1) + } + + console.log(styleText("green", "✓ Plugins installed successfully")) + console.log("\nAdd them to your quartz.config.ts:") + + for (const pkg of packageNames) { + console.log(` import { Plugin } from "${pkg}"`) + } +} + +export async function handlePluginList() { + console.log(`\n${styleText(["bgGreen", "black"], " Quartz Plugin Manager ")}\n`) + + const packageJsonPath = path.join(process.cwd(), "package.json") + + if (!fs.existsSync(packageJsonPath)) { + console.log(styleText("red", "No package.json found")) + process.exit(1) + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) + const allDeps = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + } + + const quartzPlugins = Object.entries(allDeps).filter(([name]) => { + return ( + name.startsWith("@quartz/") || + name.startsWith("quartz-") || + name.startsWith("@quartz-community/") + ) + }) + + if (quartzPlugins.length === 0) { + console.log("No Quartz plugins found in this project.") + console.log("Install plugins with: npx quartz plugin install ") + return + } + + console.log(`Found ${quartzPlugins.length} Quartz plugin(s):\n`) + + for (const [name, version] of quartzPlugins) { + console.log(` ${styleText("cyan", name)}@${version}`) + } +} + +export async function handlePluginSearch(query) { + console.log(`\n${styleText(["bgGreen", "black"], " Quartz Plugin Manager ")}\n`) + + const searchQuery = query || "quartz-plugin" + + console.log(`Searching npm for packages matching "${searchQuery}"...`) + console.log(styleText("grey", "(This may take a moment)\n")) + + try { + const result = execSync(`npm search ${searchQuery} --json`, { encoding: "utf-8" }) + const packages = JSON.parse(result) + + const quartzPlugins = packages.filter( + (pkg) => + pkg.name.startsWith("@quartz/") || + pkg.name.startsWith("quartz-") || + pkg.name.startsWith("@quartz-community/"), + ) + + if (quartzPlugins.length === 0) { + console.log("No Quartz plugins found matching your query.") + return + } + + console.log(`Found ${quartzPlugins.length} Quartz plugin(s):\n`) + + for (const pkg of quartzPlugins.slice(0, 20)) { + console.log(` ${styleText("cyan", pkg.name)}@${pkg.version}`) + if (pkg.description) { + console.log(` ${styleText("grey", pkg.description)}`) + } + console.log() + } + } catch { + console.log(styleText("yellow", "Could not search npm. Try visiting:")) + console.log(" https://www.npmjs.com/search?q=quartz-plugin") + } +} + +export async function handlePluginUninstall(packageNames) { + console.log(`\n${styleText(["bgGreen", "black"], " Quartz Plugin Manager ")}\n`) + + if (packageNames.length === 0) { + console.log(styleText("red", "Error: No package names provided")) + console.log("Usage: npx quartz plugin uninstall [package-name...]") + process.exit(1) + } + + console.log(`Uninstalling ${packageNames.length} plugin(s)...`) + + const npmArgs = ["uninstall", ...packageNames] + const result = spawnSync("npm", npmArgs, { stdio: "inherit" }) + + if (result.status !== 0) { + console.log(styleText("red", "Failed to uninstall plugins")) + process.exit(1) + } + + console.log(styleText("green", "✓ Plugins uninstalled successfully")) + console.log(styleText("yellow", "Don't forget to remove them from your quartz.config.ts!")) +} diff --git a/quartz/components/external.ts b/quartz/components/external.ts new file mode 100644 index 000000000..0113445a3 --- /dev/null +++ b/quartz/components/external.ts @@ -0,0 +1,23 @@ +import { componentRegistry } from "./registry" +import { QuartzComponent, QuartzComponentConstructor } from "./types" + +export function External( + name: string, + options?: Options, +): QuartzComponent { + const registered = componentRegistry.get(name) + if (!registered) { + throw new Error( + `External component "${name}" not found. ` + + `Make sure the plugin is installed and components are loaded before layouts are evaluated.`, + ) + } + + const { component } = registered + + if (typeof component === "function") { + return (component as QuartzComponentConstructor)(options as Options) + } + + return component as QuartzComponent +} diff --git a/quartz/components/index.ts b/quartz/components/index.ts index cece8e614..91ab4afc9 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -24,6 +24,11 @@ import Comments from "./Comments" import Flex from "./Flex" import ConditionalRender from "./ConditionalRender" +export { componentRegistry, defineComponent } from "./registry" +export { External } from "./external" +export type { ComponentManifest, RegisteredComponent } from "./registry" +export type { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" + export { ArticleTitle, Content, diff --git a/quartz/components/registry.ts b/quartz/components/registry.ts new file mode 100644 index 000000000..1fd5ab484 --- /dev/null +++ b/quartz/components/registry.ts @@ -0,0 +1,60 @@ +import { QuartzComponent, QuartzComponentConstructor } from "./types" + +export interface ComponentManifest { + name: string + displayName: string + description: string + version: string + quartzVersion?: string + author?: string + homepage?: string +} + +export interface RegisteredComponent { + component: QuartzComponent | QuartzComponentConstructor + source: string + manifest?: ComponentManifest +} + +class ComponentRegistry { + private components = new Map() + + register( + name: string, + component: QuartzComponent | QuartzComponentConstructor, + source: string, + manifest?: ComponentManifest, + ): void { + if (this.components.has(name)) { + console.warn(`Component "${name}" is being overwritten by ${source}`) + } + this.components.set(name, { component, source, manifest }) + } + + get(name: string): RegisteredComponent | undefined { + return this.components.get(name) + } + + getAll(): Map { + return new Map(this.components) + } + + getAllComponents(): QuartzComponent[] { + return Array.from(this.components.values()).map((r) => { + if (typeof r.component === "function") { + return (r.component as QuartzComponentConstructor)(undefined) + } + return r.component as QuartzComponent + }) + } +} + +export const componentRegistry = new ComponentRegistry() + +export function defineComponent( + factory: QuartzComponentConstructor, + manifest: ComponentManifest, +): QuartzComponentConstructor { + ;(factory as any).__quartzComponent = { manifest } + return factory +} diff --git a/quartz/plugins/config.ts b/quartz/plugins/config.ts new file mode 100644 index 000000000..d6b1890a2 --- /dev/null +++ b/quartz/plugins/config.ts @@ -0,0 +1,40 @@ +import { + QuartzTransformerPluginInstance, + QuartzFilterPluginInstance, + QuartzEmitterPluginInstance, +} from "./types" +import { LoadedPlugin } from "./loader/types" + +export interface PluginConfiguration { + transformers: (QuartzTransformerPluginInstance | LoadedPlugin)[] + filters: (QuartzFilterPluginInstance | LoadedPlugin)[] + emitters: (QuartzEmitterPluginInstance | LoadedPlugin)[] +} + +export function isLoadedPlugin(plugin: unknown): plugin is LoadedPlugin { + return ( + typeof plugin === "object" && + plugin !== null && + "plugin" in plugin && + "manifest" in plugin && + "type" in plugin && + typeof (plugin as LoadedPlugin).plugin === "function" + ) +} + +export function getPluginInstance( + plugin: + | QuartzTransformerPluginInstance + | QuartzFilterPluginInstance + | QuartzEmitterPluginInstance + | LoadedPlugin, + options?: T, +): QuartzTransformerPluginInstance | QuartzFilterPluginInstance | QuartzEmitterPluginInstance { + if (isLoadedPlugin(plugin)) { + const factory = plugin.plugin as ( + opts?: T, + ) => QuartzTransformerPluginInstance | QuartzFilterPluginInstance | QuartzEmitterPluginInstance + return factory(options) + } + return plugin +} diff --git a/quartz/plugins/emitters/componentResources.ts b/quartz/plugins/emitters/componentResources.ts index 9c5ee186f..8ceaa046e 100644 --- a/quartz/plugins/emitters/componentResources.ts +++ b/quartz/plugins/emitters/componentResources.ts @@ -9,6 +9,7 @@ import styles from "../../styles/custom.scss" import popoverStyle from "../../components/styles/popover.scss" import { BuildCtx } from "../../util/ctx" import { QuartzComponent } from "../../components/types" +import { componentRegistry } from "../../components/registry" import { googleFontHref, googleFontSubsetHref, @@ -27,6 +28,7 @@ type ComponentResources = { function getComponentResources(ctx: BuildCtx): ComponentResources { const allComponents: Set = new Set() + for (const emitter of ctx.cfg.plugins.emitters) { const components = emitter.getQuartzComponents?.(ctx) ?? [] for (const component of components) { @@ -34,6 +36,10 @@ function getComponentResources(ctx: BuildCtx): ComponentResources { } } + for (const component of componentRegistry.getAllComponents()) { + allComponents.add(component) + } + const componentResources = { css: new Set(), beforeDOMLoaded: new Set(), diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts index c41157c2b..041015054 100644 --- a/quartz/plugins/index.ts +++ b/quartz/plugins/index.ts @@ -45,6 +45,9 @@ export function getStaticResourcesFromPlugins(ctx: BuildCtx) { export * from "./transformers" export * from "./filters" export * from "./emitters" +export * from "./types" +export * from "./config" +export * as PluginLoader from "./loader" declare module "vfile" { // inserted in processors.ts diff --git a/quartz/plugins/loader/componentLoader.ts b/quartz/plugins/loader/componentLoader.ts new file mode 100644 index 000000000..0abe4c901 --- /dev/null +++ b/quartz/plugins/loader/componentLoader.ts @@ -0,0 +1,39 @@ +import { componentRegistry } from "../../components/registry" +import { ComponentManifest, PluginManifest } from "./types" +import { QuartzComponentConstructor } from "../../components/types" + +export async function loadComponentsFromPackage( + packageName: string, + manifest: PluginManifest | null, +): Promise { + if (!manifest?.components) return + + try { + const componentsModule = await import(`${packageName}/components`) + + for (const [exportName, componentManifest] of Object.entries(manifest.components)) { + const component = componentsModule[exportName] + if (!component) { + console.warn( + `Component "${exportName}" declared in manifest but not found in ${packageName}/components`, + ) + continue + } + + const fullName = `${packageName}/${exportName}` + componentRegistry.register( + fullName, + component as QuartzComponentConstructor, + packageName, + componentManifest as ComponentManifest, + ) + } + } catch { + // Components module doesn't exist, that's okay for plugins without components + if (manifest.components && Object.keys(manifest.components).length > 0) { + console.warn( + `Plugin ${packageName} declares components but failed to load them from ${packageName}/components`, + ) + } + } +} diff --git a/quartz/plugins/loader/index.ts b/quartz/plugins/loader/index.ts new file mode 100644 index 000000000..1993cf436 --- /dev/null +++ b/quartz/plugins/loader/index.ts @@ -0,0 +1,293 @@ +import { styleText } from "util" +import { + PluginManifest, + LoadedPlugin, + PluginResolution, + PluginResolutionError, + PluginResolutionOptions, + PluginSpecifier, +} from "./types" +import { QuartzTransformerPlugin, QuartzFilterPlugin, QuartzEmitterPlugin } from "../types" + +const MINIMUM_QUARTZ_VERSION = "4.5.0" + +function satisfiesVersion(required: string | undefined, current: string): boolean { + if (!required) return true + + const parseVersion = (v: string) => { + const parts = v.replace(/^v/, "").split(".") + return { + major: parseInt(parts[0]) || 0, + minor: parseInt(parts[1]) || 0, + patch: parseInt(parts[2]) || 0, + } + } + + const req = parseVersion(required) + const cur = parseVersion(current) + + if (cur.major > req.major) return true + if (cur.major < req.major) return false + if (cur.minor > req.minor) return true + if (cur.minor < req.minor) return false + return cur.patch >= req.patch +} + +async function tryImportPlugin(packageName: string): Promise<{ + module: unknown + manifest: PluginManifest | null +}> { + try { + const module = await import(packageName) + + const manifest: PluginManifest | null = module.manifest ?? null + + return { module, manifest } + } catch (error) { + throw new Error( + `Failed to import package: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} + +function detectPluginType(module: unknown): "transformer" | "filter" | "emitter" | null { + if (!module || typeof module !== "object") return null + + const mod = module as Record + + if (typeof mod.default === "function") { + return null + } + + const hasTransformerProps = ["textTransform", "markdownPlugins", "htmlPlugins"].some( + (key) => key in mod && (typeof mod[key] === "function" || mod[key] === undefined), + ) + + const hasFilterProps = ["shouldPublish"].some( + (key) => key in mod && typeof mod[key] === "function", + ) + + const hasEmitterProps = ["emit"].some((key) => key in mod && typeof mod[key] === "function") + + if (hasEmitterProps) return "emitter" + if (hasFilterProps) return "filter" + if (hasTransformerProps) return "transformer" + + return null +} + +function extractPluginFactory( + module: unknown, + type: "transformer" | "filter" | "emitter", +): QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin | null { + if (!module || typeof module !== "object") return null + + const mod = module as Record + + const factory = mod.default ?? mod[type] ?? mod.plugin ?? null + + if (typeof factory === "function") { + return factory as QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin + } + + return null +} + +async function resolveSinglePlugin( + specifier: PluginSpecifier, + options: PluginResolutionOptions, +): Promise<{ plugin: LoadedPlugin | null; error: PluginResolutionError | null }> { + let packageName: string + let manifest: Partial = {} + + if (typeof specifier === "string") { + packageName = specifier + } else if ("name" in specifier) { + packageName = specifier.name + } else if ("plugin" in specifier) { + const type = specifier.manifest?.category ?? "transformer" + return { + plugin: { + plugin: specifier.plugin as QuartzTransformerPlugin, + manifest: { + name: specifier.manifest?.name ?? "inline-plugin", + displayName: specifier.manifest?.displayName ?? "Inline Plugin", + description: specifier.manifest?.description ?? "Inline plugin instance", + version: specifier.manifest?.version ?? "1.0.0", + category: type as "transformer" | "filter" | "emitter", + ...specifier.manifest, + } as PluginManifest, + type: type as "transformer" | "filter" | "emitter", + source: "inline", + }, + error: null, + } + } else { + return { + plugin: null, + error: { + plugin: "unknown", + message: "Invalid plugin specifier format", + type: "invalid-manifest", + }, + } + } + + try { + const { module: importedModule, manifest: importedManifest } = + await tryImportPlugin(packageName) + + manifest = importedManifest ?? {} + + // Load components if the plugin declares any + if (manifest.components && Object.keys(manifest.components).length > 0) { + const { loadComponentsFromPackage } = await import("./componentLoader") + await loadComponentsFromPackage(packageName, manifest as PluginManifest) + } + + const detectedType = manifest.category ?? detectPluginType(importedModule) + + if (!detectedType) { + return { + plugin: null, + error: { + plugin: packageName, + message: `Could not detect plugin type. Ensure the plugin exports a valid factory function or has a 'category' field in its manifest.`, + type: "invalid-manifest", + }, + } + } + + if ( + manifest.quartzVersion && + !satisfiesVersion(manifest.quartzVersion, options.quartzVersion) + ) { + return { + plugin: null, + error: { + plugin: packageName, + message: `Plugin requires Quartz ${manifest.quartzVersion} but current version is ${options.quartzVersion}`, + type: "version-mismatch", + }, + } + } + + const factory = extractPluginFactory(importedModule, detectedType) + + if (!factory) { + return { + plugin: null, + error: { + plugin: packageName, + message: `Could not find plugin factory in module. Expected 'export default' or '${detectedType}' export.`, + type: "invalid-manifest", + }, + } + } + + const fullManifest: PluginManifest = { + name: manifest.name ?? packageName, + displayName: manifest.displayName ?? packageName, + description: manifest.description ?? "No description provided", + version: manifest.version ?? "1.0.0", + author: manifest.author, + homepage: manifest.homepage, + keywords: manifest.keywords, + category: manifest.category ?? detectedType, + quartzVersion: manifest.quartzVersion, + configSchema: manifest.configSchema, + } + + const loadedPlugin: LoadedPlugin = { + plugin: factory, + manifest: fullManifest, + type: detectedType, + source: packageName, + } + + if (options.verbose) { + console.log( + styleText("green", `✓`) + + ` Loaded ${detectedType} plugin: ${styleText("cyan", fullManifest.displayName)}@${fullManifest.version}`, + ) + } + + return { plugin: loadedPlugin, error: null } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + if (errorMessage.includes("Cannot find module") || errorMessage.includes("MODULE_NOT_FOUND")) { + return { + plugin: null, + error: { + plugin: packageName, + message: `Plugin package not found. Run 'npm install ${packageName}' to install it.`, + type: "not-found", + }, + } + } + + return { + plugin: null, + error: { + plugin: packageName, + message: errorMessage, + type: "import-error", + }, + } + } +} + +export async function resolvePlugins( + specifiers: PluginSpecifier[], + options: PluginResolutionOptions, +): Promise { + const plugins: LoadedPlugin[] = [] + const errors: PluginResolutionError[] = [] + + if (options.verbose) { + console.log(styleText("cyan", `Resolving ${specifiers.length} external plugin(s)...`)) + } + + for (const specifier of specifiers) { + const { plugin, error } = await resolveSinglePlugin(specifier, options) + + if (plugin) { + plugins.push(plugin) + } else if (error) { + errors.push(error) + console.error( + styleText("red", `✗`) + + ` Failed to load plugin: ${styleText("yellow", error.plugin)}\n` + + ` ${error.message}`, + ) + } + } + + if (options.verbose && plugins.length > 0) { + const byType = plugins.reduce( + (acc, p) => { + acc[p.type] = (acc[p.type] || 0) + 1 + return acc + }, + {} as Record, + ) + + console.log( + styleText("cyan", `External plugins loaded:`) + + ` ${byType.transformer ?? 0} transformers, ${byType.filter ?? 0} filters, ${byType.emitter ?? 0} emitters`, + ) + } + + return { plugins, errors } +} + +export function instantiatePlugin( + loadedPlugin: LoadedPlugin, + options?: T, +): ReturnType { + const factory = loadedPlugin.plugin as (opts?: T) => ReturnType + return factory(options) +} + +export { satisfiesVersion, MINIMUM_QUARTZ_VERSION } diff --git a/quartz/plugins/loader/loader.ts b/quartz/plugins/loader/loader.ts new file mode 100644 index 000000000..205bcf31c --- /dev/null +++ b/quartz/plugins/loader/loader.ts @@ -0,0 +1,2 @@ +export * from "./types" +export * from "./index" diff --git a/quartz/plugins/loader/types.ts b/quartz/plugins/loader/types.ts new file mode 100644 index 000000000..1b01d86e1 --- /dev/null +++ b/quartz/plugins/loader/types.ts @@ -0,0 +1,88 @@ +import { QuartzTransformerPlugin, QuartzFilterPlugin, QuartzEmitterPlugin } from "../types" +import { BuildCtx } from "../../util/ctx" + +/** + * Component manifest metadata + */ +export interface ComponentManifest { + name: string + displayName: string + description: string + version: string + quartzVersion?: string + author?: string + homepage?: string +} + +/** + * Plugin manifest metadata for discovery and documentation + */ +export interface PluginManifest { + name: string + displayName: string + description: string + version: string + author?: string + homepage?: string + keywords?: string[] + category?: "transformer" | "filter" | "emitter" + quartzVersion?: string + configSchema?: object + /** Components provided by this plugin */ + components?: Record +} + +/** + * Loaded plugin with metadata + */ +export interface LoadedPlugin { + plugin: QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin + manifest: PluginManifest + type: "transformer" | "filter" | "emitter" + source: string +} + +/** + * Plugin resolution result + */ +export interface PluginResolution { + /** Successfully loaded plugins */ + plugins: LoadedPlugin[] + /** Errors that occurred during resolution */ + errors: PluginResolutionError[] +} + +/** + * Plugin resolution error + */ +export interface PluginResolutionError { + /** Plugin name that failed to load */ + plugin: string + /** Error message */ + message: string + /** Error type */ + type: "not-found" | "invalid-manifest" | "version-mismatch" | "import-error" +} + +/** + * Options for plugin resolution + */ +export interface PluginResolutionOptions { + /** Current Quartz version for compatibility checking */ + quartzVersion: string + /** Build context for logging */ + ctx: BuildCtx + /** Whether to enable verbose logging */ + verbose?: boolean +} + +/** + * Plugin specifier - can be: + * - String package name (e.g., "@quartz-community/my-plugin") + * - Object with name and options (e.g., { name: "@quartz-community/my-plugin", options: {...} }) + * - Inline plugin object (already loaded plugin instance) + */ +export type PluginSpecifier = + | string + | { name: string; options?: unknown } + | { plugin: LoadedPlugin["plugin"]; manifest?: Partial }