import fs from "fs" import path from "path" import YAML from "yaml" import { styleText } from "util" import { QuartzConfig, GlobalConfiguration, FullPageLayout } from "../../cfg" import { QuartzComponent, QuartzComponentConstructor } from "../../components/types" import { PluginTypes } from "../types" import { PluginManifest, PluginJsonEntry, QuartzPluginsJson, LayoutConfig, PluginLayoutDeclaration, FlexGroupConfig, } from "./types" import { parsePluginSource, installPlugin, installNativeDeps, getPluginEntryPoint, toFileUrl, isLocalSource, } from "./gitLoader" import { loadComponentsFromPackage } from "./componentLoader" import { loadFramesFromPackage } from "./frameLoader" import { componentRegistry } from "../../components/registry" import { getCondition } from "./conditions" const CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.yaml") const DEFAULT_CONFIG_YAML_PATH = path.join(process.cwd(), "quartz.config.default.yaml") const LEGACY_PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.json") const LEGACY_DEFAULT_PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.default.json") function resolveConfigPath(): string { if (fs.existsSync(CONFIG_YAML_PATH)) return CONFIG_YAML_PATH if (fs.existsSync(LEGACY_PLUGINS_JSON_PATH)) return LEGACY_PLUGINS_JSON_PATH if (fs.existsSync(DEFAULT_CONFIG_YAML_PATH)) return DEFAULT_CONFIG_YAML_PATH if (fs.existsSync(LEGACY_DEFAULT_PLUGINS_JSON_PATH)) return LEGACY_DEFAULT_PLUGINS_JSON_PATH return CONFIG_YAML_PATH } function readPluginsJson(): QuartzPluginsJson | null { const configPath = resolveConfigPath() if (!fs.existsSync(configPath)) { return null } const raw = fs.readFileSync(configPath, "utf-8") if (configPath.endsWith(".yaml") || configPath.endsWith(".yml")) { return YAML.parse(raw) as QuartzPluginsJson } return JSON.parse(raw) as QuartzPluginsJson } function extractPluginName(source: string): string { // Local file paths: use directory basename if (isLocalSource(source)) { return path.basename(source.replace(/[\/]+$/, "")) } if (source.startsWith("github:")) { const withoutPrefix = source.replace("github:", "") const [repoPath] = withoutPrefix.split("#") const parts = repoPath.split("/") return parts[parts.length - 1] } if (source.startsWith("git+") || source.startsWith("https://")) { const url = source.replace("git+", "") const match = url.match(/\/([^/]+?)(?:\.git)?(?:#|$)/) return match?.[1] ?? source } return source } interface DependencyValidationResult { errors: string[] warnings: string[] } function validateDependencies( entries: PluginJsonEntry[], manifests: Map, ): DependencyValidationResult { const errors: string[] = [] const warnings: string[] = [] const sourceToEntry = new Map() const nameToSource = new Map() for (const entry of entries) { sourceToEntry.set(entry.source, entry) nameToSource.set(extractPluginName(entry.source), entry.source) } for (const entry of entries) { if (!entry.enabled) continue const manifest = manifests.get(entry.source) if (!manifest?.dependencies?.length) continue const pluginName = manifest.displayName || extractPluginName(entry.source) const pluginOrder = entry.order ?? manifest.defaultOrder ?? 50 for (const dep of manifest.dependencies) { const depEntry = sourceToEntry.get(dep) const depName = extractPluginName(dep) if (!depEntry) { errors.push( `Plugin "${pluginName}" requires "${depName}". Run: npx quartz plugin add ${dep}`, ) continue } if (!depEntry.enabled) { warnings.push( `Plugin "${pluginName}" depends on "${depName}" which is disabled. "${pluginName}" may not function correctly.`, ) } const depManifest = manifests.get(dep) const depOrder = depEntry.order ?? depManifest?.defaultOrder ?? 50 if (pluginOrder < depOrder) { errors.push( `Plugin "${pluginName}" (order: ${pluginOrder}) depends on "${depName}" (order: ${depOrder}), ` + `but "${pluginName}" is configured to run first. Either increase "${pluginName}"'s order above ${depOrder} ` + `or decrease "${depName}"'s order below ${pluginOrder}.`, ) } } } // Circular dependency detection const graph = new Map() for (const entry of entries) { const manifest = manifests.get(entry.source) if (manifest?.dependencies?.length) { graph.set(entry.source, manifest.dependencies) } } const visited = new Set() const inStack = new Set() function detectCycle(node: string, pathSoFar: string[]): string[] | null { if (inStack.has(node)) { const cycleStart = pathSoFar.indexOf(node) return pathSoFar.slice(cycleStart).concat(node) } if (visited.has(node)) return null visited.add(node) inStack.add(node) for (const dep of graph.get(node) ?? []) { const cycle = detectCycle(dep, [...pathSoFar, node]) if (cycle) return cycle } inStack.delete(node) return null } for (const node of graph.keys()) { const cycle = detectCycle(node, []) if (cycle) { const names = cycle.map(extractPluginName) errors.push(`Circular dependency detected: ${names.join(" → ")}`) break } } return { errors, warnings } } async function resolvePluginManifest(source: string): Promise { try { const gitSpec = parsePluginSource(source) const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) const module = await import(toFileUrl(entryPoint)) return module.manifest ?? null } catch { return null } } async function readManifestFromPackageJson(source: string): Promise { try { const gitSpec = parsePluginSource(source) const pluginDir = path.join(process.cwd(), ".quartz", "plugins", gitSpec.name) const pkgPath = path.join(pluginDir, "package.json") if (!fs.existsSync(pkgPath)) return null const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) if (!pkg.quartz) return null const q = pkg.quartz return { name: q.name ?? gitSpec.name, displayName: q.displayName ?? q.name ?? gitSpec.name, description: q.description ?? pkg.description ?? "No description", version: q.version ?? pkg.version ?? "1.0.0", author: q.author ?? pkg.author, homepage: q.homepage ?? pkg.homepage, category: q.category, quartzVersion: q.quartzVersion, dependencies: q.dependencies, defaultOrder: q.defaultOrder, defaultEnabled: q.defaultEnabled, defaultOptions: q.defaultOptions, configSchema: q.configSchema, components: q.components, frames: q.frames, } } catch { return null } } async function getManifest(source: string): Promise { // Try package.json quartz field first (preferred), then fall back to manifest.ts export return (await readManifestFromPackageJson(source)) ?? (await resolvePluginManifest(source)) } export async function loadQuartzConfig( configOverrides?: Partial, ): Promise { const json = readPluginsJson() if (!json) { // Fallback: import old-style config directly const oldConfig = await import("../../../quartz") return oldConfig.default } const configuration = { ...(json.configuration as unknown as GlobalConfiguration), ...configOverrides, } const enabledEntries = json.plugins.filter((e) => e.enabled) const manifests = new Map() // Ensure all plugins are installed and collect native deps const allNativeDeps = new Map>() for (const entry of enabledEntries) { try { const gitSpec = parsePluginSource(entry.source) const result = await installPlugin(gitSpec, { verbose: false }) if (result.nativeDeps.size > 0) { allNativeDeps.set(gitSpec.name, result.nativeDeps) } } catch (err) { console.error( styleText("red", `✗`) + ` Failed to install plugin: ${styleText("yellow", entry.source)}\n` + ` ${err instanceof Error ? err.message : String(err)}`, ) } } if (allNativeDeps.size > 0) { installNativeDeps(allNativeDeps, { verbose: false }) } // Collect manifests (requires native deps to be installed first) for (const entry of enabledEntries) { try { const manifest = await getManifest(entry.source) if (manifest) { manifests.set(entry.source, manifest) } } catch (err) { console.error( styleText("red", `✗`) + ` Failed to load manifest: ${styleText("yellow", entry.source)}\n` + ` ${err instanceof Error ? err.message : String(err)}`, ) } } // Validate dependencies const validation = validateDependencies(enabledEntries, manifests) for (const warning of validation.warnings) { console.warn(styleText("yellow", `⚠`) + ` ${warning}`) } if (validation.errors.length > 0) { for (const error of validation.errors) { console.error(styleText("red", `✗`) + ` ${error}`) } throw new Error( `Plugin dependency validation failed with ${validation.errors.length} error(s). See above for details.`, ) } // Categorize and sort plugins const transformers: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = [] const filters: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = [] const emitters: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = [] const pageTypes: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = [] for (const entry of enabledEntries) { const manifest = manifests.get(entry.source) const category = manifest?.category // Resolve processing categories: for array categories (e.g. ["transformer", "pageType", "component"]), // push the plugin into ALL matching processing category buckets. // "component" is handled separately via loadComponentsFromPackage during instantiation. const processingCategories = ["transformer", "filter", "emitter", "pageType"] as const const categoryMap: Record = { transformer: transformers, filter: filters, emitter: emitters, pageType: pageTypes, } const categories = Array.isArray(category) ? category : category ? [category] : [] const matchedProcessing = categories.filter((c) => (processingCategories as readonly string[]).includes(c), ) if (matchedProcessing.length > 0) { for (const cat of matchedProcessing) { categoryMap[cat].push({ entry, manifest }) } } else { const gitSpec = parsePluginSource(entry.source) const isComponentOnly = categories.length > 0 && categories.every((c) => c === "component") if (isComponentOnly) { // Always import the main entry point for component-only plugins. // Some plugins (e.g. Bases view registrations) rely on side effects // in their index module to register functionality. const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) try { const module = await import(toFileUrl(entryPoint)) // If the module exports an init() function, call it with merged options // so component-only plugins can receive user configuration from YAML. if (typeof module.init === "function") { const options = { ...manifest?.defaultOptions, ...entry.options } await module.init(Object.keys(options).length > 0 ? options : undefined) } } catch (e) { // Side-effect import failed — continue with manifest-based loading } 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 { const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) try { const module = await import(toFileUrl(entryPoint)) const detected = detectCategoryFromModule(module) if (detected) { categoryMap[detected].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", `⚠`) + ` Could not determine category for plugin "${extractPluginName(entry.source)}". Skipping.`, ) } } catch { 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) } 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.`, ) } } } } } // Sort by order within each category const sortByOrder = ( a: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }, b: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }, ) => { const orderA = a.entry.order ?? a.manifest?.defaultOrder ?? 50 const orderB = b.entry.order ?? b.manifest?.defaultOrder ?? 50 return orderA - orderB } transformers.sort(sortByOrder) filters.sort(sortByOrder) emitters.sort(sortByOrder) pageTypes.sort(sortByOrder) // Instantiate plugins const instantiate = async ( items: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[], expectedCategory: ProcessingCategory, ) => { const instances = [] for (const { entry, manifest } of items) { try { const gitSpec = parsePluginSource(entry.source) const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) const module = await import(toFileUrl(entryPoint)) 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) { console.warn( styleText("yellow", `⚠`) + ` Plugin "${extractPluginName(entry.source)}" has no factory function for category "${expectedCategory}". Skipping.`, ) continue } const options = { ...manifest?.defaultOptions, ...entry.options } instances.push(factory(Object.keys(options).length > 0 ? options : undefined)) } catch (err) { console.error( styleText("red", `✗`) + ` Failed to instantiate plugin "${extractPluginName(entry.source)}": ${err instanceof Error ? err.message : String(err)}`, ) } } return instances } // Import built-in plugins const builtinPlugins = await import("../index") const builtinTransformers: unknown[] = [] const builtinEmitters = [ builtinPlugins.ComponentResources(), builtinPlugins.Assets(), builtinPlugins.Static(), ] const builtinPageTypes = [builtinPlugins.PageTypes.NotFoundPageType()] const plugins: PluginTypes = { transformers: [...builtinTransformers, ...(await instantiate(transformers, "transformer"))], filters: await instantiate(filters, "filter"), emitters: [...builtinEmitters, ...(await instantiate(emitters, "emitter"))], pageTypes: [...(await instantiate(pageTypes, "pageType")), ...builtinPageTypes], } // Load layout and add PageTypeDispatcher to emitters. // This must happen after plugin instantiation so the component registry is populated. const layout = await loadQuartzLayout() plugins.emitters.push( builtinPlugins.PageTypes.PageTypeDispatcher({ defaults: layout.defaults, byPageType: layout.byPageType, }), ) return { configuration, plugins, } } type ProcessingCategory = "transformer" | "filter" | "emitter" | "pageType" function matchesCategory(factory: Function, expected: ProcessingCategory): boolean { try { const instance = factory() if (!instance || typeof instance !== "object") return false switch (expected) { case "pageType": return "match" in instance && "body" in instance && "layout" in instance case "emitter": return "emit" in instance case "filter": return "shouldPublish" in instance case "transformer": return ( "textTransform" in instance || "markdownPlugins" in instance || "htmlPlugins" in instance ) } } catch { return false } } function findFactory( module: Record, expectedCategory: ProcessingCategory, ): Function | null { if ( typeof module.default === "function" && matchesCategory(module.default as Function, expectedCategory) ) { return module.default as Function } if ( typeof module.plugin === "function" && matchesCategory(module.plugin as Function, expectedCategory) ) { return module.plugin as Function } for (const [, value] of Object.entries(module)) { if (typeof value === "function" && matchesCategory(value as Function, expectedCategory)) { return value as Function } } return null } function detectCategoryFromModule(module: unknown): ProcessingCategory | null { if (!module || typeof module !== "object") return null const mod = module as Record if (typeof mod.default === "function") { // Try to instantiate and inspect try { const instance = (mod.default as Function)() if (instance && typeof instance === "object") { if ("match" in instance && "body" in instance && "layout" in instance) return "pageType" if ("emit" in instance) return "emitter" if ("shouldPublish" in instance) return "filter" if ( "textTransform" in instance || "markdownPlugins" in instance || "htmlPlugins" in instance ) return "transformer" } } catch { // Couldn't instantiate, skip detection } } return null } export async function loadQuartzLayout(layoutOverrides?: { defaults?: Partial byPageType?: Record> }): Promise<{ defaults: Partial byPageType: Record> }> { const json = readPluginsJson() if (!json) { // Fallback: import old-style layout directly const oldLayout = await import("../../../quartz") return oldLayout.layout } const enabledWithLayout = json.plugins.filter((e) => e.enabled && e.layout) const layoutConfig = json.layout ?? {} // Build default layout for all page types const defaultLayout = buildLayoutForEntries(enabledWithLayout, layoutConfig) // Build per-page-type overrides const byPageType: Record> = {} if (layoutConfig.byPageType) { for (const [pageType, override] of Object.entries(layoutConfig.byPageType)) { let filteredEntries = enabledWithLayout // Apply exclusions if (override.exclude?.length) { filteredEntries = filteredEntries.filter((e) => { const name = extractPluginName(e.source) return !override.exclude!.includes(name) }) } const ptLayout = buildLayoutForEntries(filteredEntries, layoutConfig) // Apply position overrides (empty array = clear position) if (override.positions) { for (const [pos, components] of Object.entries(override.positions)) { if (Array.isArray(components) && components.length === 0) { const key = pos as keyof Pick< FullPageLayout, "left" | "right" | "beforeBody" | "afterBody" > if (key in ptLayout) { ;(ptLayout as Record)[key] = [] } } } } // Apply frame template override if (override.template) { ptLayout.frame = override.template } byPageType[pageType] = ptLayout } } // Add Head (built-in) and Footer (plugin) const HeadModule = await import("../../components/Head") const head = HeadModule.default() // Find footer from component registry (loaded during plugin instantiation) const footerEntry = json.plugins.find( (e) => e.enabled && extractPluginName(e.source) === "footer", ) let footer: QuartzComponent | undefined if (footerEntry) { // Try registry lookup: plugin name ("footer") or export name ("Footer") const footerReg = componentRegistry.get("footer") ?? componentRegistry.get("Footer") if (footerReg) { if (typeof footerReg.component === "function" && !("displayName" in footerReg.component)) { // It's a constructor — use registry cache for consistent instances const opts = { ...footerEntry.options } footer = componentRegistry.instantiate( footerReg.component as QuartzComponentConstructor, Object.keys(opts).length > 0 ? opts : undefined, ) } else { footer = footerReg.component as QuartzComponent } } } // Apply structural defaults defaultLayout.head = head defaultLayout.header = defaultLayout.header ?? [] if (footer) { defaultLayout.footer = footer } // Ensure all byPageType entries inherit structural slots for (const pageType of Object.keys(byPageType)) { const pt = byPageType[pageType] if (!pt.head) pt.head = head if (!pt.header) pt.header = [] if (footer && !pt.footer) pt.footer = footer } const mergedDefaults = { ...defaultLayout, ...layoutOverrides?.defaults } const mergedByPageType = { ...byPageType } if (layoutOverrides?.byPageType) { for (const [pageType, overrideLayout] of Object.entries(layoutOverrides.byPageType)) { mergedByPageType[pageType] = { ...mergedByPageType[pageType], ...overrideLayout } } } return { defaults: mergedDefaults, byPageType: mergedByPageType } } function buildLayoutForEntries( entries: PluginJsonEntry[], layoutConfig: LayoutConfig, ): Partial { const positions: Record< string, { component: QuartzComponent priority: number group?: string groupOptions?: PluginLayoutDeclaration["groupOptions"] }[] > = { left: [], right: [], beforeBody: [], afterBody: [], } for (const entry of entries) { if (!entry.layout) continue const layout = entry.layout const name = extractPluginName(entry.source) // Look up component from registry const registered = componentRegistry.get(name) ?? componentRegistry.get(`${entry.source}/${name}`) if (!registered) { // Try common naming patterns const pascalName = name .split("-") .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) .join("") const altRegistered = componentRegistry.get(pascalName) if (!altRegistered) continue } const reg = registered ?? componentRegistry.get( name .split("-") .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) .join(""), ) if (!reg) continue let component: QuartzComponent if (typeof reg.component === "function" && !("displayName" in reg.component)) { // It's a constructor — use registry cache to avoid duplicate instances // (and duplicate afterDOMLoaded scripts) across page-type layouts const opts = { ...entry.options } const optsArg = Object.keys(opts).length > 0 ? opts : undefined component = componentRegistry.instantiate( reg.component as QuartzComponentConstructor, optsArg, ) } else { component = reg.component as QuartzComponent } // Apply display modifier if (layout.display && layout.display !== "all") { component = applyDisplayWrapper(component, layout.display) } // Apply condition if (layout.condition) { component = applyConditionWrapper(component, layout.condition) } const posArray = positions[layout.position] if (posArray) { posArray.push({ component, priority: layout.priority, group: layout.group, groupOptions: layout.groupOptions, }) } } // Sort by priority and resolve groups const result: Partial = {} for (const [position, items] of Object.entries(positions)) { items.sort((a, b) => a.priority - b.priority) const resolved = resolveGroups(items, layoutConfig.groups ?? {}) const key = position as keyof Pick< FullPageLayout, "left" | "right" | "beforeBody" | "afterBody" > ;(result as Record)[key] = resolved } return result } function resolveGroups( items: { component: QuartzComponent priority: number group?: string groupOptions?: PluginLayoutDeclaration["groupOptions"] }[], groups: Record, ): QuartzComponent[] { // Collect grouped components and track the effective priority for each group. // Effective priority = explicit group config priority ?? first member's priority. const groupedComponents = new Map< string, { component: QuartzComponent; groupOptions?: PluginLayoutDeclaration["groupOptions"] }[] >() const groupPriority = new Map() for (const item of items) { if (item.group) { if (!groupedComponents.has(item.group)) { groupedComponents.set(item.group, []) // Use explicit group priority from config if set, otherwise fall back to first member's priority const groupConfig = groups[item.group] groupPriority.set(item.group, groupConfig?.priority ?? item.priority) } groupedComponents.get(item.group)!.push({ component: item.component, groupOptions: item.groupOptions, }) } } // Build a unified list of renderable entries (ungrouped components + flex groups), // each with a priority, so we can sort them together. type RenderEntry = { priority: number; component: QuartzComponent } const entries: RenderEntry[] = [] const processedGroups = new Set() for (const item of items) { if (item.group) { // Only emit the flex group once (on first encounter) if (processedGroups.has(item.group)) continue processedGroups.add(item.group) const members = groupedComponents.get(item.group)! const groupConfig = groups[item.group] ?? {} const flexComponents = members.map((m) => ({ Component: m.component, grow: m.groupOptions?.grow, shrink: m.groupOptions?.shrink, basis: m.groupOptions?.basis, order: m.groupOptions?.order, align: m.groupOptions?.align, justify: m.groupOptions?.justify, })) // Dynamically import Flex to avoid circular dependencies const FlexModule = require("../../components/Flex") const Flex = FlexModule.default as Function const flexComponent = Flex({ components: flexComponents, direction: groupConfig.direction ?? "row", wrap: groupConfig.wrap, gap: groupConfig.gap ?? "1rem", }) as QuartzComponent entries.push({ priority: groupPriority.get(item.group)!, component: flexComponent }) } else { entries.push({ priority: item.priority, component: item.component }) } } // Stable sort by priority (items already arrive sorted, so equal priorities preserve order) entries.sort((a, b) => a.priority - b.priority) return entries.map((e) => e.component) } function applyDisplayWrapper( component: QuartzComponent, display: "mobile-only" | "desktop-only", ): QuartzComponent { if (display === "mobile-only") { const MobileOnly = require("../../components/MobileOnly").default as Function return MobileOnly(component) as QuartzComponent } else { const DesktopOnly = require("../../components/DesktopOnly").default as Function return DesktopOnly(component) as QuartzComponent } } function applyConditionWrapper(component: QuartzComponent, conditionName: string): QuartzComponent { const predicate = getCondition(conditionName) if (!predicate) { console.warn( styleText("yellow", `⚠`) + ` Unknown condition "${conditionName}". Component will always render.`, ) return component } const ConditionalRender = require("../../components/ConditionalRender").default as Function return ConditionalRender({ component, condition: predicate, }) as QuartzComponent }