/
superded
/
void
Обзор
Документация
Войти
/
superded
/
void
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
1
CI/CD
Аналитика
Безопасность
readme
quartz/plugins/loader/gitLoader.ts
1 077 строк
33 KB
superded42
Initial commit
04 июл 2026, 05:05
Не верифицирован
04 июл 2026, 05:05
af1cbe5
Код
Авторство
О чём код?
import fs from "fs" import path from "path" import { execSync } from "child_process" import git from "isomorphic-git" import http from "isomorphic-git/http/node" import { styleText } from "util" import { pathToFileURL } from "url" import { PluginSource } from "./types" /** * Convert an absolute filesystem path to a file:// URL string for use with dynamic import(). * On Windows, absolute paths like D:\path\file.js have "D:" interpreted as a URL protocol * by Node ESM, so they must be converted to file:// URLs. * Non-absolute paths (e.g. npm package names) are returned as-is. */ export function toFileUrl(filePath: string): string { if (path.isAbsolute(filePath)) { return pathToFileURL(filePath).href } return filePath } export interface GitPluginSpec { /** Plugin name (used for directory) */ name: string /** Git repository URL or absolute local path */ repo: string /** Git ref (branch, tag, or commit hash). Omit to use the remote's default branch. */ ref?: string /** Optional subdirectory within the repo if plugin is not at root */ subdir?: string /** Whether this is a local path source */ local?: boolean } export type PluginInstallSource = string | GitPluginSpec const PLUGINS_CACHE_DIR = path.join(process.cwd(), ".quartz", "plugins") /** * Check if a source string refers to a local file path. * Local sources start with ./, ../, / or a Windows drive letter (e.g. C:\). */ export function isLocalSource(source: PluginSource): boolean { if (typeof source === "object") { return isLocalSource(source.repo) } if (source.startsWith("./") || source.startsWith("../") || source.startsWith("/")) { return true } // Windows absolute paths (e.g. C:\ or D:/) if (/^[A-Za-z]:[\\/]/.test(source)) { return true } return false } /** * Parse a plugin source string into a GitPluginSpec * Supports: * - "./path/to/plugin" or "/absolute/path" -> local path * - "github:user/repo" -> https://github.com/user/repo.git * - "github:user/repo#ref" -> https://github.com/user/repo.git with specific ref * - "git+https://..." -> direct git URL * - "https://github.com/..." -> direct https URL */ 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 } } // 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 if (isLocalSource(source)) { const resolved = path.resolve(source) const name = path.basename(resolved) return { name, repo: resolved, local: true } } // Handle github shorthand: github:user/repo or github:user/repo#ref if (source.startsWith("github:")) { const withoutPrefix = source.replace("github:", "") const [repoPath, ref] = withoutPrefix.split("#") const [owner, repo] = repoPath.split("/") if (!owner || !repo) { throw new Error(`Invalid GitHub source: ${source}. Expected format: github:user/repo`) } return { name: repo, repo: `https://github.com/${owner}/${repo}.git`, ref: ref || undefined, } } // Handle git+https:// protocol if (source.startsWith("git+")) { const raw = source.replace("git+", "") const [url, ref] = raw.split("#") const name = extractRepoName(url) return { name, repo: url, ref: ref || undefined } } // Handle direct HTTPS URL (GitHub, GitLab, etc.) if (source.startsWith("https://")) { const [url, ref] = source.split("#") const name = extractRepoName(url) return { name, repo: url, ref: ref || undefined } } // Assume it's a plain repo name and try github const parts = source.split("/") if (parts.length === 2) { return { name: parts[1], repo: `https://github.com/${source}.git`, } } throw new Error(`Cannot parse plugin source: ${source}`) } function extractRepoName(url: string): string { // Extract repo name from URL like https://github.com/user/repo.git const match = url.match(/\/([^\/]+?)(?:\.git)?$/) return match ? match[1] : "unknown" } /** * Collect native (peer) dependencies from a plugin that declares requiresInstall. */ function collectNativeDeps(pluginDir: string): Map<string, string> { const result = new Map<string, string>() const pkgPath = path.join(pluginDir, "package.json") 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 result const peerDeps: Record<string, string> = pkg.peerDependencies ?? {} const sharedExternals = getSharedExternals() for (const [name, range] of Object.entries(peerDeps)) { if (sharedExternals.some((prefix) => name.startsWith(prefix))) { 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<string, Map<string, string>>, options: { verbose?: boolean }, ): void { const merged = new Map<string, Map<string, string>>() 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) { const sources = [...pluginRanges.entries()] .map(([plugin, range]) => `${plugin} (${range})`) .join(", ") console.log( styleText("cyan", `→`), `Native dep ${styleText("bold", pkg)} required by: ${sources}`, ) } 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}`) } } 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 hasPrebuiltDist(pluginDir: string): boolean { const distDir = path.join(pluginDir, "dist") return fs.existsSync(distDir) && !isDistGitignored(pluginDir) } 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 trySymlink(target: string, linkPath: string): void { try { fs.symlinkSync(target, linkPath, "dir") } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === "EEXIST") return throw err } } 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<string, string> = 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) trySymlink(target, peerNodeModulesPath) 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) trySymlink(target, peerNodeModulesPath) } } 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 { 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<string, string> } /** * 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<PluginInstallResult> { const pluginDir = path.join(PLUGINS_CACHE_DIR, spec.name) // Local source: symlink instead of clone if (spec.local) { if (!fs.existsSync(spec.repo)) { throw new Error(`Local plugin path does not exist: ${spec.repo}`) } if (!options.force && fs.existsSync(pluginDir)) { // Check if existing entry is already a symlink to the right place try { const stat = fs.lstatSync(pluginDir) if (stat.isSymbolicLink() && fs.realpathSync(pluginDir) === fs.realpathSync(spec.repo)) { if (options.verbose) { console.log(styleText("cyan", `→`), `Plugin ${spec.name} already linked`) } return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) } } } catch { // stat failed, recreate } } // Clean up if force reinstall or existing non-symlink entry if (fs.existsSync(pluginDir)) { const stat = fs.lstatSync(pluginDir) if (stat.isSymbolicLink()) { fs.unlinkSync(pluginDir) } else { fs.rmSync(pluginDir, { recursive: true }) } } // Ensure parent directory exists const parentDir = path.dirname(pluginDir) if (!fs.existsSync(parentDir)) { fs.mkdirSync(parentDir, { recursive: true }) } if (options.verbose) { console.log(styleText("cyan", `→`), `Linking ${spec.name} from ${spec.repo}...`) } fs.symlinkSync(spec.repo, pluginDir, "dir") if (options.verbose) { console.log(styleText("green", `✓`), `Linked ${spec.name}`) } return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) } } // Git source: clone // Check if already installed if (!options.force && fs.existsSync(pluginDir)) { // 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 } } } // 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}` : "" const subdirSuffix = spec.subdir ? ` (subdir: ${spec.subdir})` : "" console.log( styleText("cyan", `→`), `Cloning ${spec.name} from ${spec.repo}${refSuffix}${subdirSuffix}...`, ) } 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}`) } return { pluginDir, nativeDeps: collectNativeDeps(pluginDir) } } /** * Install multiple plugins from Git repositories */ export async function installPlugins( sources: PluginInstallSource[], options: { verbose?: boolean; force?: boolean } = {}, ): Promise<Map<string, string>> { const installed = new Map<string, string>() const allNativeDeps = new Map<string, Map<string, string>>() for (const source of sources) { try { const spec = typeof source === "string" ? parsePluginSource(source) : source 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 } /** * Get the installation directory for a plugin */ export function getPluginDir(name: string): string { return path.join(PLUGINS_CACHE_DIR, name) } /** * Check if a plugin is installed */ export function isPluginInstalled(name: string): boolean { return fs.existsSync(getPluginDir(name)) } /** * Get the entry point for a plugin. * Prefers compiled dist/ output over raw src/ to avoid ESM resolution issues. */ export function getPluginEntryPoint(name: string): string { const pluginDir = getPluginDir(name) const searchDir = pluginDir // Check package.json exports first (most reliable) const pkgJsonPath = path.join(searchDir, "package.json") if (fs.existsSync(pkgJsonPath)) { try { const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")) const exportEntry = pkg.exports?.["."] const importPath = typeof exportEntry === "string" ? exportEntry : exportEntry?.import if (importPath) { const resolved = path.join(searchDir, importPath) if (fs.existsSync(resolved)) { return resolved } } // Fall back to main/module fields const mainField = pkg.module ?? pkg.main if (mainField) { const resolved = path.join(searchDir, mainField) if (fs.existsSync(resolved)) { return resolved } } } catch { // package.json parse error, fall through to candidates } } // Try common entry points — prefer compiled dist/ over raw src/ const candidates = [ path.join(searchDir, "dist", "index.js"), path.join(searchDir, "dist", "index.mjs"), path.join(searchDir, "index.js"), path.join(searchDir, "index.ts"), path.join(searchDir, "src", "index.js"), path.join(searchDir, "src", "index.ts"), ] for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate } } // If no entry found, return the search dir and let Node handle it return searchDir } /** * 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): string | null { const pluginDir = getPluginDir(name) const searchDir = pluginDir // Check package.json exports map const pkgJsonPath = path.join(searchDir, "package.json") if (fs.existsSync(pkgJsonPath)) { try { const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")) const exportEntry = pkg.exports?.[subpath] const importPath = typeof exportEntry === "string" ? exportEntry : exportEntry?.import if (importPath) { const resolved = path.join(searchDir, importPath) if (fs.existsSync(resolved)) { return resolved } } } catch { // fall through } } // Fall back: try dist/<subpath>/index.js const subpathClean = subpath.replace(/^\.\/?/, "") const fallbackCandidates = [ path.join(searchDir, "dist", subpathClean, "index.js"), path.join(searchDir, "dist", `${subpathClean}.js`), path.join(searchDir, subpathClean, "index.js"), ] for (const candidate of fallbackCandidates) { if (fs.existsSync(candidate)) { return candidate } } return null } /** * Update all installed plugins */ export async function updatePlugins(options: { verbose?: boolean } = {}): Promise<void> { if (!fs.existsSync(PLUGINS_CACHE_DIR)) { console.log("No plugins installed") return } const plugins = fs.readdirSync(PLUGINS_CACHE_DIR) for (const pluginName of plugins) { const pluginDir = path.join(PLUGINS_CACHE_DIR, pluginName) try { // Check if it's a git repo await git.resolveRef({ fs, dir: pluginDir, ref: "HEAD" }) if (options.verbose) { console.log(styleText("cyan", `→`), `Updating ${pluginName}...`) } // Fetch latest await git.fetch({ fs, http, dir: pluginDir, singleBranch: true, }) // Checkout to latest fetched commit await git.checkout({ fs, dir: pluginDir, ref: "FETCH_HEAD", force: true, }) if (options.verbose) { console.log(styleText("green", `✓`), `Updated ${pluginName}`) } } catch (error) { if (options.verbose) { console.error(styleText("yellow", `⚠`), `Skipping ${pluginName}: Not a git repo`) } } } } /** * Clean all installed plugins */ export function cleanPlugins(): void { if (fs.existsSync(PLUGINS_CACHE_DIR)) { fs.rmSync(PLUGINS_CACHE_DIR, { recursive: true }) console.log(styleText("green", `✓`), "Cleaned all plugins") } } const NODE_BUILTINS = new Set([ "assert", "buffer", "child_process", "cluster", "console", "constants", "crypto", "dgram", "dns", "domain", "events", "fs", "http", "http2", "https", "inspector", "module", "net", "os", "path", "perf_hooks", "process", "punycode", "querystring", "readline", "repl", "stream", "string_decoder", "sys", "timers", "tls", "trace_events", "tty", "url", "util", "v8", "vm", "wasi", "worker_threads", "zlib", ]) /** * 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 { if (specifier.startsWith("node:")) return true const bare = specifier.split("/")[0] if (NODE_BUILTINS.has(bare)) return true const sharedExternals = getSharedExternals() if (sharedExternals.some((prefix) => specifier.startsWith(prefix))) return true if (pluginPeerDeps.some((dep) => specifier === dep || specifier.startsWith(dep + "/"))) { return true } return false } export function validatePluginExternals( pluginName: string, entryPoint: string, _options?: { verbose?: boolean }, ): string[] { try { const content = fs.readFileSync(entryPoint, "utf-8") let peerDeps: string[] = [] const pluginDir = path.dirname(entryPoint).replace(/\/dist$/, "") const pkgPath = path.join(pluginDir, "package.json") if (fs.existsSync(pkgPath)) { try { const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) peerDeps = Object.keys(pkg.peerDependencies ?? {}) } catch { // ignore parse errors } } const importPattern = /^\s*(?:import\s+.*\s+from|export\s+.*\s+from)\s+["']([^"'./][^"']*)["']/gm const unexpected: string[] = [] for (const match of content.matchAll(importPattern)) { const specifier = match[1] if (!isAllowedExternal(specifier, peerDeps)) { unexpected.push(specifier) } } const unique = [...new Set(unexpected)] if (unique.length > 0) { console.error( styleText("red", `✗`) + ` Plugin ${styleText("cyan", pluginName)} has unbundled external imports that will fail at runtime:\n` + unique.map((s) => ` - ${s}`).join("\n") + `\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.`, ) } return unique } catch { return [] } } export async function regeneratePluginIndex(options: { verbose?: boolean } = {}): Promise<void> { if (!fs.existsSync(PLUGINS_CACHE_DIR)) { return } const pluginDirs = fs.readdirSync(PLUGINS_CACHE_DIR).filter((name) => { const pluginPath = path.join(PLUGINS_CACHE_DIR, name) return fs.statSync(pluginPath).isDirectory() }) // Phase 1: Collect all exports per plugin, detect conflicts const pluginExports = new Map< string, { overridable: string[]; passthrough: string[]; types: string[] } >() const nameCount = new Map<string, number>() for (const pluginName of pluginDirs) { const pluginDir = path.join(PLUGINS_CACHE_DIR, pluginName) const distIndex = path.join(pluginDir, "dist", "index.d.ts") if (!fs.existsSync(distIndex)) { if (options.verbose) { console.log(styleText("yellow", `⚠`), `Skipping ${pluginName}: no dist/index.d.ts found`) } continue } const dtsContent = fs.readFileSync(distIndex, "utf-8") const exportedNames = parseExportsFromDts(dtsContent) const named = exportedNames.filter((e) => !e.startsWith("type ")) const types = exportedNames.filter((e) => e.startsWith("type ")).map((e) => e.slice(5)) const overridable = named.filter((n) => isOverridableExport(n, dtsContent)) const passthrough = named.filter((n) => !isOverridableExport(n, dtsContent)) if (overridable.length > 0 || passthrough.length > 0 || types.length > 0) { pluginExports.set(pluginName, { overridable, passthrough, types }) for (const n of [...overridable, ...passthrough]) { nameCount.set(n, (nameCount.get(n) ?? 0) + 1) } } } // Phase 2: Generate index with registry import, plugin map, and conditional top-level exports const lines: string[] = [] lines.push(`import { componentRegistry } from "../../quartz/components/registry"`) lines.push("") // Type re-exports for (const [pluginName, { types }] of pluginExports) { if (types.length > 0) { lines.push(`export type { ${types.join(", ")} } from "./${pluginName}"`) } } // Direct re-exports for non-overridable values (constants, utility functions, etc.) for (const [pluginName, { passthrough }] of pluginExports) { if (passthrough.length === 0) continue const unique = passthrough.filter((n) => (nameCount.get(n) ?? 0) === 1) if (unique.length > 0) { lines.push(`export { ${unique.join(", ")} } from "./${pluginName}"`) } } lines.push("") // Generate the plugins map with override wrappers (overridable exports only) lines.push( `export const plugins: Record<string, Record<string, (...args: unknown[]) => void>> = {`, ) for (const [pluginName, { overridable }] of pluginExports) { if (overridable.length === 0) continue const escapedName = pluginName.replace(/"/g, '\\"') lines.push(` "${escapedName}": {`) for (const n of overridable) { lines.push( ` ${n}: (...args: unknown[]) => { componentRegistry.setOptionOverrides("${escapedName}", args[0] as Record<string, unknown>); },`, ) } lines.push(` },`) } lines.push(`}`) lines.push("") // Top-level exports for overridable names: alias to the plugins map wrapper for (const [pluginName, { overridable }] of pluginExports) { if (overridable.length === 0) continue const unique = overridable.filter((n) => (nameCount.get(n) ?? 0) === 1) const conflicting = overridable.filter((n) => (nameCount.get(n) ?? 0) > 1) if (unique.length > 0) { const escapedName = pluginName.replace(/"/g, '\\"') for (const n of unique) { lines.push(`export const ${n} = plugins["${escapedName}"].${n}`) } } if (conflicting.length > 0 && options.verbose) { for (const n of conflicting) { console.warn( styleText("yellow", `⚠`), `Export "${n}" conflicts across plugins — use plugins["${pluginName}"].${n} in quartz.ts`, ) } } } lines.push("") const indexContent = lines.join("\n") const indexPath = path.join(PLUGINS_CACHE_DIR, "index.ts") fs.writeFileSync(indexPath, indexContent) if (options.verbose) { console.log( styleText("green", `✓`), `Regenerated plugin index with ${pluginDirs.length} plugins`, ) } } const INTERNAL_EXPORTS = new Set(["manifest", "default"]) const PLUGIN_TYPE_PATTERN = /Quartz(?:Emitter|Transformer|Filter|PageType)Plugin|QuartzComponentConstructor|\(.*\)\s*=>\s*QuartzComponent\b/ function resolveOriginalName(exportName: string, dtsContent: string): string { const aliasPattern = new RegExp(`(\\w+)\\s+as\\s+${exportName}\\b`) const match = dtsContent.match(aliasPattern) return match ? match[1] : exportName } function isOverridableExport(name: string, dtsContent: string): boolean { const declName = resolveOriginalName(name, dtsContent) const declPattern = new RegExp(`declare\\s+const\\s+${declName}\\s*:\\s*(.+?)(?:;|$)`, "m") const match = dtsContent.match(declPattern) if (!match) return false return PLUGIN_TYPE_PATTERN.test(match[1]) } function parseExportsFromDts(content: string): string[] { const exports: string[] = [] const exportMatches = content.matchAll(/export\s*{\s*([^}]+)\s*}(?:\s*from\s*['"]([^'"]+)['"])?/g) for (const match of exportMatches) { const fromModule = match[2] if (fromModule?.startsWith("@")) { continue } const names = match[1] .split(",") .map((n) => n.trim()) .filter(Boolean) for (const name of names) { const cleanName = name.split(" as ").pop()?.trim() || name.trim() if (cleanName && !cleanName.startsWith("_") && !INTERNAL_EXPORTS.has(cleanName)) { const finalName = cleanName.replace(/^type\s+/, "") if (name.includes("type ")) { exports.push(`type ${finalName}`) } else { exports.push(finalName) } } } } return exports }