Initial import

This commit is contained in:
Admin
2026-07-11 17:08:37 +00:00
commit 03e5f3452b
303 changed files with 43365 additions and 0 deletions
@@ -0,0 +1,71 @@
import { componentRegistry } from "../../components/registry"
import { ComponentManifest, PluginManifest } from "./types"
import { QuartzComponentConstructor } from "../../components/types"
import { getPluginSubpathEntry, toFileUrl } from "./gitLoader"
export async function loadComponentsFromPackage(
pluginName: string,
manifest: PluginManifest | null,
): Promise<void> {
if (!manifest?.components) return
try {
const componentsPath = getPluginSubpathEntry(pluginName, "./components")
let componentsModule: Record<string, unknown>
if (componentsPath) {
componentsModule = await import(toFileUrl(componentsPath))
} else {
componentsModule = await import(`${pluginName}/components`)
}
const componentEntries = Object.entries(manifest.components)
for (const [exportName, componentManifest] of componentEntries) {
const component = componentsModule[exportName]
if (!component) {
console.warn(
`Component "${exportName}" declared in manifest but not found in ${pluginName}/components`,
)
continue
}
// Register under the fully-qualified key (pluginName/exportName)
componentRegistry.register(
`${pluginName}/${exportName}`,
component as QuartzComponentConstructor,
pluginName,
componentManifest as ComponentManifest,
)
// Also register under just the export name (e.g. "Footer", "NotePropertiesComponent")
// so buildLayoutForEntries can find it via PascalCase conversion of plugin name
if (!componentRegistry.get(exportName)) {
componentRegistry.register(
exportName,
component as QuartzComponentConstructor,
pluginName,
componentManifest as ComponentManifest,
)
}
}
// If plugin has exactly one component, also register under just the plugin name
// (e.g. "footer", "note-properties") for direct kebab-case lookup
if (componentEntries.length === 1) {
const [exportName] = componentEntries[0]
const component = componentsModule[exportName]
if (component && !componentRegistry.get(pluginName)) {
componentRegistry.register(
pluginName,
component as QuartzComponentConstructor,
pluginName,
componentEntries[0][1] as ComponentManifest,
)
}
}
} catch {
if (manifest.components && Object.keys(manifest.components).length > 0) {
console.warn(`Plugin "${pluginName}" declares components but failed to load them`)
}
}
}
@@ -0,0 +1,33 @@
import { QuartzComponentProps } from "../../components/types"
export type ConditionPredicate = (props: QuartzComponentProps) => boolean
const builtinConditions: Record<string, ConditionPredicate> = {
"not-index": (props) => props.fileData.slug !== "index",
"has-tags": (props) => {
const tags = props.fileData.frontmatter?.tags
return Array.isArray(tags) && tags.length > 0
},
"has-backlinks": (props) => {
const backlinks = (props.fileData as Record<string, unknown>).backlinks
return Array.isArray(backlinks) && backlinks.length > 0
},
"has-toc": (props) => {
const toc = (props.fileData as Record<string, unknown>).toc
return Array.isArray(toc) && toc.length > 0
},
}
const customConditions = new Map<string, ConditionPredicate>()
export function registerCondition(name: string, predicate: ConditionPredicate): void {
customConditions.set(name, predicate)
}
export function getCondition(name: string): ConditionPredicate | undefined {
return customConditions.get(name) ?? builtinConditions[name]
}
export function getAllConditionNames(): string[] {
return [...Object.keys(builtinConditions), ...customConditions.keys()]
}
@@ -0,0 +1,943 @@
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,
PluginSource,
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: PluginSource): string {
if (typeof source === "object" && source !== null) {
if (source.name) return source.name
return extractPluginName(source.repo)
}
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
}
function formatSourceDisplay(source: PluginSource): string {
if (typeof source === "string") return source
const parts = [source.repo]
if (source.subdir) parts.push(`(subdir: ${source.subdir})`)
if (source.ref) parts.push(`(ref: ${source.ref})`)
return parts.join(" ")
}
function sourceKey(source: PluginSource): string {
if (typeof source === "string") return source
return JSON.stringify(source)
}
interface DependencyValidationResult {
errors: string[]
warnings: string[]
}
function validateDependencies(
entries: PluginJsonEntry[],
manifests: Map<string, PluginManifest>,
): DependencyValidationResult {
const errors: string[] = []
const warnings: string[] = []
const sourceToEntry = new Map<string, PluginJsonEntry>()
const nameToSource = new Map<string, string>()
for (const entry of entries) {
sourceToEntry.set(sourceKey(entry.source), entry)
nameToSource.set(extractPluginName(entry.source), sourceKey(entry.source))
}
for (const entry of entries) {
if (!entry.enabled) continue
const manifest = manifests.get(sourceKey(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}.`,
)
}
}
}
const graph = new Map<string, string[]>()
for (const entry of entries) {
const manifest = manifests.get(sourceKey(entry.source))
if (manifest?.dependencies?.length) {
graph.set(sourceKey(entry.source), manifest.dependencies)
}
}
const visited = new Set<string>()
const inStack = new Set<string>()
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: PluginSource): Promise<PluginManifest | null> {
try {
const gitSpec = parsePluginSource(source)
const entryPoint = getPluginEntryPoint(gitSpec.name)
const module = await import(toFileUrl(entryPoint))
return module.manifest ?? null
} catch {
return null
}
}
async function readManifestFromPackageJson(source: PluginSource): Promise<PluginManifest | null> {
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: PluginSource): Promise<PluginManifest | null> {
// 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<GlobalConfiguration>,
): Promise<QuartzConfig> {
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<string, PluginManifest>()
// Ensure all plugins are installed and collect native deps
const allNativeDeps = new Map<string, Map<string, string>>()
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", formatSourceDisplay(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(sourceKey(entry.source), manifest)
}
} catch (err) {
console.error(
styleText("red", ``) +
` Failed to load manifest: ${styleText("yellow", formatSourceDisplay(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(sourceKey(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<string, typeof transformers> = {
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)
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 initOverrides = componentRegistry.getOptionOverrides(gitSpec.name)
const options = { ...manifest?.defaultOptions, ...entry.options, ...initOverrides }
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)
}
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
await loadFramesFromPackage(gitSpec.name, manifest)
}
} else {
const entryPoint = getPluginEntryPoint(gitSpec.name)
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)
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
await loadFramesFromPackage(gitSpec.name, manifest)
}
} 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)
}
if (hasFrames) {
await loadFramesFromPackage(gitSpec.name, manifest)
}
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)
const module = await import(toFileUrl(entryPoint))
if (manifest?.components && Object.keys(manifest.components).length > 0) {
await loadComponentsFromPackage(gitSpec.name, manifest)
}
if (manifest?.frames && Object.keys(manifest.frames).length > 0) {
await loadFramesFromPackage(gitSpec.name, manifest)
}
const factory = findFactory(module, expectedCategory)
if (!factory) {
console.warn(
styleText("yellow", ``) +
` Plugin "${extractPluginName(entry.source)}" has no factory function for category "${expectedCategory}". ` +
`Ensure your plugin exports a default function, a "plugin" named export, or a single exported function.`,
)
continue
}
const pluginOverrides = componentRegistry.getOptionOverrides(gitSpec.name)
const options = { ...manifest?.defaultOptions, ...entry.options, ...pluginOverrides }
const instance = factory(Object.keys(options).length > 0 ? options : undefined)
if (!instance || typeof instance !== "object") {
console.warn(
styleText("yellow", ``) +
` Plugin "${extractPluginName(entry.source)}" factory did not return a valid plugin instance. Skipping.`,
)
continue
}
if (!validateCategory(instance, expectedCategory)) {
console.warn(
styleText("yellow", ``) +
` Plugin "${extractPluginName(entry.source)}" declares category "${expectedCategory}" ` +
`but its factory returned an instance missing the required methods. Skipping.`,
)
continue
}
instances.push(instance)
} 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"
/**
* Validate that a plugin instance has the required methods for its declared category.
* Called AFTER real instantiation — never used to probe/discover category.
*/
function validateCategory(
instance: Record<string, unknown>,
expected: ProcessingCategory,
): boolean {
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
)
}
}
/**
* Find the factory function from a plugin module by export convention.
* Prefers `default` export, then `plugin` named export, then the sole exported function.
* For multi-export modules with an expectedCategory, probes candidate functions to find
* the one matching the category shape.
*/
function findFactory(
module: Record<string, unknown>,
expectedCategory?: ProcessingCategory,
): Function | null {
if (typeof module.default === "function") {
return module.default as Function
}
if (typeof module.plugin === "function") {
return module.plugin as Function
}
const exportedFunctions = Object.entries(module).filter(
([key, value]) => typeof value === "function" && !key.startsWith("__"),
)
if (exportedFunctions.length === 1) {
return exportedFunctions[0][1] as Function
}
// Multiple exports: probe candidates to find the one matching the expected category.
// This is the only code path that calls factory() for discovery, and only when
// there is no default/plugin export and multiple functions are exported.
if (exportedFunctions.length > 1 && expectedCategory) {
for (const [, fn] of exportedFunctions) {
try {
const instance = (fn as Function)()
if (
instance &&
typeof instance === "object" &&
validateCategory(instance, expectedCategory)
) {
return fn as Function
}
} catch {
// This export doesn't work without args — skip it
}
}
}
return null
}
function detectCategoryFromModule(module: unknown): ProcessingCategory | null {
if (!module || typeof module !== "object") return null
const mod = module as Record<string, unknown>
// Prefer static category marker on the factory if available
const factory = findFactory(mod as Record<string, unknown>)
if (factory && "quartzCategory" in factory) {
const cat = (factory as Record<string, unknown>).quartzCategory
if (cat === "transformer" || cat === "filter" || cat === "emitter" || cat === "pageType") {
return cat
}
}
// Fallback: try instantiating with no args and inspect the result.
// This may fail for plugins that do I/O or require options during construction.
if (typeof factory === "function") {
try {
const instance = factory()
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 {
// Factory requires arguments or does I/O — cannot detect category by probing.
// Plugin should declare category in package.json quartz.category field.
}
}
return null
}
export async function loadQuartzLayout(layoutOverrides?: {
defaults?: Partial<FullPageLayout>
byPageType?: Record<string, Partial<FullPageLayout>>
}): Promise<{
defaults: Partial<FullPageLayout>
byPageType: Record<string, Partial<FullPageLayout>>
}> {
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<string, Partial<FullPageLayout>> = {}
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<string, unknown>)[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 footerOverrides = componentRegistry.getOptionOverrides("footer")
const opts = { ...footerEntry.options, ...footerOverrides }
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<FullPageLayout> {
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(`${formatSourceDisplay(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 tsOverrides = componentRegistry.getOptionOverrides(name)
const opts = { ...entry.options, ...tsOverrides }
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<FullPageLayout> = {}
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<string, QuartzComponent[]>)[key] = resolved
}
return result
}
function resolveGroups(
items: {
component: QuartzComponent
priority: number
group?: string
groupOptions?: PluginLayoutDeclaration["groupOptions"]
}[],
groups: Record<string, FlexGroupConfig>,
): 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<string, number>()
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)
}
const groupMembers = groupedComponents.get(item.group)
if (groupMembers) {
groupMembers.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<string>()
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)
if (!members) continue
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) ?? 50, 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
}
@@ -0,0 +1,47 @@
import { frameRegistry } from "../../components/frames/registry"
import { PluginManifest } from "./types"
import { PageFrame } from "../../components/frames/types"
import { getPluginSubpathEntry, toFileUrl } from "./gitLoader"
export async function loadFramesFromPackage(
pluginName: string,
manifest: PluginManifest | null,
): Promise<void> {
if (!manifest?.frames) return
try {
const framesPath = getPluginSubpathEntry(pluginName, "./frames")
let framesModule: Record<string, unknown>
if (framesPath) {
framesModule = await import(toFileUrl(framesPath))
} else {
framesModule = await import(`${pluginName}/frames`)
}
for (const [exportName, _frameMeta] of Object.entries(manifest.frames)) {
const frame = framesModule[exportName]
if (!frame) {
console.warn(
`Frame "${exportName}" declared in manifest but not found in ${pluginName}/frames`,
)
continue
}
const pageFrame = frame as PageFrame
if (!pageFrame.name || typeof pageFrame.render !== "function") {
console.warn(
`Frame "${exportName}" from ${pluginName} is not a valid PageFrame (missing name or render)`,
)
continue
}
// Register under the frame's declared name
frameRegistry.register(pageFrame.name, pageFrame, pluginName)
}
} catch {
if (manifest.frames && Object.keys(manifest.frames).length > 0) {
console.warn(`Plugin "${pluginName}" declares frames but failed to load them`)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,499 @@
import { styleText } from "util"
import {
PluginManifest,
PluginCategory,
LoadedPlugin,
PluginResolution,
PluginResolutionError,
PluginResolutionOptions,
PluginSpecifier,
} from "./types"
import {
QuartzTransformerPlugin,
QuartzFilterPlugin,
QuartzEmitterPlugin,
QuartzPageTypePlugin,
} from "../types"
import {
parsePluginSource,
installPlugin,
getPluginEntryPoint,
toFileUrl,
isLocalSource,
validatePluginExternals,
} from "./gitLoader"
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" | "pageType" | null {
if (!module || typeof module !== "object") return null
const mod = module as Record<string, unknown>
if (typeof mod.default === "function") {
return null
}
const hasPageTypeProps = ["match", "body", "layout"].every((key) => key in mod)
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 (hasPageTypeProps) return "pageType"
if (hasEmitterProps) return "emitter"
if (hasFilterProps) return "filter"
if (hasTransformerProps) return "transformer"
return null
}
function extractPluginFactory(
module: unknown,
type: "transformer" | "filter" | "emitter" | "pageType",
):
| QuartzTransformerPlugin
| QuartzFilterPlugin
| QuartzEmitterPlugin
| QuartzPageTypePlugin
| 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
| QuartzPageTypePlugin
}
return null
}
function isGitSource(source: string): boolean {
// Check if it's a Git-based or local file path source
return (
isLocalSource(source) ||
source.startsWith("github:") ||
source.startsWith("git+") ||
source.startsWith("https://github.com/") ||
source.startsWith("https://gitlab.com/") ||
source.startsWith("https://bitbucket.org/")
)
}
async function resolveSinglePlugin(
specifier: PluginSpecifier,
options: PluginResolutionOptions,
): Promise<{ plugin: LoadedPlugin | null; error: PluginResolutionError | null }> {
let packageName: string
let manifest: Partial<PluginManifest> = {}
let pluginSource = "npm"
if (typeof specifier === "string") {
packageName = specifier
// Check if it's a Git-based source
if (isGitSource(specifier)) {
pluginSource = "git"
}
} else if ("name" in specifier) {
packageName = specifier.name
if (isGitSource(specifier.name)) {
pluginSource = "git"
}
} else if ("plugin" in specifier) {
const rawType = specifier.manifest?.category ?? "transformer"
const type = Array.isArray(rawType) ? rawType[0] : rawType
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: rawType,
...specifier.manifest,
} as PluginManifest,
type,
source: "inline",
},
error: null,
}
} else {
return {
plugin: null,
error: {
plugin: "unknown",
message: "Invalid plugin specifier format",
type: "invalid-manifest",
},
}
}
if (pluginSource === "git") {
try {
const gitSpec = parsePluginSource(packageName)
await installPlugin(gitSpec, { verbose: options.verbose })
const entryPoint = getPluginEntryPoint(gitSpec.name)
// Import the plugin
const module = await import(toFileUrl(entryPoint))
const importedManifest: PluginManifest | null = module.manifest ?? null
validatePluginExternals(gitSpec.name, entryPoint, { verbose: options.verbose })
manifest = importedManifest ?? {}
const categoryOrCategories = manifest.category ?? detectPluginType(module)
if (!categoryOrCategories) {
return {
plugin: null,
error: {
plugin: packageName,
message: "Could not detect plugin type from Git source",
type: "invalid-manifest",
},
}
}
// Normalize to single processing category for factory extraction
const processingCategories = ["transformer", "filter", "emitter", "pageType"] as const
type ProcessingCategory = (typeof processingCategories)[number]
const detectedType: PluginCategory = Array.isArray(categoryOrCategories)
? categoryOrCategories[0]
: categoryOrCategories
const processingType: ProcessingCategory | undefined = Array.isArray(categoryOrCategories)
? (categoryOrCategories.find((c) =>
(processingCategories as readonly string[]).includes(c),
) as ProcessingCategory | undefined)
: (processingCategories as readonly string[]).includes(categoryOrCategories)
? (categoryOrCategories as ProcessingCategory)
: undefined
// Component-only plugins don't have a processing factory
if (!processingType) {
const fullManifest: PluginManifest = {
name: manifest.name ?? gitSpec.name,
displayName: manifest.displayName ?? gitSpec.name,
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,
}
if (options.verbose) {
console.log(
styleText("green", `\u2713`) +
` Loaded ${detectedType} plugin: ${styleText("cyan", fullManifest.displayName)}@${fullManifest.version} ${styleText("gray", `(from ${gitSpec.repo})`)}`,
)
}
return { plugin: null, error: null }
}
const factory = extractPluginFactory(module, processingType)
if (!factory) {
return {
plugin: null,
error: {
plugin: packageName,
message: "Could not find plugin factory in Git source",
type: "invalid-manifest",
},
}
}
const fullManifest: PluginManifest = {
name: manifest.name ?? gitSpec.name,
displayName: manifest.displayName ?? gitSpec.name,
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: gitSpec.local ? `local:${gitSpec.repo}` : `${gitSpec.repo}#${gitSpec.ref}`,
}
if (options.verbose) {
console.log(
styleText("green", ``) +
` Loaded ${detectedType} plugin: ${styleText("cyan", fullManifest.displayName)}@${fullManifest.version} ${styleText("gray", `(from ${gitSpec.repo})`)}`,
)
}
return { plugin: loadedPlugin, error: null }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return {
plugin: null,
error: {
plugin: packageName,
message: `Failed to load Git plugin: ${errorMessage}`,
type: "import-error",
},
}
}
}
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 categoryOrCategories = manifest.category ?? detectPluginType(importedModule)
if (!categoryOrCategories) {
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",
},
}
}
// Normalize to single processing category for factory extraction
const processingCategories = ["transformer", "filter", "emitter", "pageType"] as const
type ProcessingCategory = (typeof processingCategories)[number]
const detectedType: PluginCategory = Array.isArray(categoryOrCategories)
? categoryOrCategories[0]
: categoryOrCategories
const processingType: ProcessingCategory | undefined = Array.isArray(categoryOrCategories)
? (categoryOrCategories.find((c) =>
(processingCategories as readonly string[]).includes(c),
) as ProcessingCategory | undefined)
: (processingCategories as readonly string[]).includes(categoryOrCategories)
? (categoryOrCategories as ProcessingCategory)
: undefined
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",
},
}
}
// Component-only plugins don't have a processing factory
if (!processingType) {
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,
}
if (options.verbose) {
console.log(
styleText("green", `\u2713`) +
` Loaded ${detectedType} plugin: ${styleText("cyan", fullManifest.displayName)}@${fullManifest.version}`,
)
}
return { plugin: null, error: null }
}
const factory = extractPluginFactory(importedModule, processingType)
if (!factory) {
return {
plugin: null,
error: {
plugin: packageName,
message: `Could not find plugin factory in module. Expected 'export default' or '${processingType}' 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, ${byType.pageType ?? 0} pageTypes`,
)
}
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 }
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import { installPlugins, parsePluginSource } from "./gitLoader.js"
import config from "../../../quartz.js"
async function main() {
const quartzConfig: any = config
const externalPlugins = quartzConfig.externalPlugins || []
if (externalPlugins.length === 0) {
console.log("No external plugins to install.")
return
}
console.log(`Installing ${externalPlugins.length} plugin(s) from Git...`)
const specs = externalPlugins.map((source: string) => parsePluginSource(source))
const installed = await installPlugins(specs, { verbose: true })
if (installed.size === externalPlugins.length) {
console.log("✓ All plugins installed successfully")
} else {
console.error(`✗ Only ${installed.size}/${externalPlugins.length} plugins installed`)
process.exit(1)
}
}
main().catch((err) => {
console.error("Failed to install plugins:", err)
process.exit(1)
})
@@ -0,0 +1,4 @@
export * from "./types"
export * from "./index"
export * from "./conditions"
export { loadQuartzConfig, loadQuartzLayout } from "./config-loader"
@@ -0,0 +1,193 @@
import {
QuartzTransformerPlugin,
QuartzFilterPlugin,
QuartzEmitterPlugin,
QuartzPageTypePlugin,
} from "../types"
import { BuildCtx } from "../../util/ctx"
export type PluginCategory = "transformer" | "filter" | "emitter" | "pageType" | "component"
export type LayoutPosition = "left" | "right" | "beforeBody" | "afterBody"
export type LayoutDisplay = "all" | "mobile-only" | "desktop-only"
/**
* Component manifest metadata
*/
export interface ComponentManifest {
name: string
displayName: string
description: string
version: string
quartzVersion?: string
author?: string
homepage?: string
}
/**
* Layout defaults for a component declared in a plugin manifest.
* These are used as fallback values when no user layout config is specified.
*/
export interface ComponentLayoutDefaults {
displayName: string
description?: string
defaultPosition?: LayoutPosition
defaultPriority?: number
}
/**
* Plugin manifest metadata for discovery and documentation.
*
* This corresponds to the `quartz` field in a plugin's `package.json`.
*/
export interface PluginManifest {
name: string
displayName: string
description: string
version: string
author?: string
homepage?: string
keywords?: string[]
category?: PluginCategory | PluginCategory[]
quartzVersion?: string
/** Plugin sources this plugin depends on (e.g., "github:quartz-community/crawl-links") */
dependencies?: string[]
/** Default numeric execution order (0-100 convention, lower = runs first). Defaults to 50. */
defaultOrder?: number
/** Whether the plugin is enabled by default on install. Defaults to true. */
defaultEnabled?: boolean
/** Default options applied when no user options are specified */
defaultOptions?: Record<string, unknown>
/** JSON Schema for the plugin's options object, used for validation and TUI generation */
configSchema?: object
/** Components provided by this plugin, keyed by component export name */
components?: Record<string, ComponentManifest & ComponentLayoutDefaults>
/** Page frames provided by this plugin, keyed by export name. Each entry maps to a PageFrame object. */
frames?: Record<string, { exportName: string }>
/** Whether the plugin requires `npm install` after cloning (e.g. for native dependencies like sharp). */
requiresInstall?: boolean
}
/**
* Loaded plugin with metadata
*/
export interface LoadedPlugin {
plugin: QuartzTransformerPlugin | QuartzFilterPlugin | QuartzEmitterPlugin | QuartzPageTypePlugin
manifest: PluginManifest
type: PluginCategory
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> }
/** Layout declaration for a component-providing plugin in quartz.config.yaml */
export interface PluginLayoutDeclaration {
position: LayoutPosition
priority: number
display?: LayoutDisplay
condition?: string
group?: string
groupOptions?: {
grow?: boolean
shrink?: boolean
basis?: string
order?: number
align?: "start" | "end" | "center" | "stretch"
justify?: "start" | "end" | "center" | "between" | "around"
}
}
/** Object form of a plugin source (for monorepo / advanced config) */
export interface PluginSourceObject {
repo: string
subdir?: string
ref?: string
name?: string
}
/** A plugin source can be a string shorthand or an object with additional fields */
export type PluginSource = string | PluginSourceObject
/** A single plugin entry in quartz.config.yaml */
export interface PluginJsonEntry {
source: PluginSource
enabled: boolean
options?: Record<string, unknown>
order?: number
layout?: PluginLayoutDeclaration
}
/** Flex group configuration in the top-level layout section */
export interface FlexGroupConfig {
/** Explicit priority for the group. Overrides first-member priority. Lower = renders first. */
priority?: number
direction?: "row" | "row-reverse" | "column" | "column-reverse"
wrap?: "nowrap" | "wrap" | "wrap-reverse"
gap?: string
}
/** Per-page-type layout overrides */
export interface PageTypeLayoutOverride {
exclude?: string[]
positions?: Partial<Record<LayoutPosition, PluginLayoutDeclaration[]>>
/** Override the page frame template (e.g. "default", "full-width", "minimal") */
template?: string
}
/** Top-level layout section of quartz.config.yaml */
export interface LayoutConfig {
groups?: Record<string, FlexGroupConfig>
byPageType?: Record<string, PageTypeLayoutOverride>
}
/** Root type for quartz.config.yaml */
export interface QuartzPluginsJson {
$schema?: string
configuration: Record<string, unknown>
plugins: PluginJsonEntry[]
layout?: LayoutConfig
}