feat: add TreeTransform hook, fix multi-category plugins, and resolve cross-plugin dependencies

- Add TreeTransform type and treeTransforms hook to pageType plugins, enabling
  render-time HAST tree mutations (e.g. bases-page inline codeblock resolution)
- Fix config-loader to push multi-category plugins into ALL matching processing
  buckets instead of only the first match
- Add side-effect import for component-only plugins so view registrations
  (e.g. leaflet-map via globalThis ViewRegistry) execute at load time
- Add npm prune --omit=dev and cross-plugin peer dependency symlinking to
  buildPlugin() to prevent duplicate-singleton issues from nested node_modules
This commit is contained in:
saberzero1 2026-03-08 10:54:55 +01:00
parent 28fe1d55d3
commit 472b337d92
No known key found for this signature in database
7 changed files with 152 additions and 50 deletions

View File

@ -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:

View File

@ -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",

View File

@ -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)

View File

@ -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<FullSlug>([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

View File

@ -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<string, typeof transformers> = {
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) {

View File

@ -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<Partial<DispatcherOptions>>
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<Partial<DispatcherOptions>>
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<Partial<DispatcherOptions>>
allFilesWithVirtual,
ve.layout,
resources,
treeTransforms,
)
}
},
@ -225,6 +232,11 @@ export const PageTypeDispatcher: QuartzEmitterPlugin<Partial<DispatcherOptions>>
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<Partial<DispatcherOptions>>
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<Partial<DispatcherOptions>>
allFilesWithVirtual,
ve.layout,
resources,
treeTransforms,
)
}
},

View File

@ -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<Options extends OptionType = undefined> = (
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[]
}