mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-22 05:55:42 -05:00
Replace per-plugin npm install with a single aggregated npm install in the quartz root. Plugins declaring requiresInstall in their quartz manifest now have their peerDependencies collected and installed together, letting npm resolve compatible versions across plugins.
867 lines
29 KiB
TypeScript
867 lines
29 KiB
TypeScript
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<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(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<string, string[]>()
|
|
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<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: string): Promise<PluginManifest | null> {
|
|
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<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: string): 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", 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<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, 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<string, unknown>,
|
|
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<string, unknown>
|
|
|
|
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<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 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<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(`${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<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)
|
|
}
|
|
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<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)!
|
|
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
|
|
}
|