feat(plugins): v5 plugin system

This commit is contained in:
saberzero1 2026-02-07 01:33:13 +01:00
parent ec00a40aef
commit 4066ffdace
No known key found for this signature in database
14 changed files with 771 additions and 1 deletions

View File

@ -8,7 +8,21 @@ import {
handleRestore, handleRestore,
handleSync, handleSync,
} from "./cli/handlers.js" } 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" import { version } from "./cli/constants.js"
yargs(hideBin(process.argv)) 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) => { .command("build", "Build Quartz into a bundle of static HTML files", BuildArgv, async (argv) => {
await handleBuild(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) .showHelpOnFail(false)
.help() .help()
.strict() .strict()

View File

@ -1,6 +1,7 @@
import { ValidDateType } from "./components/Date" import { ValidDateType } from "./components/Date"
import { QuartzComponent } from "./components/types" import { QuartzComponent } from "./components/types"
import { ValidLocale } from "./i18n" import { ValidLocale } from "./i18n"
import { PluginSpecifier } from "./plugins/loader/types"
import { PluginTypes } from "./plugins/types" import { PluginTypes } from "./plugins/types"
import { Theme } from "./util/theme" import { Theme } from "./util/theme"
@ -88,6 +89,7 @@ export interface GlobalConfiguration {
export interface QuartzConfig { export interface QuartzConfig {
configuration: GlobalConfiguration configuration: GlobalConfiguration
plugins: PluginTypes plugins: PluginTypes
externalPlugins?: PluginSpecifier[]
} }
export interface FullPageLayout { export interface FullPageLayout {

View File

@ -106,3 +106,30 @@ export const BuildArgv = {
describe: "how many threads to use to parse notes", 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",
},
}

View 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!"))
}

View 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
}

View File

@ -24,6 +24,11 @@ import Comments from "./Comments"
import Flex from "./Flex" import Flex from "./Flex"
import ConditionalRender from "./ConditionalRender" 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 { export {
ArticleTitle, ArticleTitle,
Content, Content,

View 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
View 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
}

View File

@ -9,6 +9,7 @@ import styles from "../../styles/custom.scss"
import popoverStyle from "../../components/styles/popover.scss" import popoverStyle from "../../components/styles/popover.scss"
import { BuildCtx } from "../../util/ctx" import { BuildCtx } from "../../util/ctx"
import { QuartzComponent } from "../../components/types" import { QuartzComponent } from "../../components/types"
import { componentRegistry } from "../../components/registry"
import { import {
googleFontHref, googleFontHref,
googleFontSubsetHref, googleFontSubsetHref,
@ -27,6 +28,7 @@ type ComponentResources = {
function getComponentResources(ctx: BuildCtx): ComponentResources { function getComponentResources(ctx: BuildCtx): ComponentResources {
const allComponents: Set<QuartzComponent> = new Set() const allComponents: Set<QuartzComponent> = new Set()
for (const emitter of ctx.cfg.plugins.emitters) { for (const emitter of ctx.cfg.plugins.emitters) {
const components = emitter.getQuartzComponents?.(ctx) ?? [] const components = emitter.getQuartzComponents?.(ctx) ?? []
for (const component of components) { 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 = { const componentResources = {
css: new Set<string>(), css: new Set<string>(),
beforeDOMLoaded: new Set<string>(), beforeDOMLoaded: new Set<string>(),

View File

@ -45,6 +45,9 @@ export function getStaticResourcesFromPlugins(ctx: BuildCtx) {
export * from "./transformers" export * from "./transformers"
export * from "./filters" export * from "./filters"
export * from "./emitters" export * from "./emitters"
export * from "./types"
export * from "./config"
export * as PluginLoader from "./loader"
declare module "vfile" { declare module "vfile" {
// inserted in processors.ts // inserted in processors.ts

View 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`,
)
}
}
}

View 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 }

View File

@ -0,0 +1,2 @@
export * from "./types"
export * from "./index"

View 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> }