mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-21 21:45:42 -05:00
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:
parent
28fe1d55d3
commit
472b337d92
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@ -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[]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user