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 position: beforeBody
priority: 15 priority: 15
display: all display: all
- source: github:quartz-community/external-quartz-leaflet-map-plugin
enabled: true
options: {}
order: 50
layout: layout:
groups: groups:
toolbar: toolbar:

View File

@ -22,8 +22,8 @@
"bases-page": { "bases-page": {
"source": "github:quartz-community/bases-page", "source": "github:quartz-community/bases-page",
"resolved": "https://github.com/quartz-community/bases-page.git", "resolved": "https://github.com/quartz-community/bases-page.git",
"commit": "7f6439ed711f4aa9d38c5017e88a7a9757cd2d5c", "commit": "c00c56b7d1aef0c07846b8772a3b9303ad03d816",
"installedAt": "2026-02-28T20:09:14.396Z" "installedAt": "2026-03-07T23:21:54.535Z"
}, },
"breadcrumbs": { "breadcrumbs": {
"source": "github:quartz-community/breadcrumbs", "source": "github:quartz-community/breadcrumbs",
@ -109,6 +109,12 @@
"commit": "b70d50a96c00e4726fb08614dc4fab116b1c5ca9", "commit": "b70d50a96c00e4726fb08614dc4fab116b1c5ca9",
"installedAt": "2026-02-28T20:09:21.150Z" "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": { "favicon": {
"source": "github:quartz-community/favicon", "source": "github:quartz-community/favicon",
"resolved": "https://github.com/quartz-community/favicon.git", "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" }) execSync("npm install", { cwd: pluginDir, stdio: "ignore" })
console.log(styleText("cyan", `${name}: building...`)) console.log(styleText("cyan", `${name}: building...`))
execSync("npm run build", { cwd: pluginDir, stdio: "ignore" }) 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 return true
} catch (error) { } catch (error) {
console.log(styleText("red", `${name}: build failed`)) console.log(styleText("red", `${name}: build failed`))
@ -35,6 +42,66 @@ function needsBuild(pluginDir) {
return !fs.existsSync(distDir) 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) { function parseExportsFromDts(content) {
const exports = [] const exports = []
const exportMatches = content.matchAll(/export\s*{\s*([^}]+)\s*}(?:\s*from\s*['"]([^'"]+)['"])?/g) 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 { i18n } from "../i18n"
import { styleText } from "util" import { styleText } from "util"
import { resolveFrame } from "./frames" import { resolveFrame } from "./frames"
import type { TreeTransform } from "../plugins/types"
interface RenderComponents { interface RenderComponents {
head: QuartzComponent head: QuartzComponent
@ -231,6 +232,7 @@ export function renderPage(
componentData: QuartzComponentProps, componentData: QuartzComponentProps,
components: RenderComponents, components: RenderComponents,
pageResources: StaticResources, pageResources: StaticResources,
treeTransforms?: TreeTransform[],
): string { ): string {
// make a deep copy of the tree so we don't remove the transclusion references // make a deep copy of the tree so we don't remove the transclusion references
// for the file cached in contentMap in build.ts // for the file cached in contentMap in build.ts
@ -238,6 +240,13 @@ export function renderPage(
const visited = new Set<FullSlug>([slug]) const visited = new Set<FullSlug>([slug])
renderTranscludes(root, cfg, slug, componentData, visited) 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 // set componentData.tree to the edited html that has transclusions rendered
componentData.tree = root componentData.tree = root

View File

@ -264,58 +264,54 @@ export async function loadQuartzConfig(
for (const entry of enabledEntries) { for (const entry of enabledEntries) {
const manifest = manifests.get(entry.source) const manifest = manifests.get(entry.source)
const category = manifest?.category const category = manifest?.category
// Resolve processing category: for array categories (e.g. ["transformer", "component"]), // Resolve processing categories: for array categories (e.g. ["transformer", "pageType", "component"]),
// find the first processing category. "component" is handled separately via loadComponentsFromPackage. // 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 const processingCategories = ["transformer", "filter", "emitter", "pageType"] as const
let resolvedCategory: string | undefined const categoryMap: Record<string, typeof transformers> = {
if (Array.isArray(category)) { transformer: transformers,
resolvedCategory = category.find((c) => filter: filters,
(processingCategories as readonly string[]).includes(c), emitter: emitters,
) pageType: pageTypes,
} else {
resolvedCategory = category
} }
switch (resolvedCategory) { const categories = Array.isArray(category) ? category : category ? [category] : []
case "transformer": const matchedProcessing = categories.filter((c) =>
transformers.push({ entry, manifest }) (processingCategories as readonly string[]).includes(c),
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"))
if (isComponentOnly) { if (matchedProcessing.length > 0) {
if (manifest?.components && Object.keys(manifest.components).length > 0) { for (const cat of matchedProcessing) {
await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) categoryMap[cat].push({ entry, manifest })
} }
if (manifest?.frames && Object.keys(manifest.frames).length > 0) { } else {
await loadFramesFromPackage(gitSpec.name, manifest, gitSpec.subdir) const gitSpec = parsePluginSource(entry.source)
} const isComponentOnly =
break 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) const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir)
try { try {
const module = await import(toFileUrl(entryPoint)) const module = await import(toFileUrl(entryPoint))
const detected = detectCategoryFromModule(module) const detected = detectCategoryFromModule(module)
if (detected) { if (detected) {
const target = { categoryMap[detected].push({ entry, manifest })
transformer: transformers,
filter: filters,
emitter: emitters,
pageType: pageTypes,
}[detected]
target.push({ entry, manifest })
} else if (manifest?.components && Object.keys(manifest.components).length > 0) { } else if (manifest?.components && Object.keys(manifest.components).length > 0) {
await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir)
if (manifest?.frames && Object.keys(manifest.frames).length > 0) { 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 { QuartzComponent, QuartzComponentProps } from "../../components/types"
import { pageResources, renderPage } from "../../components/renderPage" import { pageResources, renderPage } from "../../components/renderPage"
import { FullPageLayout } from "../../cfg" import { FullPageLayout } from "../../cfg"
@ -74,6 +74,7 @@ async function emitPage(
allFiles: ProcessedContent[1]["data"][], allFiles: ProcessedContent[1]["data"][],
layout: FullPageLayout, layout: FullPageLayout,
resources: StaticResources, resources: StaticResources,
treeTransforms?: TreeTransform[],
) { ) {
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
// For the 404 page, use an absolute base path so assets resolve correctly // For the 404 page, use an absolute base path so assets resolve correctly
@ -95,7 +96,7 @@ async function emitPage(
return write({ return write({
ctx, ctx,
content: renderPage(cfg, slug, componentData, layout, externalResources), content: renderPage(cfg, slug, componentData, layout, externalResources, treeTransforms),
slug, slug,
ext: ".html", ext: ".html",
}) })
@ -157,6 +158,11 @@ export const PageTypeDispatcher: QuartzEmitterPlugin<Partial<DispatcherOptions>>
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const allFiles = content.map((c) => c[1].data) 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) // Ensure trie is available for components that need folder hierarchy (e.g. FolderContent)
ctx.trie ??= trieFromAllFiles(allFiles) ctx.trie ??= trieFromAllFiles(allFiles)
@ -201,7 +207,7 @@ export const PageTypeDispatcher: QuartzEmitterPlugin<Partial<DispatcherOptions>>
for (const pt of pageTypes) { for (const pt of pageTypes) {
if (pt.match({ slug, fileData, cfg })) { if (pt.match({ slug, fileData, cfg })) {
const layout = resolveLayout(pt, defaults, byPageType) 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 break
} }
} }
@ -217,6 +223,7 @@ export const PageTypeDispatcher: QuartzEmitterPlugin<Partial<DispatcherOptions>>
allFilesWithVirtual, allFilesWithVirtual,
ve.layout, ve.layout,
resources, resources,
treeTransforms,
) )
} }
}, },
@ -225,6 +232,11 @@ export const PageTypeDispatcher: QuartzEmitterPlugin<Partial<DispatcherOptions>>
const cfg = ctx.cfg.configuration const cfg = ctx.cfg.configuration
const allFiles = content.map((c) => c[1].data) 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 // Rebuild trie on partial emit to reflect file changes
ctx.trie = trieFromAllFiles(allFiles) ctx.trie = trieFromAllFiles(allFiles)
@ -278,7 +290,7 @@ export const PageTypeDispatcher: QuartzEmitterPlugin<Partial<DispatcherOptions>>
for (const pt of pageTypes) { for (const pt of pageTypes) {
if (pt.match({ slug, fileData, cfg })) { if (pt.match({ slug, fileData, cfg })) {
const layout = resolveLayout(pt, defaults, byPageType) 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 break
} }
} }
@ -294,6 +306,7 @@ export const PageTypeDispatcher: QuartzEmitterPlugin<Partial<DispatcherOptions>>
allFilesWithVirtual, allFilesWithVirtual,
ve.layout, ve.layout,
resources, resources,
treeTransforms,
) )
} }
}, },

View File

@ -1,11 +1,12 @@
import { PluggableList } from "unified" import { PluggableList } from "unified"
import { StaticResources } from "../util/resources" import { StaticResources } from "../util/resources"
import { ProcessedContent, QuartzPluginData } from "./vfile" import { ProcessedContent, QuartzPluginData } from "./vfile"
import { QuartzComponent, QuartzComponentConstructor } from "../components/types" import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../components/types"
import { FilePath } from "../util/path" import { FilePath, FullSlug } from "../util/path"
import { BuildCtx } from "../util/ctx" import { BuildCtx } from "../util/ctx"
import { GlobalConfiguration } from "../cfg" import { GlobalConfiguration } from "../cfg"
import { VFile } from "vfile" import { VFile } from "vfile"
import { Root } from "hast"
export interface PluginTypes { export interface PluginTypes {
transformers: QuartzTransformerPluginInstance[] transformers: QuartzTransformerPluginInstance[]
@ -90,6 +91,9 @@ export type PageGenerator = (args: {
[key: string]: unknown [key: string]: unknown
}) => VirtualPage[] }) => 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> = ( export type QuartzPageTypePlugin<Options extends OptionType = undefined> = (
opts?: Options, opts?: Options,
) => QuartzPageTypePluginInstance ) => QuartzPageTypePluginInstance
@ -104,6 +108,8 @@ export interface QuartzPageTypePluginInstance {
/** Optional page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */ /** Optional page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */
frame?: string frame?: string
body: QuartzComponentConstructor body: QuartzComponentConstructor
/** Optional render-time HAST tree transforms (e.g. resolving inline codeblocks). */
treeTransforms?: (ctx: BuildCtx) => TreeTransform[]
} }
// Structural supertype accepted in plugin configuration arrays. // 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". */ /** Optional page frame name (e.g. "default", "full-width", "minimal"). Defaults to "default". */
frame?: string frame?: string
body: QuartzComponentConstructor body: QuartzComponentConstructor
treeTransforms?: (...args: never[]) => TreeTransform[]
} }