feat(loader): prefer pre-built plugins

This commit is contained in:
saberzero1
2026-04-27 22:03:23 +02:00
parent 6a2bb304d2
commit efd9e8e095
2 changed files with 91 additions and 10 deletions

View File

@@ -46,6 +46,12 @@ async function cloneWithSubdirAsync({ url, ref, subdir, pluginDir }) {
} }
async function buildPluginAsync(pluginDir, name) { async function buildPluginAsync(pluginDir, name) {
if (hasPrebuiltDist(pluginDir)) {
console.log(styleText("green", `${name}: using pre-built dist/`))
linkPeerPlugins(pluginDir)
return true
}
try { try {
const skipBuild = !needsBuild(pluginDir) const skipBuild = !needsBuild(pluginDir)
console.log(styleText("cyan", `${name}: installing dependencies...`)) console.log(styleText("cyan", `${name}: installing dependencies...`))
@@ -102,6 +108,11 @@ function isDistGitignored(pluginDir) {
}) })
} }
function hasPrebuiltDist(pluginDir) {
const distDir = path.join(pluginDir, "dist")
return fs.existsSync(distDir) && !isDistGitignored(pluginDir)
}
function needsBuild(pluginDir) { function needsBuild(pluginDir) {
if (isDistGitignored(pluginDir)) return true if (isDistGitignored(pluginDir)) return true
const distDir = path.join(pluginDir, "dist") const distDir = path.join(pluginDir, "dist")

View File

@@ -161,9 +161,9 @@ function collectNativeDeps(pluginDir: string): Map<string, string> {
if (!manifest.requiresInstall) return result if (!manifest.requiresInstall) return result
const peerDeps: Record<string, string> = pkg.peerDependencies ?? {} const peerDeps: Record<string, string> = pkg.peerDependencies ?? {}
const sharedExternals = getSharedExternals()
for (const [name, range] of Object.entries(peerDeps)) { for (const [name, range] of Object.entries(peerDeps)) {
// Skip shared externals that Quartz already provides if (sharedExternals.some((prefix) => name.startsWith(prefix))) {
if (SHARED_EXTERNALS.some((prefix) => name.startsWith(prefix)) || name === "vfile") {
continue continue
} }
result.set(name, range) result.set(name, range)
@@ -269,6 +269,11 @@ function isDistGitignored(pluginDir: string): boolean {
}) })
} }
function hasPrebuiltDist(pluginDir: string): boolean {
const distDir = path.join(pluginDir, "dist")
return fs.existsSync(distDir) && !isDistGitignored(pluginDir)
}
function needsBuild(pluginDir: string): boolean { function needsBuild(pluginDir: string): boolean {
if (isDistGitignored(pluginDir)) return true if (isDistGitignored(pluginDir)) return true
const distDir = path.join(pluginDir, "dist") const distDir = path.join(pluginDir, "dist")
@@ -344,6 +349,14 @@ function linkPeerDependencies(pluginDir: string): void {
} }
function buildInstalledPlugin(pluginDir: string, name: string, verbose?: boolean): void { function buildInstalledPlugin(pluginDir: string, name: string, verbose?: boolean): void {
if (hasPrebuiltDist(pluginDir)) {
if (verbose) {
console.log(styleText("green", ``), `${name}: using pre-built dist/`)
}
linkPeerDependencies(pluginDir)
return
}
try { try {
const shouldBuild = needsBuild(pluginDir) const shouldBuild = needsBuild(pluginDir)
@@ -759,15 +772,71 @@ const NODE_BUILTINS = new Set([
"zlib", "zlib",
]) ])
const SHARED_EXTERNALS = ["@quartz-community/", "preact", "@jackyzha0/quartz", "vfile"] /**
* Packages that must be the same JavaScript module instance at runtime across
* all plugins and the host. These are true singletons — duplicating them causes
* broken identity checks (e.g. `instanceof`, shared registries).
*
* This list should be kept small and explicit. Only add packages here when
* multiple copies at runtime would cause correctness issues.
*/
const SINGLETON_EXTERNALS = ["preact", "@jackyzha0/quartz", "vfile", "unified"]
/**
* Scope prefixes whose packages are always treated as shared externals.
* Plugins under these scopes are co-installed siblings, not bundled deps.
*/
const SHARED_SCOPES = ["@quartz-community/"]
/**
* Build the full shared externals list by combining:
* 1. Explicit singleton packages (must be same instance at runtime)
* 2. Shared scope prefixes (@quartz-community/*)
* 3. Auto-detected dependencies from Quartz's own package.json
*
* The auto-detection ensures that when Quartz adds a new dependency,
* plugins that import it won't get false "unbundled external" warnings.
*/
let _sharedExternalsCache: string[] | null = null
export function getSharedExternals(): string[] {
if (_sharedExternalsCache) return _sharedExternalsCache
const externals = [...SINGLETON_EXTERNALS, ...SHARED_SCOPES]
// Auto-detect from Quartz's package.json
const quartzPkgPath = path.join(process.cwd(), "package.json")
if (fs.existsSync(quartzPkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(quartzPkgPath, "utf-8"))
const deps = Object.keys(pkg.dependencies ?? {})
for (const dep of deps) {
if (!externals.includes(dep)) {
externals.push(dep)
}
}
} catch {
// Fall back to explicit list only
}
}
_sharedExternalsCache = externals
return externals
}
/**
* Check whether an import specifier is an allowed external for a plugin.
* Allowed externals are: Node builtins, shared externals (singletons +
* Quartz deps + shared scopes), and the plugin's own declared peerDependencies.
*/
function isAllowedExternal(specifier: string, pluginPeerDeps: string[]): boolean { function isAllowedExternal(specifier: string, pluginPeerDeps: string[]): boolean {
if (specifier.startsWith("node:")) return true if (specifier.startsWith("node:")) return true
const bare = specifier.split("/")[0] const bare = specifier.split("/")[0]
if (NODE_BUILTINS.has(bare)) return true if (NODE_BUILTINS.has(bare)) return true
if (SHARED_EXTERNALS.some((prefix) => specifier.startsWith(prefix))) return true const sharedExternals = getSharedExternals()
if (sharedExternals.some((prefix) => specifier.startsWith(prefix))) return true
if (pluginPeerDeps.some((dep) => specifier === dep || specifier.startsWith(dep + "/"))) { if (pluginPeerDeps.some((dep) => specifier === dep || specifier.startsWith(dep + "/"))) {
return true return true
@@ -779,7 +848,7 @@ function isAllowedExternal(specifier: string, pluginPeerDeps: string[]): boolean
export function validatePluginExternals( export function validatePluginExternals(
pluginName: string, pluginName: string,
entryPoint: string, entryPoint: string,
options?: { verbose?: boolean }, _options?: { verbose?: boolean },
): string[] { ): string[] {
try { try {
const content = fs.readFileSync(entryPoint, "utf-8") const content = fs.readFileSync(entryPoint, "utf-8")
@@ -809,12 +878,13 @@ export function validatePluginExternals(
const unique = [...new Set(unexpected)] const unique = [...new Set(unexpected)]
if (unique.length > 0 && options?.verbose) { if (unique.length > 0) {
console.warn( console.error(
styleText("yellow", ``) + styleText("red", ``) +
` Plugin ${styleText("cyan", pluginName)} has unbundled external imports that may fail at runtime:\n` + ` Plugin ${styleText("cyan", pluginName)} has unbundled external imports that will fail at runtime:\n` +
unique.map((s) => ` - ${s}`).join("\n") + unique.map((s) => ` - ${s}`).join("\n") +
`\n These packages are not provided by Quartz. The plugin should bundle them into dist/.`, `\n These packages are not provided by Quartz. The plugin must bundle them into dist/.` +
`\n In the plugin's tsup.config.ts, add these to noExternal or remove the imports.`,
) )
} }