mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-21 21:45:42 -05:00
feat(plugins): v5 plugin system
This commit is contained in:
parent
ec00a40aef
commit
4066ffdace
@ -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 <subcommand>",
|
||||
"Manage Quartz plugins",
|
||||
(yargs) => {
|
||||
return yargs
|
||||
.command(
|
||||
"install <packages..>",
|
||||
"Install Quartz plugins from npm",
|
||||
PluginInstallArgv,
|
||||
async (argv) => {
|
||||
await handlePluginInstall(argv.packages)
|
||||
},
|
||||
)
|
||||
.command(
|
||||
"uninstall <packages..>",
|
||||
"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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
130
quartz/cli/plugin-handlers.js
Normal file
130
quartz/cli/plugin-handlers.js
Normal file
@ -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> [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 <package-name>")
|
||||
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> [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!"))
|
||||
}
|
||||
23
quartz/components/external.ts
Normal file
23
quartz/components/external.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { componentRegistry } from "./registry"
|
||||
import { QuartzComponent, QuartzComponentConstructor } from "./types"
|
||||
|
||||
export function External<Options extends object | undefined>(
|
||||
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>)(options as Options)
|
||||
}
|
||||
|
||||
return component as QuartzComponent
|
||||
}
|
||||
@ -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,
|
||||
|
||||
60
quartz/components/registry.ts
Normal file
60
quartz/components/registry.ts
Normal file
@ -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<string, RegisteredComponent>()
|
||||
|
||||
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<string, RegisteredComponent> {
|
||||
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<Options extends object | undefined = undefined>(
|
||||
factory: QuartzComponentConstructor<Options>,
|
||||
manifest: ComponentManifest,
|
||||
): QuartzComponentConstructor<Options> {
|
||||
;(factory as any).__quartzComponent = { manifest }
|
||||
return factory
|
||||
}
|
||||
40
quartz/plugins/config.ts
Normal file
40
quartz/plugins/config.ts
Normal file
@ -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<T extends object | undefined>(
|
||||
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
|
||||
}
|
||||
@ -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<QuartzComponent> = 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<string>(),
|
||||
beforeDOMLoaded: new Set<string>(),
|
||||
|
||||
@ -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
|
||||
|
||||
39
quartz/plugins/loader/componentLoader.ts
Normal file
39
quartz/plugins/loader/componentLoader.ts
Normal file
@ -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<void> {
|
||||
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`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
293
quartz/plugins/loader/index.ts
Normal file
293
quartz/plugins/loader/index.ts
Normal file
@ -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<string, unknown>
|
||||
|
||||
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<string, unknown>
|
||||
|
||||
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<PluginManifest> = {}
|
||||
|
||||
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<PluginResolution> {
|
||||
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<string, number>,
|
||||
)
|
||||
|
||||
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<T>(
|
||||
loadedPlugin: LoadedPlugin,
|
||||
options?: T,
|
||||
): ReturnType<typeof loadedPlugin.plugin> {
|
||||
const factory = loadedPlugin.plugin as (opts?: T) => ReturnType<typeof loadedPlugin.plugin>
|
||||
return factory(options)
|
||||
}
|
||||
|
||||
export { satisfiesVersion, MINIMUM_QUARTZ_VERSION }
|
||||
2
quartz/plugins/loader/loader.ts
Normal file
2
quartz/plugins/loader/loader.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./index"
|
||||
88
quartz/plugins/loader/types.ts
Normal file
88
quartz/plugins/loader/types.ts
Normal file
@ -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<string, ComponentManifest>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<PluginManifest> }
|
||||
Loading…
Reference in New Issue
Block a user