diff --git a/quartz/cli/plugin-git-handlers.js b/quartz/cli/plugin-git-handlers.js index 6ddf8e8..6ff7360 100644 --- a/quartz/cli/plugin-git-handlers.js +++ b/quartz/cli/plugin-git-handlers.js @@ -203,6 +203,23 @@ function findPluginByPackageName(packageName) { return null } +const PLUGIN_TYPE_PATTERN = + /Quartz(?:Emitter|Transformer|Filter|PageType)Plugin|QuartzComponentConstructor|\(.*\)\s*=>\s*QuartzComponent\b/ + +function resolveOriginalName(exportName, dtsContent) { + const aliasPattern = new RegExp(`(\\w+)\\s+as\\s+${exportName}\\b`) + const match = dtsContent.match(aliasPattern) + return match ? match[1] : exportName +} + +function isOverridableExport(name, dtsContent) { + 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) { const exports = [] const exportMatches = content.matchAll(/export\s*{\s*([^}]+)\s*}(?:\s*from\s*['"]([^'"]+)['"])?/g) @@ -232,14 +249,16 @@ function parseExportsFromDts(content) { async function regeneratePluginIndex() { if (!fs.existsSync(PLUGINS_DIR)) return - const plugins = fs.readdirSync(PLUGINS_DIR).filter((name) => { + const pluginDirs = fs.readdirSync(PLUGINS_DIR).filter((name) => { const pluginPath = path.join(PLUGINS_DIR, name) return fs.statSync(pluginPath).isDirectory() }) - const exports = [] + // Phase 1: Collect all exports per plugin, detect conflicts + const pluginExports = new Map() + const nameCount = new Map() - for (const pluginName of plugins) { + for (const pluginName of pluginDirs) { const pluginDir = path.join(PLUGINS_DIR, pluginName) const distIndex = path.join(pluginDir, "dist", "index.d.ts") @@ -247,21 +266,88 @@ async function regeneratePluginIndex() { 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)) - if (exportedNames.length > 0) { - const namedExports = exportedNames.filter((e) => !e.startsWith("type ")) - const typeExports = 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 (namedExports.length > 0) { - exports.push(`export { ${namedExports.join(", ")} } from "./${pluginName}"`) - } - if (typeExports.length > 0) { - exports.push(`export type { ${typeExports.join(", ")} } from "./${pluginName}"`) + 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) } } } - const indexContent = exports.join("\n") + "\n" + // Phase 2: Generate index with registry import, plugin map, and conditional top-level exports + const lines = [] + + 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 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); },`, + ) + } + 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) { + 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_DIR, "index.ts") fs.writeFileSync(indexPath, indexContent) } diff --git a/quartz/plugins/loader/gitLoader.ts b/quartz/plugins/loader/gitLoader.ts index 3a511b1..20fbb43 100644 --- a/quartz/plugins/loader/gitLoader.ts +++ b/quartz/plugins/loader/gitLoader.ts @@ -914,7 +914,10 @@ export async function regeneratePluginIndex(options: { verbose?: boolean } = {}) }) // Phase 1: Collect all exports per plugin, detect conflicts - const pluginExports = new Map() + const pluginExports = new Map< + string, + { overridable: string[]; passthrough: string[]; types: string[] } + >() const nameCount = new Map() for (const pluginName of pluginDirs) { @@ -933,9 +936,12 @@ export async function regeneratePluginIndex(options: { verbose?: boolean } = {}) const named = exportedNames.filter((e) => !e.startsWith("type ")) const types = exportedNames.filter((e) => e.startsWith("type ")).map((e) => e.slice(5)) - if (named.length > 0 || types.length > 0) { - pluginExports.set(pluginName, { named, types }) - for (const n of named) { + 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) } } @@ -953,17 +959,26 @@ export async function regeneratePluginIndex(options: { verbose?: boolean } = {}) 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 + // Generate the plugins map with override wrappers (overridable exports only) lines.push( `export const plugins: Record void>> = {`, ) - for (const [pluginName, { named }] of pluginExports) { - if (named.length === 0) continue + for (const [pluginName, { overridable }] of pluginExports) { + if (overridable.length === 0) continue const escapedName = pluginName.replace(/"/g, '\\"') lines.push(` "${escapedName}": {`) - for (const n of named) { + for (const n of overridable) { lines.push( ` ${n}: (...args: unknown[]) => { componentRegistry.setOptionOverrides("${escapedName}", args[0] as Record); },`, ) @@ -973,12 +988,12 @@ export async function regeneratePluginIndex(options: { verbose?: boolean } = {}) lines.push(`}`) lines.push("") - // Top-level exports: only for non-conflicting names - for (const [pluginName, { named }] of pluginExports) { - if (named.length === 0) continue + // 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 = named.filter((n) => (nameCount.get(n) ?? 0) === 1) - const conflicting = named.filter((n) => (nameCount.get(n) ?? 0) > 1) + 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, '\\"') @@ -1014,6 +1029,23 @@ export async function regeneratePluginIndex(options: { verbose?: boolean } = {}) 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[] = []