feat(loader): prefer pre-built plugins
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user