From 51b05e79e2fc0b36f1039bb84ae1ad71ca920bf6 Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Tue, 17 Mar 2026 22:32:47 +0100 Subject: [PATCH] feat: centralize native dep installation across plugins 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. --- package-lock.json | 12 +- quartz/plugins/loader/config-loader.ts | 26 ++++- quartz/plugins/loader/gitLoader.ts | 145 ++++++++++++++++++++----- 3 files changed, 148 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index 09b9514b3..625ff2aba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,9 +93,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "license": "MIT", "optional": true, "dependencies": { @@ -544,9 +544,9 @@ "license": "MIT" }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "engines": { "node": ">=18" diff --git a/quartz/plugins/loader/config-loader.ts b/quartz/plugins/loader/config-loader.ts index 689fd2d39..76d6f7a66 100644 --- a/quartz/plugins/loader/config-loader.ts +++ b/quartz/plugins/loader/config-loader.ts @@ -16,6 +16,7 @@ import { import { parsePluginSource, installPlugin, + installNativeDeps, getPluginEntryPoint, toFileUrl, isLocalSource, @@ -236,12 +237,31 @@ export async function loadQuartzConfig( const enabledEntries = json.plugins.filter((e) => e.enabled) const manifests = new Map() - // Ensure all plugins are installed and collect manifests + // Ensure all plugins are installed and collect native deps + const allNativeDeps = new Map>() for (const entry of enabledEntries) { try { const gitSpec = parsePluginSource(entry.source) - await installPlugin(gitSpec, { verbose: false }) + 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) @@ -249,7 +269,7 @@ export async function loadQuartzConfig( } catch (err) { console.error( styleText("red", `✗`) + - ` Failed to install plugin: ${styleText("yellow", entry.source)}\n` + + ` Failed to load manifest: ${styleText("yellow", entry.source)}\n` + ` ${err instanceof Error ? err.message : String(err)}`, ) } diff --git a/quartz/plugins/loader/gitLoader.ts b/quartz/plugins/loader/gitLoader.ts index d6b9dd098..0c471d4f4 100644 --- a/quartz/plugins/loader/gitLoader.ts +++ b/quartz/plugins/loader/gitLoader.ts @@ -118,43 +118,130 @@ function extractRepoName(url: string): string { return match ? match[1] : "unknown" } -async function installPluginDepsIfNeeded( - pluginDir: string, - pluginName: string, - options: { verbose?: boolean }, -): Promise { +/** + * Collect native (peer) dependencies from a plugin that declares requiresInstall. + */ +function collectNativeDeps(pluginDir: string): Map { + const result = new Map() const pkgPath = path.join(pluginDir, "package.json") - if (!fs.existsSync(pkgPath)) return + if (!fs.existsSync(pkgPath)) return result try { const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) const manifest = pkg.quartz ?? pkg.manifest ?? {} - if (!manifest.requiresInstall) return + if (!manifest.requiresInstall) return result + + const peerDeps: Record = pkg.peerDependencies ?? {} + for (const [name, range] of Object.entries(peerDeps)) { + // Skip shared externals that Quartz already provides + if (SHARED_EXTERNALS.some((prefix) => name.startsWith(prefix)) || name === "vfile") { + continue + } + result.set(name, range) + } + } catch { + // ignore parse errors + } + + return result +} + +/** + * Install all collected native dependencies into the Quartz root with a single + * `npm install --no-save`. Lets npm resolve compatible versions across plugins. + */ +export function installNativeDeps( + nativeDeps: Map>, + options: { verbose?: boolean }, +): void { + const merged = new Map>() + for (const [pluginName, deps] of nativeDeps) { + for (const [pkg, range] of deps) { + if (!merged.has(pkg)) { + merged.set(pkg, new Map()) + } + merged.get(pkg)!.set(pluginName, range) + } + } + + if (merged.size === 0) return + + const installArgs: string[] = [] + for (const [pkg, pluginRanges] of merged) { + const ranges = [...pluginRanges.values()] + const uniqueRanges = [...new Set(ranges)] if (options.verbose) { - console.log(styleText("cyan", `→`), `Installing native dependencies for ${pluginName}...`) + const sources = [...pluginRanges.entries()] + .map(([plugin, range]) => `${plugin} (${range})`) + .join(", ") + console.log( + styleText("cyan", `→`), + `Native dep ${styleText("bold", pkg)} required by: ${sources}`, + ) } - execSync("npm install --omit=dev --ignore-scripts=false", { - cwd: pluginDir, - stdio: options.verbose ? "inherit" : "pipe", - timeout: 60_000, - }) - } catch { - console.warn( - styleText("yellow", `⚠`), - `Failed to install dependencies for ${pluginName}. Native features may not work.`, + if (uniqueRanges.length === 1) { + installArgs.push(`${pkg}@${JSON.stringify(uniqueRanges[0])}`) + } else { + if (options.verbose) { + console.warn( + styleText("yellow", `⚠`), + `Multiple version ranges for ${pkg}: ${uniqueRanges.join(", ")}. npm will attempt to resolve a compatible version.`, + ) + } + // Use first range; npm will fail if truly incompatible + installArgs.push(`${pkg}@${JSON.stringify(uniqueRanges[0])}`) + } + } + + if (installArgs.length === 0) return + + if (options.verbose) { + console.log( + styleText("cyan", `→`), + `Installing ${installArgs.length} native package(s) into Quartz root...`, ) } + + try { + execSync(`npm install --no-save ${installArgs.join(" ")}`, { + cwd: process.cwd(), + stdio: options.verbose ? "inherit" : "pipe", + timeout: 120_000, + }) + + if (options.verbose) { + console.log( + styleText("green", `✓`), + `Installed native dependencies: ${[...merged.keys()].join(", ")}`, + ) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error( + styleText("red", `✗`), + `Failed to install native dependencies. This may indicate incompatible version ranges across plugins.\n` + + ` Packages: ${[...merged.keys()].join(", ")}\n` + + ` Error: ${message}`, + ) + throw new Error(`Native dependency installation failed: ${message}`) + } +} + +interface PluginInstallResult { + pluginDir: string + nativeDeps: Map } /** * Install a plugin from a Git repository, or symlink a local plugin. + * Returns the plugin directory and any native dependencies it requires. */ export async function installPlugin( spec: GitPluginSpec, options: { verbose?: boolean; force?: boolean } = {}, -): Promise { +): Promise { const pluginDir = path.join(PLUGINS_CACHE_DIR, spec.name) // Local source: symlink instead of clone @@ -171,7 +258,7 @@ export async function installPlugin( if (options.verbose) { console.log(styleText("cyan", `→`), `Plugin ${spec.name} already linked`) } - return pluginDir + return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) } } } catch { // stat failed, recreate @@ -204,7 +291,7 @@ export async function installPlugin( console.log(styleText("green", `✓`), `Linked ${spec.name}`) } - return pluginDir + return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) } } // Git source: clone @@ -216,7 +303,7 @@ export async function installPlugin( if (options.verbose) { console.log(styleText("cyan", `→`), `Plugin ${spec.name} already installed`) } - return pluginDir + return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) } } catch { // If git operations fail, re-clone } @@ -244,13 +331,11 @@ export async function installPlugin( noCheckout: false, }) - await installPluginDepsIfNeeded(pluginDir, spec.name, options) - if (options.verbose) { console.log(styleText("green", `✓`), `Installed ${spec.name}`) } - return pluginDir + return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) } } /** @@ -261,18 +346,26 @@ export async function installPlugins( options: { verbose?: boolean; force?: boolean } = {}, ): Promise> { const installed = new Map() + const allNativeDeps = new Map>() for (const source of sources) { try { const spec = typeof source === "string" ? parsePluginSource(source) : source - const pluginDir = await installPlugin(spec, options) - installed.set(spec.name, pluginDir) + const result = await installPlugin(spec, options) + installed.set(spec.name, result.pluginDir) + if (result.nativeDeps.size > 0) { + allNativeDeps.set(spec.name, result.nativeDeps) + } } catch (error) { const message = error instanceof Error ? error.message : String(error) console.error(styleText("red", `✗`), `Failed to install plugin: ${message}`) } } + if (allNativeDeps.size > 0) { + installNativeDeps(allNativeDeps, options) + } + await regeneratePluginIndex(options) return installed