From f2c2a312927581347f2881961dfce30809f7fc68 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Wed, 18 Mar 2026 02:39:45 +0100 Subject: [PATCH] fix(cli): resolve subdir plugins during build installs --- quartz/plugins/loader/componentLoader.ts | 3 +- quartz/plugins/loader/config-loader.ts | 24 +-- quartz/plugins/loader/frameLoader.ts | 3 +- quartz/plugins/loader/gitLoader.ts | 220 +++++++++++++++++++---- quartz/plugins/loader/index.ts | 2 +- 5 files changed, 203 insertions(+), 49 deletions(-) diff --git a/quartz/plugins/loader/componentLoader.ts b/quartz/plugins/loader/componentLoader.ts index f7034b9ac..fd26a3ede 100644 --- a/quartz/plugins/loader/componentLoader.ts +++ b/quartz/plugins/loader/componentLoader.ts @@ -6,12 +6,11 @@ import { getPluginSubpathEntry, toFileUrl } from "./gitLoader" export async function loadComponentsFromPackage( pluginName: string, manifest: PluginManifest | null, - subdir?: string, ): Promise { if (!manifest?.components) return try { - const componentsPath = getPluginSubpathEntry(pluginName, "./components", subdir) + const componentsPath = getPluginSubpathEntry(pluginName, "./components") let componentsModule: Record if (componentsPath) { diff --git a/quartz/plugins/loader/config-loader.ts b/quartz/plugins/loader/config-loader.ts index 192dba803..ad250bbc7 100644 --- a/quartz/plugins/loader/config-loader.ts +++ b/quartz/plugins/loader/config-loader.ts @@ -189,7 +189,7 @@ function validateDependencies( async function resolvePluginManifest(source: PluginSource): Promise { try { const gitSpec = parsePluginSource(source) - const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) + const entryPoint = getPluginEntryPoint(gitSpec.name) const module = await import(toFileUrl(entryPoint)) return module.manifest ?? null } catch { @@ -343,7 +343,7 @@ export async function loadQuartzConfig( // 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) + const entryPoint = getPluginEntryPoint(gitSpec.name) try { const module = await import(toFileUrl(entryPoint)) // If the module exports an init() function, call it with merged options @@ -356,22 +356,22 @@ export async function loadQuartzConfig( // 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) + await loadComponentsFromPackage(gitSpec.name, manifest) } if (manifest?.frames && Object.keys(manifest.frames).length > 0) { - await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) + await loadFramesFromPackage(gitSpec.name, manifest) } } else { - const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) + 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, gitSpec.subdir) + await loadComponentsFromPackage(gitSpec.name, manifest) if (manifest?.frames && Object.keys(manifest.frames).length > 0) { - await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) + await loadFramesFromPackage(gitSpec.name, manifest) } } else { console.warn( @@ -383,10 +383,10 @@ export async function loadQuartzConfig( 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) + await loadComponentsFromPackage(gitSpec.name, manifest) } if (hasFrames) { - await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) + await loadFramesFromPackage(gitSpec.name, manifest) } if (!hasComponents && !hasFrames) { console.warn( @@ -423,13 +423,13 @@ export async function loadQuartzConfig( for (const { entry, manifest } of items) { try { const gitSpec = parsePluginSource(entry.source) - const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) + 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, gitSpec.subdir) + await loadComponentsFromPackage(gitSpec.name, manifest) } if (manifest?.frames && Object.keys(manifest.frames).length > 0) { - await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) + await loadFramesFromPackage(gitSpec.name, manifest) } const factory = findFactory(module, expectedCategory) diff --git a/quartz/plugins/loader/frameLoader.ts b/quartz/plugins/loader/frameLoader.ts index cab5e555e..950c1b72c 100644 --- a/quartz/plugins/loader/frameLoader.ts +++ b/quartz/plugins/loader/frameLoader.ts @@ -6,12 +6,11 @@ import { getPluginSubpathEntry, toFileUrl } from "./gitLoader" export async function loadFramesFromPackage( pluginName: string, manifest: PluginManifest | null, - subdir?: string, ): Promise { if (!manifest?.frames) return try { - const framesPath = getPluginSubpathEntry(pluginName, "./frames", subdir) + const framesPath = getPluginSubpathEntry(pluginName, "./frames") let framesModule: Record if (framesPath) { diff --git a/quartz/plugins/loader/gitLoader.ts b/quartz/plugins/loader/gitLoader.ts index 79deaf97f..15351ba01 100644 --- a/quartz/plugins/loader/gitLoader.ts +++ b/quartz/plugins/loader/gitLoader.ts @@ -76,8 +76,18 @@ export function parsePluginSource(source: PluginSource): GitPluginSpec { return { name, repo: resolved, local: true, subdir } } - const name = source.name ?? extractRepoName(url) - return { name, repo: url, ref: ref || undefined, subdir } + // Expand shorthand formats in the repo field (e.g. "github:user/repo") + // by recursing through the string-based parsing path, then overlay + // the object-level fields (subdir, ref, name) on top. + const expanded = parsePluginSource(url) + const name = source.name ?? expanded.name + return { + name, + repo: expanded.repo, + ref: ref || expanded.ref || undefined, + subdir, + local: expanded.local, + } } // Handle local paths @@ -248,6 +258,129 @@ export function installNativeDeps( } } +function isDistGitignored(pluginDir: string): boolean { + const gitignorePath = path.join(pluginDir, ".gitignore") + if (!fs.existsSync(gitignorePath)) return false + + const lines = fs.readFileSync(gitignorePath, "utf-8").split("\n") + return lines.some((line) => { + const trimmed = line.trim() + return trimmed === "dist" || trimmed === "dist/" || trimmed === "/dist" || trimmed === "/dist/" + }) +} + +function needsBuild(pluginDir: string): boolean { + if (isDistGitignored(pluginDir)) return true + const distDir = path.join(pluginDir, "dist") + return !fs.existsSync(distDir) +} + +function findPluginByPackageName(packageName: string): string | null { + if (!fs.existsSync(PLUGINS_CACHE_DIR)) return null + + const plugins = fs.readdirSync(PLUGINS_CACHE_DIR).filter((entry) => { + const entryPath = path.join(PLUGINS_CACHE_DIR, entry) + return fs.statSync(entryPath).isDirectory() + }) + + for (const pluginDirName of plugins) { + const pkgPath = path.join(PLUGINS_CACHE_DIR, pluginDirName, "package.json") + if (!fs.existsSync(pkgPath)) continue + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) + if (pkg.name === packageName) { + return path.join(PLUGINS_CACHE_DIR, pluginDirName) + } + } catch {} + } + return null +} + +/** + * Symlink peer dependencies to the host Quartz node_modules so plugins + * share a single copy of packages like unified, vfile, preact, etc. + * @quartz-community/* peers resolve to co-installed sibling plugins instead. + */ +function linkPeerDependencies(pluginDir: string): void { + const pkgPath = path.join(pluginDir, "package.json") + if (!fs.existsSync(pkgPath)) return + + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) + const peers: Record = pkg.peerDependencies ?? {} + + const quartzRoot = path.resolve(pluginDir, "..", "..", "..") + const hostNodeModules = path.join(quartzRoot, "node_modules") + + for (const peerName of Object.keys(peers)) { + const peerNodeModulesPath = path.join(pluginDir, "node_modules", ...peerName.split("/")) + if (fs.existsSync(peerNodeModulesPath)) continue + + if (peerName.startsWith("@quartz-community/")) { + const siblingPlugin = findPluginByPackageName(peerName) + if (!siblingPlugin) continue + + const scopeDir = path.join(pluginDir, "node_modules", peerName.split("/")[0]) + fs.mkdirSync(scopeDir, { recursive: true }) + + const target = path.relative(scopeDir, siblingPlugin) + fs.symlinkSync(target, peerNodeModulesPath, "dir") + continue + } + + const hostPeerPath = path.join(hostNodeModules, ...peerName.split("/")) + if (!fs.existsSync(hostPeerPath)) continue + + const parts = peerName.split("/") + if (parts.length > 1) { + const scopeDir = path.join(pluginDir, "node_modules", parts[0]) + fs.mkdirSync(scopeDir, { recursive: true }) + } else { + fs.mkdirSync(path.join(pluginDir, "node_modules"), { recursive: true }) + } + + const target = path.relative(path.dirname(peerNodeModulesPath), hostPeerPath) + fs.symlinkSync(target, peerNodeModulesPath, "dir") + } +} + +function buildInstalledPlugin(pluginDir: string, name: string, verbose?: boolean): void { + try { + const shouldBuild = needsBuild(pluginDir) + + if (verbose) { + console.log(styleText("cyan", `→`), `${name}: installing dependencies...`) + } + execSync("npm install --ignore-scripts", { + cwd: pluginDir, + stdio: verbose ? "inherit" : "pipe", + timeout: 120_000, + }) + + if (shouldBuild) { + if (verbose) { + console.log(styleText("cyan", `→`), `${name}: building...`) + } + execSync("npm run build", { + cwd: pluginDir, + stdio: verbose ? "inherit" : "pipe", + timeout: 120_000, + }) + } + + execSync("npm prune --omit=dev", { + cwd: pluginDir, + stdio: verbose ? "inherit" : "pipe", + timeout: 60_000, + }) + + linkPeerDependencies(pluginDir) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(styleText("red", `✗`), `${name}: post-install build failed: ${message}`) + throw new Error(`Failed to build plugin ${name}: ${message}`) + } +} + interface PluginInstallResult { pluginDir: string nativeDeps: Map @@ -316,39 +449,66 @@ export async function installPlugin( // Git source: clone // Check if already installed if (!options.force && fs.existsSync(pluginDir)) { - // Check if it's a git repo by trying to resolve HEAD - try { - await git.resolveRef({ fs, dir: pluginDir, ref: "HEAD" }) - if (options.verbose) { - console.log(styleText("cyan", `→`), `Plugin ${spec.name} already installed`) + // For subdir installs, the .git directory is removed after extraction, + // so check for package.json instead. For full-repo installs, check git HEAD. + if (spec.subdir) { + const pkgPath = path.join(pluginDir, "package.json") + if (fs.existsSync(pkgPath)) { + if (options.verbose) { + console.log(styleText("cyan", `→`), `Plugin ${spec.name} already installed`) + } + return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) } + } + } else { + try { + await git.resolveRef({ fs, dir: pluginDir, ref: "HEAD" }) + if (options.verbose) { + console.log(styleText("cyan", `→`), `Plugin ${spec.name} already installed`) + } + return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) } + } catch { + // If git operations fail, re-clone } - return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) } - } catch { - // If git operations fail, re-clone } } - // Clean up if force reinstall - if (options.force && fs.existsSync(pluginDir)) { + // Clean up if force reinstall or stale install + if (fs.existsSync(pluginDir)) { fs.rmSync(pluginDir, { recursive: true }) } if (options.verbose) { const refSuffix = spec.ref ? `#${spec.ref}` : "" - console.log(styleText("cyan", `→`), `Cloning ${spec.name} from ${spec.repo}${refSuffix}...`) + const subdirSuffix = spec.subdir ? ` (subdir: ${spec.subdir})` : "" + console.log( + styleText("cyan", `→`), + `Cloning ${spec.name} from ${spec.repo}${refSuffix}${subdirSuffix}...`, + ) } - // Clone the repository - await git.clone({ - fs, - http, - dir: pluginDir, - url: spec.repo, - ref: spec.ref, - singleBranch: true, - depth: 1, - noCheckout: false, - }) + if (spec.subdir) { + const tmpDir = pluginDir + ".__tmp__" + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true }) + } + + const branchArg = spec.ref ? ` --branch ${spec.ref}` : "" + execSync(`git clone --depth 1${branchArg} "${spec.repo}" "${tmpDir}"`, { stdio: "pipe" }) + + const subdirPath = path.join(tmpDir, spec.subdir) + if (!fs.existsSync(subdirPath)) { + fs.rmSync(tmpDir, { recursive: true }) + throw new Error(`Subdirectory "${spec.subdir}" not found in repository ${spec.repo}`) + } + + fs.renameSync(subdirPath, pluginDir) + fs.rmSync(tmpDir, { recursive: true }) + } else { + const branchArg = spec.ref ? ` --branch ${spec.ref}` : "" + execSync(`git clone --depth 1${branchArg} "${spec.repo}" "${pluginDir}"`, { stdio: "pipe" }) + } + + buildInstalledPlugin(pluginDir, spec.name, options.verbose) if (options.verbose) { console.log(styleText("green", `✓`), `Installed ${spec.name}`) @@ -408,9 +568,9 @@ export function isPluginInstalled(name: string): boolean { * Get the entry point for a plugin. * Prefers compiled dist/ output over raw src/ to avoid ESM resolution issues. */ -export function getPluginEntryPoint(name: string, subdir?: string): string { +export function getPluginEntryPoint(name: string): string { const pluginDir = getPluginDir(name) - const searchDir = subdir ? path.join(pluginDir, subdir) : pluginDir + const searchDir = pluginDir // Check package.json exports first (most reliable) const pkgJsonPath = path.join(searchDir, "package.json") if (fs.existsSync(pkgJsonPath)) { @@ -459,13 +619,9 @@ export function getPluginEntryPoint(name: string, subdir?: string): string { * Resolve a subpath export for a plugin (e.g. "./components"). * Uses package.json exports map, then falls back to dist/ directory structure. */ -export function getPluginSubpathEntry( - name: string, - subpath: string, - subdir?: string, -): string | null { +export function getPluginSubpathEntry(name: string, subpath: string): string | null { const pluginDir = getPluginDir(name) - const searchDir = subdir ? path.join(pluginDir, subdir) : pluginDir + const searchDir = pluginDir // Check package.json exports map const pkgJsonPath = path.join(searchDir, "package.json") diff --git a/quartz/plugins/loader/index.ts b/quartz/plugins/loader/index.ts index 08fed9a2e..de0240471 100644 --- a/quartz/plugins/loader/index.ts +++ b/quartz/plugins/loader/index.ts @@ -186,7 +186,7 @@ async function resolveSinglePlugin( try { const gitSpec = parsePluginSource(packageName) await installPlugin(gitSpec, { verbose: options.verbose }) - const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) + const entryPoint = getPluginEntryPoint(gitSpec.name) // Import the plugin const module = await import(toFileUrl(entryPoint))