diff --git a/quartz.config.yaml b/quartz.config.yaml index 0669a0d0b..8a1f87f04 100644 --- a/quartz.config.yaml +++ b/quartz.config.yaml @@ -241,6 +241,10 @@ plugins: position: beforeBody priority: 15 display: all + - source: github:quartz-community/external-quartz-leaflet-map-plugin + enabled: true + options: {} + order: 50 layout: groups: toolbar: diff --git a/quartz.lock.json b/quartz.lock.json index 2334cdc69..d57e82491 100644 --- a/quartz.lock.json +++ b/quartz.lock.json @@ -22,8 +22,8 @@ "bases-page": { "source": "github:quartz-community/bases-page", "resolved": "https://github.com/quartz-community/bases-page.git", - "commit": "7f6439ed711f4aa9d38c5017e88a7a9757cd2d5c", - "installedAt": "2026-02-28T20:09:14.396Z" + "commit": "c00c56b7d1aef0c07846b8772a3b9303ad03d816", + "installedAt": "2026-03-07T23:21:54.535Z" }, "breadcrumbs": { "source": "github:quartz-community/breadcrumbs", @@ -109,6 +109,12 @@ "commit": "b70d50a96c00e4726fb08614dc4fab116b1c5ca9", "installedAt": "2026-02-28T20:09:21.150Z" }, + "external-quartz-leaflet-map-plugin": { + "source": "github:quartz-community/external-quartz-leaflet-map-plugin", + "resolved": "https://github.com/quartz-community/external-quartz-leaflet-map-plugin.git", + "commit": "e77e11ea8948b9cb78a7ec57977325af2a9b6617", + "installedAt": "2026-03-07T23:22:48.300Z" + }, "favicon": { "source": "github:quartz-community/favicon", "resolved": "https://github.com/quartz-community/favicon.git", diff --git a/quartz/cli/plugin-git-handlers.js b/quartz/cli/plugin-git-handlers.js index 847f83379..678b519f0 100644 --- a/quartz/cli/plugin-git-handlers.js +++ b/quartz/cli/plugin-git-handlers.js @@ -23,6 +23,13 @@ function buildPlugin(pluginDir, name) { execSync("npm install", { cwd: pluginDir, stdio: "ignore" }) console.log(styleText("cyan", ` → ${name}: building...`)) execSync("npm run build", { cwd: pluginDir, stdio: "ignore" }) + // Remove devDependencies after build — they are no longer needed and their + // presence can cause duplicate-singleton issues when a plugin ships its own + // copy of a shared dependency (e.g. bases-page's ViewRegistry). + execSync("npm prune --omit=dev", { cwd: pluginDir, stdio: "ignore" }) + // Symlink any peerDependencies that are co-installed Quartz plugins so that + // Node's module resolution finds the host copy instead of a stale nested one. + linkPeerPlugins(pluginDir) return true } catch (error) { console.log(styleText("red", ` ✗ ${name}: build failed`)) @@ -35,6 +42,66 @@ function needsBuild(pluginDir) { return !fs.existsSync(distDir) } +/** + * After pruning devDependencies, peerDependencies that reference other Quartz + * plugins (e.g. @quartz-community/bases-page) won't be installed as npm + * packages — they're loaded by v5 as sibling plugins. To make Node's module + * resolution work, we symlink those peers to the co-installed plugin directory. + */ +function linkPeerPlugins(pluginDir) { + const pkgPath = path.join(pluginDir, "package.json") + if (!fs.existsSync(pkgPath)) return + + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) + const peers = pkg.peerDependencies ?? {} + + for (const peerName of Object.keys(peers)) { + // Only handle @quartz-community scoped packages — those are Quartz plugins + if (!peerName.startsWith("@quartz-community/")) continue + + // Check if this peer is already satisfied (e.g. installed as a regular dep) + const peerNodeModulesPath = path.join(pluginDir, "node_modules", ...peerName.split("/")) + if (fs.existsSync(peerNodeModulesPath)) continue + + // Find the sibling plugin by its npm package name + const siblingPlugin = findPluginByPackageName(peerName) + if (!siblingPlugin) continue + + // Create the scoped directory if needed + const scopeDir = path.join(pluginDir, "node_modules", peerName.split("/")[0]) + fs.mkdirSync(scopeDir, { recursive: true }) + + // Create a relative symlink to the sibling plugin + const target = path.relative(scopeDir, siblingPlugin) + fs.symlinkSync(target, peerNodeModulesPath, "dir") + } +} + +/** + * Search installed plugins for one whose package.json "name" matches the given + * npm package name (e.g. "@quartz-community/bases-page"). + */ +function findPluginByPackageName(packageName) { + if (!fs.existsSync(PLUGINS_DIR)) return null + + const plugins = fs.readdirSync(PLUGINS_DIR).filter((entry) => { + const entryPath = path.join(PLUGINS_DIR, entry) + return fs.statSync(entryPath).isDirectory() + }) + + for (const pluginDirName of plugins) { + const pkgPath = path.join(PLUGINS_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_DIR, pluginDirName) + } + } catch {} + } + return null +} + function parseExportsFromDts(content) { const exports = [] const exportMatches = content.matchAll(/export\s*{\s*([^}]+)\s*}(?:\s*from\s*['"]([^'"]+)['"])?/g) diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index 11645bd1f..42d49b342 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -10,6 +10,7 @@ import { GlobalConfiguration } from "../cfg" import { i18n } from "../i18n" import { styleText } from "util" import { resolveFrame } from "./frames" +import type { TreeTransform } from "../plugins/types" interface RenderComponents { head: QuartzComponent @@ -231,6 +232,7 @@ export function renderPage( componentData: QuartzComponentProps, components: RenderComponents, pageResources: StaticResources, + treeTransforms?: TreeTransform[], ): string { // make a deep copy of the tree so we don't remove the transclusion references // for the file cached in contentMap in build.ts @@ -238,6 +240,13 @@ export function renderPage( const visited = new Set([slug]) renderTranscludes(root, cfg, slug, componentData, visited) + // Run plugin-provided tree transforms (e.g. resolving inline bases codeblocks) + if (treeTransforms) { + for (const transform of treeTransforms) { + transform(root, slug, componentData) + } + } + // set componentData.tree to the edited html that has transclusions rendered componentData.tree = root diff --git a/quartz/plugins/loader/config-loader.ts b/quartz/plugins/loader/config-loader.ts index a2c796f75..51516d627 100644 --- a/quartz/plugins/loader/config-loader.ts +++ b/quartz/plugins/loader/config-loader.ts @@ -264,58 +264,54 @@ export async function loadQuartzConfig( for (const entry of enabledEntries) { const manifest = manifests.get(entry.source) const category = manifest?.category - // Resolve processing category: for array categories (e.g. ["transformer", "component"]), - // find the first processing category. "component" is handled separately via loadComponentsFromPackage. + // Resolve processing categories: for array categories (e.g. ["transformer", "pageType", "component"]), + // push the plugin into ALL matching processing category buckets. + // "component" is handled separately via loadComponentsFromPackage during instantiation. const processingCategories = ["transformer", "filter", "emitter", "pageType"] as const - let resolvedCategory: string | undefined - if (Array.isArray(category)) { - resolvedCategory = category.find((c) => - (processingCategories as readonly string[]).includes(c), - ) - } else { - resolvedCategory = category + const categoryMap: Record = { + transformer: transformers, + filter: filters, + emitter: emitters, + pageType: pageTypes, } - switch (resolvedCategory) { - case "transformer": - transformers.push({ entry, manifest }) - break - case "filter": - filters.push({ entry, manifest }) - break - case "emitter": - emitters.push({ entry, manifest }) - break - case "pageType": - pageTypes.push({ entry, manifest }) - break - default: { - const gitSpec = parsePluginSource(entry.source) - const isComponentOnly = - resolvedCategory === "component" || - (Array.isArray(category) && category.every((c) => c === "component")) + const categories = Array.isArray(category) ? category : category ? [category] : [] + const matchedProcessing = categories.filter((c) => + (processingCategories as readonly string[]).includes(c), + ) - if (isComponentOnly) { - if (manifest?.components && Object.keys(manifest.components).length > 0) { - await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) - } - if (manifest?.frames && Object.keys(manifest.frames).length > 0) { - await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) - } - break + if (matchedProcessing.length > 0) { + for (const cat of matchedProcessing) { + categoryMap[cat].push({ entry, manifest }) + } + } else { + const gitSpec = parsePluginSource(entry.source) + const isComponentOnly = + categories.length > 0 && categories.every((c) => c === "component") + + if (isComponentOnly) { + // Always import the main entry point for component-only plugins. + // Some plugins (e.g. Bases view registrations) rely on side effects + // in their index module to register functionality. + const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) + try { + await import(toFileUrl(entryPoint)) + } catch (e) { + // Side-effect import failed — continue with manifest-based loading } + if (manifest?.components && Object.keys(manifest.components).length > 0) { + await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) + } + if (manifest?.frames && Object.keys(manifest.frames).length > 0) { + await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) + } + } else { const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) try { const module = await import(toFileUrl(entryPoint)) const detected = detectCategoryFromModule(module) if (detected) { - const target = { - transformer: transformers, - filter: filters, - emitter: emitters, - pageType: pageTypes, - }[detected] - target.push({ entry, manifest }) + categoryMap[detected].push({ entry, manifest }) } else if (manifest?.components && Object.keys(manifest.components).length > 0) { await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) if (manifest?.frames && Object.keys(manifest.frames).length > 0) { diff --git a/quartz/plugins/pageTypes/dispatcher.ts b/quartz/plugins/pageTypes/dispatcher.ts index d981f9801..3381a8871 100644 --- a/quartz/plugins/pageTypes/dispatcher.ts +++ b/quartz/plugins/pageTypes/dispatcher.ts @@ -1,4 +1,4 @@ -import { QuartzEmitterPlugin, QuartzPageTypePluginInstance } from "../types" +import { QuartzEmitterPlugin, QuartzPageTypePluginInstance, TreeTransform } from "../types" import { QuartzComponent, QuartzComponentProps } from "../../components/types" import { pageResources, renderPage } from "../../components/renderPage" import { FullPageLayout } from "../../cfg" @@ -74,6 +74,7 @@ async function emitPage( allFiles: ProcessedContent[1]["data"][], layout: FullPageLayout, resources: StaticResources, + treeTransforms?: TreeTransform[], ) { const cfg = ctx.cfg.configuration // For the 404 page, use an absolute base path so assets resolve correctly @@ -95,7 +96,7 @@ async function emitPage( return write({ ctx, - content: renderPage(cfg, slug, componentData, layout, externalResources), + content: renderPage(cfg, slug, componentData, layout, externalResources, treeTransforms), slug, ext: ".html", }) @@ -157,6 +158,11 @@ export const PageTypeDispatcher: QuartzEmitterPlugin> const cfg = ctx.cfg.configuration const allFiles = content.map((c) => c[1].data) + // Collect tree transforms from all page type plugins + const treeTransforms: TreeTransform[] = pageTypes.flatMap( + (pt) => pt.treeTransforms?.(ctx) ?? [], + ) + // Ensure trie is available for components that need folder hierarchy (e.g. FolderContent) ctx.trie ??= trieFromAllFiles(allFiles) @@ -201,7 +207,7 @@ export const PageTypeDispatcher: QuartzEmitterPlugin> for (const pt of pageTypes) { if (pt.match({ slug, fileData, cfg })) { const layout = resolveLayout(pt, defaults, byPageType) - yield emitPage(ctx, slug, tree, fileData, allFilesWithVirtual, layout, resources) + yield emitPage(ctx, slug, tree, fileData, allFilesWithVirtual, layout, resources, treeTransforms) break } } @@ -217,6 +223,7 @@ export const PageTypeDispatcher: QuartzEmitterPlugin> allFilesWithVirtual, ve.layout, resources, + treeTransforms, ) } }, @@ -225,6 +232,11 @@ export const PageTypeDispatcher: QuartzEmitterPlugin> const cfg = ctx.cfg.configuration const allFiles = content.map((c) => c[1].data) + // Collect tree transforms from all page type plugins + const treeTransforms: TreeTransform[] = pageTypes.flatMap( + (pt) => pt.treeTransforms?.(ctx) ?? [], + ) + // Rebuild trie on partial emit to reflect file changes ctx.trie = trieFromAllFiles(allFiles) @@ -278,7 +290,7 @@ export const PageTypeDispatcher: QuartzEmitterPlugin> for (const pt of pageTypes) { if (pt.match({ slug, fileData, cfg })) { const layout = resolveLayout(pt, defaults, byPageType) - yield emitPage(ctx, slug, tree, fileData, allFilesWithVirtual, layout, resources) + yield emitPage(ctx, slug, tree, fileData, allFilesWithVirtual, layout, resources, treeTransforms) break } } @@ -294,6 +306,7 @@ export const PageTypeDispatcher: QuartzEmitterPlugin> allFilesWithVirtual, ve.layout, resources, + treeTransforms, ) } }, diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index 5e1cb0fd6..8b5924f32 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -1,11 +1,12 @@ import { PluggableList } from "unified" import { StaticResources } from "../util/resources" import { ProcessedContent, QuartzPluginData } from "./vfile" -import { QuartzComponent, QuartzComponentConstructor } from "../components/types" -import { FilePath } from "../util/path" +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../components/types" +import { FilePath, FullSlug } from "../util/path" import { BuildCtx } from "../util/ctx" import { GlobalConfiguration } from "../cfg" import { VFile } from "vfile" +import { Root } from "hast" export interface PluginTypes { transformers: QuartzTransformerPluginInstance[] @@ -90,6 +91,9 @@ export type PageGenerator = (args: { [key: string]: unknown }) => VirtualPage[] +/** A function that mutates a HAST tree at render time, when allFiles is available. */ +export type TreeTransform = (root: Root, slug: FullSlug, componentData: QuartzComponentProps) => void + export type QuartzPageTypePlugin = ( opts?: Options, ) => QuartzPageTypePluginInstance @@ -104,6 +108,8 @@ export interface QuartzPageTypePluginInstance { /** Optional page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */ frame?: string body: QuartzComponentConstructor + /** Optional render-time HAST tree transforms (e.g. resolving inline codeblocks). */ + treeTransforms?: (ctx: BuildCtx) => TreeTransform[] } // Structural supertype accepted in plugin configuration arrays. @@ -122,4 +128,5 @@ export interface PageTypePluginEntry { /** Optional page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */ frame?: string body: QuartzComponentConstructor + treeTransforms?: (...args: never[]) => TreeTransform[] }