fix(cli): properly resolve subdir plugin installs

This commit is contained in:
saberzero1 2026-03-18 01:56:48 +01:00
parent a7dca22667
commit 166b9c87d8
No known key found for this signature in database
3 changed files with 67 additions and 19 deletions

View File

@ -8,6 +8,7 @@ import { PluginTypes } from "../types"
import { import {
PluginManifest, PluginManifest,
PluginJsonEntry, PluginJsonEntry,
PluginSource,
QuartzPluginsJson, QuartzPluginsJson,
LayoutConfig, LayoutConfig,
PluginLayoutDeclaration, PluginLayoutDeclaration,
@ -50,8 +51,12 @@ function readPluginsJson(): QuartzPluginsJson | null {
return JSON.parse(raw) as QuartzPluginsJson return JSON.parse(raw) as QuartzPluginsJson
} }
function extractPluginName(source: string): string { function extractPluginName(source: PluginSource): string {
// Local file paths: use directory basename if (typeof source === "object" && source !== null) {
if (source.name) return source.name
return extractPluginName(source.repo)
}
if (isLocalSource(source)) { if (isLocalSource(source)) {
return path.basename(source.replace(/[\/]+$/, "")) return path.basename(source.replace(/[\/]+$/, ""))
} }
@ -69,6 +74,19 @@ function extractPluginName(source: string): string {
return 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 { interface DependencyValidationResult {
errors: string[] errors: string[]
warnings: string[] warnings: string[]
@ -84,13 +102,13 @@ function validateDependencies(
const sourceToEntry = new Map<string, PluginJsonEntry>() const sourceToEntry = new Map<string, PluginJsonEntry>()
const nameToSource = new Map<string, string>() const nameToSource = new Map<string, string>()
for (const entry of entries) { for (const entry of entries) {
sourceToEntry.set(entry.source, entry) sourceToEntry.set(sourceKey(entry.source), entry)
nameToSource.set(extractPluginName(entry.source), entry.source) nameToSource.set(extractPluginName(entry.source), sourceKey(entry.source))
} }
for (const entry of entries) { for (const entry of entries) {
if (!entry.enabled) continue if (!entry.enabled) continue
const manifest = manifests.get(entry.source) const manifest = manifests.get(sourceKey(entry.source))
if (!manifest?.dependencies?.length) continue if (!manifest?.dependencies?.length) continue
const pluginName = manifest.displayName || extractPluginName(entry.source) const pluginName = manifest.displayName || extractPluginName(entry.source)
@ -126,12 +144,11 @@ function validateDependencies(
} }
} }
// Circular dependency detection
const graph = new Map<string, string[]>() const graph = new Map<string, string[]>()
for (const entry of entries) { for (const entry of entries) {
const manifest = manifests.get(entry.source) const manifest = manifests.get(sourceKey(entry.source))
if (manifest?.dependencies?.length) { if (manifest?.dependencies?.length) {
graph.set(entry.source, manifest.dependencies) graph.set(sourceKey(entry.source), manifest.dependencies)
} }
} }
@ -169,7 +186,7 @@ function validateDependencies(
return { errors, warnings } return { errors, warnings }
} }
async function resolvePluginManifest(source: string): Promise<PluginManifest | null> { async function resolvePluginManifest(source: PluginSource): Promise<PluginManifest | null> {
try { try {
const gitSpec = parsePluginSource(source) const gitSpec = parsePluginSource(source)
const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir)
@ -180,7 +197,7 @@ async function resolvePluginManifest(source: string): Promise<PluginManifest | n
} }
} }
async function readManifestFromPackageJson(source: string): Promise<PluginManifest | null> { async function readManifestFromPackageJson(source: PluginSource): Promise<PluginManifest | null> {
try { try {
const gitSpec = parsePluginSource(source) const gitSpec = parsePluginSource(source)
const pluginDir = path.join(process.cwd(), ".quartz", "plugins", gitSpec.name) const pluginDir = path.join(process.cwd(), ".quartz", "plugins", gitSpec.name)
@ -213,7 +230,7 @@ async function readManifestFromPackageJson(source: string): Promise<PluginManife
} }
} }
async function getManifest(source: string): Promise<PluginManifest | null> { async function getManifest(source: PluginSource): Promise<PluginManifest | null> {
// Try package.json quartz field first (preferred), then fall back to manifest.ts export // Try package.json quartz field first (preferred), then fall back to manifest.ts export
return (await readManifestFromPackageJson(source)) ?? (await resolvePluginManifest(source)) return (await readManifestFromPackageJson(source)) ?? (await resolvePluginManifest(source))
} }
@ -249,7 +266,7 @@ export async function loadQuartzConfig(
} catch (err) { } catch (err) {
console.error( console.error(
styleText("red", ``) + styleText("red", ``) +
` Failed to install plugin: ${styleText("yellow", entry.source)}\n` + ` Failed to install plugin: ${styleText("yellow", formatSourceDisplay(entry.source))}\n` +
` ${err instanceof Error ? err.message : String(err)}`, ` ${err instanceof Error ? err.message : String(err)}`,
) )
} }
@ -264,12 +281,12 @@ export async function loadQuartzConfig(
try { try {
const manifest = await getManifest(entry.source) const manifest = await getManifest(entry.source)
if (manifest) { if (manifest) {
manifests.set(entry.source, manifest) manifests.set(sourceKey(entry.source), manifest)
} }
} catch (err) { } catch (err) {
console.error( console.error(
styleText("red", ``) + styleText("red", ``) +
` Failed to load manifest: ${styleText("yellow", entry.source)}\n` + ` Failed to load manifest: ${styleText("yellow", formatSourceDisplay(entry.source))}\n` +
` ${err instanceof Error ? err.message : String(err)}`, ` ${err instanceof Error ? err.message : String(err)}`,
) )
} }
@ -296,7 +313,7 @@ export async function loadQuartzConfig(
const pageTypes: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = [] const pageTypes: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[] = []
for (const entry of enabledEntries) { for (const entry of enabledEntries) {
const manifest = manifests.get(entry.source) const manifest = manifests.get(sourceKey(entry.source))
const category = manifest?.category const category = manifest?.category
// Resolve processing categories: for array categories (e.g. ["transformer", "pageType", "component"]), // Resolve processing categories: for array categories (e.g. ["transformer", "pageType", "component"]),
// push the plugin into ALL matching processing category buckets. // push the plugin into ALL matching processing category buckets.
@ -683,7 +700,8 @@ function buildLayoutForEntries(
// Look up component from registry // Look up component from registry
const registered = const registered =
componentRegistry.get(name) ?? componentRegistry.get(`${entry.source}/${name}`) componentRegistry.get(name) ??
componentRegistry.get(`${formatSourceDisplay(entry.source)}/${name}`)
if (!registered) { if (!registered) {
// Try common naming patterns // Try common naming patterns
const pascalName = name const pascalName = name

View File

@ -5,6 +5,7 @@ import git from "isomorphic-git"
import http from "isomorphic-git/http/node" import http from "isomorphic-git/http/node"
import { styleText } from "util" import { styleText } from "util"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { PluginSource } from "./types"
/** /**
* Convert an absolute filesystem path to a file:// URL string for use with dynamic import(). * Convert an absolute filesystem path to a file:// URL string for use with dynamic import().
@ -40,7 +41,10 @@ const PLUGINS_CACHE_DIR = path.join(process.cwd(), ".quartz", "plugins")
* Check if a source string refers to a local file path. * Check if a source string refers to a local file path.
* Local sources start with ./, ../, / or a Windows drive letter (e.g. C:\). * Local sources start with ./, ../, / or a Windows drive letter (e.g. C:\).
*/ */
export function isLocalSource(source: string): boolean { export function isLocalSource(source: PluginSource): boolean {
if (typeof source === "object") {
return isLocalSource(source.repo)
}
if (source.startsWith("./") || source.startsWith("../") || source.startsWith("/")) { if (source.startsWith("./") || source.startsWith("../") || source.startsWith("/")) {
return true return true
} }
@ -60,7 +64,22 @@ export function isLocalSource(source: string): boolean {
* - "git+https://..." -> direct git URL * - "git+https://..." -> direct git URL
* - "https://github.com/..." -> direct https URL * - "https://github.com/..." -> direct https URL
*/ */
export function parsePluginSource(source: string): GitPluginSpec { export function parsePluginSource(source: PluginSource): GitPluginSpec {
if (typeof source === "object" && source !== null) {
const url = source.repo
const subdir = source.subdir
const ref = source.ref
if (isLocalSource(url)) {
const resolved = path.resolve(url)
const name = source.name ?? path.basename(resolved)
return { name, repo: resolved, local: true, subdir }
}
const name = source.name ?? extractRepoName(url)
return { name, repo: url, ref: ref || undefined, subdir }
}
// Handle local paths // Handle local paths
if (isLocalSource(source)) { if (isLocalSource(source)) {
const resolved = path.resolve(source) const resolved = path.resolve(source)

View File

@ -141,9 +141,20 @@ export interface PluginLayoutDeclaration {
} }
} }
/** 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 */ /** A single plugin entry in quartz.config.yaml */
export interface PluginJsonEntry { export interface PluginJsonEntry {
source: string source: PluginSource
enabled: boolean enabled: boolean
options?: Record<string, unknown> options?: Record<string, unknown>
order?: number order?: number