From 0007ed3f29010defbc9f3109968b08c28cadc92f Mon Sep 17 00:00:00 2001 From: saberzero1 Date: Mon, 23 Feb 2026 23:47:50 +0100 Subject: [PATCH] fix: plugin mapping from configuration --- quartz.config.ts | 153 +---------------------- quartz.layout.ts | 152 +--------------------- quartz/components/registry.ts | 28 ++++- quartz/plugins/loader/componentLoader.ts | 55 ++++++-- quartz/plugins/loader/config-loader.ts | 130 ++++++++++++++----- quartz/plugins/loader/gitLoader.ts | 87 +++++++++++-- quartz/plugins/loader/types.ts | 2 +- 7 files changed, 250 insertions(+), 357 deletions(-) diff --git a/quartz.config.ts b/quartz.config.ts index 64d92a2b3..ccc56bb2d 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -1,151 +1,2 @@ -import { QuartzConfig } from "./quartz/cfg" -import * as Plugin from "./quartz/plugins" -import * as ExternalPlugin from "./.quartz/plugins" -import { layout } from "./quartz.layout" - -const config: QuartzConfig = { - configuration: { - pageTitle: "Quartz 5", - pageTitleSuffix: "", - enableSPA: true, - enablePopovers: true, - analytics: { - provider: "plausible", - }, - locale: "en-US", - baseUrl: "quartz.jzhao.xyz", - ignorePatterns: ["private", "templates", ".obsidian"], - defaultDateType: "modified", - theme: { - fontOrigin: "googleFonts", - cdnCaching: true, - typography: { - header: "Schibsted Grotesk", - body: "Source Sans Pro", - code: "IBM Plex Mono", - }, - colors: { - lightMode: { - light: "#faf8f8", - lightgray: "#e5e5e5", - gray: "#b8b8b8", - darkgray: "#4e4e4e", - dark: "#2b2b2b", - secondary: "#284b63", - tertiary: "#84a59d", - highlight: "rgba(143, 159, 169, 0.15)", - textHighlight: "#fff23688", - }, - darkMode: { - light: "#161618", - lightgray: "#393639", - gray: "#646464", - darkgray: "#d4d4d4", - dark: "#ebebec", - secondary: "#7b97aa", - tertiary: "#84a59d", - highlight: "rgba(143, 159, 169, 0.15)", - textHighlight: "#b3aa0288", - }, - }, - }, - }, - plugins: { - transformers: [ - ExternalPlugin.NoteProperties({ - includeAll: false, - includedProperties: ["description", "tags", "aliases"], - excludedProperties: [], - hidePropertiesView: false, - delimiters: "---", - language: "yaml", - }), - ExternalPlugin.CreatedModifiedDate({ - priority: ["frontmatter", "git", "filesystem"], - }), - ExternalPlugin.SyntaxHighlighting({ - theme: { - light: "github-light", - dark: "github-dark", - }, - keepBackground: false, - }), - ExternalPlugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false, enableCheckbox: true }), - ExternalPlugin.GitHubFlavoredMarkdown(), - ExternalPlugin.TableOfContentsTransformer(), - ExternalPlugin.CrawlLinks({ markdownLinkResolution: "shortest" }), - ExternalPlugin.Description(), - ExternalPlugin.Latex({ renderEngine: "katex" }), - ], - filters: [ExternalPlugin.RemoveDrafts()], - emitters: [ - ExternalPlugin.AliasRedirects(), - Plugin.ComponentResources(), - ExternalPlugin.ContentIndex({ - enableSiteMap: true, - enableRSS: true, - }), - Plugin.Assets(), - Plugin.Static(), - ExternalPlugin.Favicon(), - Plugin.PageTypes.PageTypeDispatcher({ - defaults: layout.defaults, - byPageType: layout.byPageType, - }), - ExternalPlugin.CustomOgImages(), - ExternalPlugin.CNAME(), - ], - pageTypes: [ - ExternalPlugin.CanvasPage(), - ExternalPlugin.BasesPage(), - ExternalPlugin.ContentPage(), - ExternalPlugin.FolderPage(), - ExternalPlugin.TagPage(), - Plugin.PageTypes.NotFoundPageType(), - ], - }, - externalPlugins: [ - "github:quartz-community/explorer", - "github:quartz-community/graph", - "github:quartz-community/search", - "github:quartz-community/table-of-contents", - "github:quartz-community/backlinks", - "github:quartz-community/comments", - "github:quartz-community/article-title", - "github:quartz-community/tag-list", - "github:quartz-community/page-title", - "github:quartz-community/darkmode", - "github:quartz-community/reader-mode", - "github:quartz-community/content-meta", - "github:quartz-community/footer", - "github:quartz-community/content-page", - "github:quartz-community/folder-page", - "github:quartz-community/tag-page", - "github:quartz-community/latex", - "github:quartz-community/created-modified-date", - "github:quartz-community/syntax-highlighting", - "github:quartz-community/obsidian-flavored-markdown", - "github:quartz-community/github-flavored-markdown", - "github:quartz-community/crawl-links", - "github:quartz-community/description", - "github:quartz-community/hard-line-breaks", - "github:quartz-community/citations", - "github:quartz-community/ox-hugo", - "github:quartz-community/roam", - "github:quartz-community/remove-draft", - "github:quartz-community/explicit-publish", - "github:quartz-community/alias-redirects", - "github:quartz-community/cname", - "github:quartz-community/favicon", - "github:quartz-community/content-index", - "github:quartz-community/og-image", - "github:quartz-community/canvas-page", - "github:quartz-community/note-properties", - "github:quartz-community/bases-page", - "github:quartz-community/breadcrumbs", - "github:quartz-community/spacer", - "github:quartz-community/recent-notes", - ], -} - -export default config +import { loadQuartzConfig } from "./quartz/plugins/loader/config-loader" +export default await loadQuartzConfig() diff --git a/quartz.layout.ts b/quartz.layout.ts index 3d7e606e9..cffc6ba24 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -1,150 +1,2 @@ -import { FullPageLayout } from "./quartz/cfg" -import * as Component from "./quartz/components" -import * as Plugin from "./.quartz/plugins" - -// Create plugin components once and reuse across layouts -const explorerComponent = Plugin.Explorer() -const graphComponent = Plugin.Graph() -const searchComponent = Plugin.Search() -const backlinksComponent = Plugin.Backlinks() -const tocComponent = Plugin.TableOfContents() -const articleTitleComponent = Plugin.ArticleTitle() -const contentMetaComponent = Plugin.ContentMeta() -const tagListComponent = Plugin.TagList() -const pageTitleComponent = Plugin.PageTitle() -const darkmodeComponent = Plugin.Darkmode() -const readerModeComponent = Plugin.ReaderMode() -const breadcrumbsComponent = Plugin.Breadcrumbs() -const notePropertiesComponent = Plugin.NotePropertiesComponent() - -export const layout: { - defaults: Partial - byPageType: Record> -} = { - // Components shared across all page types (can be overridden per page type) - defaults: { - head: Component.Head(), - header: [], - afterBody: [ - // Plugin.Comments({ - // provider: "giscus", - // options: {}}), - ], - footer: Plugin.Footer({ - links: { - GitHub: "https://github.com/jackyzha0/quartz", - "Discord Community": "https://discord.gg/cRFFHYye7t", - }, - }), - }, - - // Per-page-type layout overrides - byPageType: { - // Content pages (single notes) - content: { - beforeBody: [ - Component.ConditionalRender({ - component: breadcrumbsComponent, - condition: (page) => page.fileData.slug !== "index", - }), - articleTitleComponent, - notePropertiesComponent, - contentMetaComponent, - tagListComponent, - ], - left: [ - pageTitleComponent, - Component.MobileOnly(Component.Spacer()), - Component.Flex({ - components: [ - { - Component: searchComponent, - grow: true, - }, - { Component: darkmodeComponent }, - { Component: readerModeComponent }, - ], - }), - explorerComponent, - ], - right: [graphComponent, Component.DesktopOnly(tocComponent), backlinksComponent], - }, - - // Folder listing pages - folder: { - beforeBody: [ - breadcrumbsComponent, - articleTitleComponent, - notePropertiesComponent, - contentMetaComponent, - ], - left: [ - pageTitleComponent, - Component.MobileOnly(Component.Spacer()), - Component.Flex({ - components: [ - { - Component: searchComponent, - grow: true, - }, - { Component: darkmodeComponent }, - ], - }), - explorerComponent, - ], - right: [], - }, - - // Tag listing pages - tag: { - beforeBody: [ - breadcrumbsComponent, - articleTitleComponent, - notePropertiesComponent, - contentMetaComponent, - ], - left: [ - pageTitleComponent, - Component.MobileOnly(Component.Spacer()), - Component.Flex({ - components: [ - { - Component: searchComponent, - grow: true, - }, - { Component: darkmodeComponent }, - ], - }), - explorerComponent, - ], - right: [], - }, - - // 404 page — minimal layout - "404": { - beforeBody: [], - left: [], - right: [], - }, - - // Canvas pages — expansive layout, no sidebars - canvas: { - beforeBody: [breadcrumbsComponent, articleTitleComponent, notePropertiesComponent], - left: [ - pageTitleComponent, - Component.MobileOnly(Component.Spacer()), - Component.Flex({ - components: [ - { - Component: searchComponent, - grow: true, - }, - { Component: darkmodeComponent }, - ], - }), - explorerComponent, - ], - right: [graphComponent, Component.DesktopOnly(tocComponent), backlinksComponent], - }, - }, -} +import { loadQuartzLayout } from "./quartz/plugins/loader/config-loader" +export const layout = await loadQuartzLayout() diff --git a/quartz/components/registry.ts b/quartz/components/registry.ts index 1fd5ab484..65c07ea1a 100644 --- a/quartz/components/registry.ts +++ b/quartz/components/registry.ts @@ -25,7 +25,8 @@ class ComponentRegistry { source: string, manifest?: ComponentManifest, ): void { - if (this.components.has(name)) { + const existing = this.components.get(name) + if (existing && existing.source !== source) { console.warn(`Component "${name}" is being overwritten by ${source}`) } this.components.set(name, { component, source, manifest }) @@ -40,12 +41,27 @@ class ComponentRegistry { } getAllComponents(): QuartzComponent[] { - return Array.from(this.components.values()).map((r) => { - if (typeof r.component === "function") { - return (r.component as QuartzComponentConstructor)(undefined) + // Deduplicate by component reference (same constructor may be registered under multiple keys) + const seen = new Set() + const results: QuartzComponent[] = [] + for (const r of this.components.values()) { + if (seen.has(r.component)) continue + seen.add(r.component) + try { + let instance: QuartzComponent + if (typeof r.component === "function") { + instance = (r.component as QuartzComponentConstructor)(undefined) + } else { + instance = r.component as QuartzComponent + } + if (instance) { + results.push(instance) + } + } catch { + // Skip components that fail to instantiate } - return r.component as QuartzComponent - }) + } + return results } } diff --git a/quartz/plugins/loader/componentLoader.ts b/quartz/plugins/loader/componentLoader.ts index 0abe4c901..65b7816f4 100644 --- a/quartz/plugins/loader/componentLoader.ts +++ b/quartz/plugins/loader/componentLoader.ts @@ -1,39 +1,72 @@ import { componentRegistry } from "../../components/registry" import { ComponentManifest, PluginManifest } from "./types" import { QuartzComponentConstructor } from "../../components/types" +import { getPluginSubpathEntry } from "./gitLoader" export async function loadComponentsFromPackage( - packageName: string, + pluginName: string, manifest: PluginManifest | null, + subdir?: string, ): Promise { if (!manifest?.components) return try { - const componentsModule = await import(`${packageName}/components`) + const componentsPath = getPluginSubpathEntry(pluginName, "./components", subdir) - for (const [exportName, componentManifest] of Object.entries(manifest.components)) { + let componentsModule: Record + if (componentsPath) { + componentsModule = await import(componentsPath) + } else { + componentsModule = await import(`${pluginName}/components`) + } + + const componentEntries = Object.entries(manifest.components) + for (const [exportName, componentManifest] of componentEntries) { const component = componentsModule[exportName] if (!component) { console.warn( - `Component "${exportName}" declared in manifest but not found in ${packageName}/components`, + `Component "${exportName}" declared in manifest but not found in ${pluginName}/components`, ) continue } - const fullName = `${packageName}/${exportName}` + // Register under the fully-qualified key (pluginName/exportName) componentRegistry.register( - fullName, + `${pluginName}/${exportName}`, component as QuartzComponentConstructor, - packageName, + pluginName, componentManifest as ComponentManifest, ) + + // Also register under just the export name (e.g. "Footer", "NotePropertiesComponent") + // so buildLayoutForEntries can find it via PascalCase conversion of plugin name + if (!componentRegistry.get(exportName)) { + componentRegistry.register( + exportName, + component as QuartzComponentConstructor, + pluginName, + componentManifest as ComponentManifest, + ) + } + } + + // If plugin has exactly one component, also register under just the plugin name + // (e.g. "footer", "note-properties") for direct kebab-case lookup + if (componentEntries.length === 1) { + const [exportName] = componentEntries[0] + const component = componentsModule[exportName] + if (component && !componentRegistry.get(pluginName)) { + componentRegistry.register( + pluginName, + component as QuartzComponentConstructor, + pluginName, + componentEntries[0][1] as ComponentManifest, + ) + } } } catch { - // Components module doesn't exist, that's okay for plugins without components if (manifest.components && Object.keys(manifest.components).length > 0) { - console.warn( - `Plugin ${packageName} declares components but failed to load them from ${packageName}/components`, - ) + console.warn(`Plugin "${pluginName}" declares components but failed to load them`) } } } diff --git a/quartz/plugins/loader/config-loader.ts b/quartz/plugins/loader/config-loader.ts index 98fa24f67..fd46938b4 100644 --- a/quartz/plugins/loader/config-loader.ts +++ b/quartz/plugins/loader/config-loader.ts @@ -12,7 +12,6 @@ import { LayoutConfig, PluginLayoutDeclaration, FlexGroupConfig, - PluginCategory, } from "./types" import { parsePluginSource, installPlugin, getPluginEntryPoint } from "./gitLoader" import { loadComponentsFromPackage } from "./componentLoader" @@ -284,8 +283,17 @@ export async function loadQuartzConfig(): Promise { pageTypes.push({ entry, manifest }) break default: { - // Try to detect category from the loaded module const gitSpec = parsePluginSource(entry.source) + const isComponentOnly = + resolvedCategory === "component" || + (Array.isArray(category) && category.every((c) => c === "component")) + + if (isComponentOnly) { + if (manifest?.components && Object.keys(manifest.components).length > 0) { + await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) + } + break + } const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) try { const module = await import(entryPoint) @@ -298,6 +306,8 @@ export async function loadQuartzConfig(): Promise { pageType: pageTypes, }[detected] target.push({ entry, manifest }) + } else if (manifest?.components && Object.keys(manifest.components).length > 0) { + await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) } else { console.warn( styleText("yellow", `⚠`) + @@ -305,10 +315,14 @@ export async function loadQuartzConfig(): Promise { ) } } catch { - console.warn( - styleText("yellow", `⚠`) + - ` Could not load plugin "${extractPluginName(entry.source)}" to detect category. Skipping.`, - ) + if (manifest?.components && Object.keys(manifest.components).length > 0) { + await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) + } else { + console.warn( + styleText("yellow", `⚠`) + + ` Could not load plugin "${extractPluginName(entry.source)}" to detect category. Skipping.`, + ) + } } } } @@ -332,6 +346,7 @@ export async function loadQuartzConfig(): Promise { // Instantiate plugins const instantiate = async ( items: { entry: PluginJsonEntry; manifest: PluginManifest | undefined }[], + expectedCategory: ProcessingCategory, ) => { const instances = [] for (const { entry, manifest } of items) { @@ -339,22 +354,18 @@ export async function loadQuartzConfig(): Promise { const gitSpec = parsePluginSource(entry.source) const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) const module = await import(entryPoint) - - // Load components if declared if (manifest?.components && Object.keys(manifest.components).length > 0) { - await loadComponentsFromPackage(entryPoint, manifest) + await loadComponentsFromPackage(gitSpec.name, manifest, gitSpec.subdir) } - const factory = module.default ?? module.plugin - if (typeof factory !== "function") { + const factory = findFactory(module, expectedCategory) + if (!factory) { console.warn( styleText("yellow", `⚠`) + - ` Plugin "${extractPluginName(entry.source)}" has no factory function. Skipping.`, + ` Plugin "${extractPluginName(entry.source)}" has no factory function for category "${expectedCategory}". Skipping.`, ) continue } - - // Merge default options with user options const options = { ...manifest?.defaultOptions, ...entry.options } instances.push(factory(Object.keys(options).length > 0 ? options : undefined)) } catch (err) { @@ -378,19 +389,77 @@ export async function loadQuartzConfig(): Promise { const builtinPageTypes = [builtinPlugins.PageTypes.NotFoundPageType()] const plugins: PluginTypes = { - transformers: [...builtinTransformers, ...(await instantiate(transformers))], - filters: await instantiate(filters), - emitters: [...builtinEmitters, ...(await instantiate(emitters))], - pageTypes: [...(await instantiate(pageTypes)), ...builtinPageTypes], + transformers: [...builtinTransformers, ...(await instantiate(transformers, "transformer"))], + filters: await instantiate(filters, "filter"), + emitters: [...builtinEmitters, ...(await instantiate(emitters, "emitter"))], + pageTypes: [...(await instantiate(pageTypes, "pageType")), ...builtinPageTypes], } + // Load layout and add PageTypeDispatcher to emitters. + // This must happen after plugin instantiation so the component registry is populated. + const layout = await loadQuartzLayout() + plugins.emitters.push( + builtinPlugins.PageTypes.PageTypeDispatcher({ + defaults: layout.defaults, + byPageType: layout.byPageType, + }), + ) return { configuration, plugins, } } -function detectCategoryFromModule(module: unknown): PluginCategory | null { +type ProcessingCategory = "transformer" | "filter" | "emitter" | "pageType" + +function matchesCategory(factory: Function, expected: ProcessingCategory): boolean { + try { + const instance = factory() + if (!instance || typeof instance !== "object") return false + switch (expected) { + case "pageType": + return "match" in instance && "body" in instance && "layout" in instance + case "emitter": + return "emit" in instance + case "filter": + return "shouldPublish" in instance + case "transformer": + return ( + "textTransform" in instance || "markdownPlugins" in instance || "htmlPlugins" in instance + ) + } + } catch { + return false + } +} + +function findFactory( + module: Record, + expectedCategory: ProcessingCategory, +): Function | null { + if ( + typeof module.default === "function" && + matchesCategory(module.default as Function, expectedCategory) + ) { + return module.default as Function + } + if ( + typeof module.plugin === "function" && + matchesCategory(module.plugin as Function, expectedCategory) + ) { + return module.plugin as Function + } + + for (const [, value] of Object.entries(module)) { + if (typeof value === "function" && matchesCategory(value as Function, expectedCategory)) { + return value as Function + } + } + + return null +} + +function detectCategoryFromModule(module: unknown): ProcessingCategory | null { if (!module || typeof module !== "object") return null const mod = module as Record @@ -474,23 +543,24 @@ export async function loadQuartzLayout(): Promise<{ const HeadModule = await import("../../components/Head") const head = HeadModule.default() - // Find footer plugin + // Find footer from component registry (loaded during plugin instantiation) const footerEntry = json.plugins.find( (e) => e.enabled && extractPluginName(e.source) === "footer", ) let footer: QuartzComponent | undefined if (footerEntry) { - try { - const gitSpec = parsePluginSource(footerEntry.source) - const entryPoint = getPluginEntryPoint(gitSpec.name, gitSpec.subdir) - const module = await import(entryPoint) - const factory = module.default ?? module.plugin - if (typeof factory === "function") { - const options = { ...footerEntry.options } - footer = factory(Object.keys(options).length > 0 ? options : undefined) + // Try registry lookup: plugin name ("footer") or export name ("Footer") + const footerReg = componentRegistry.get("footer") ?? componentRegistry.get("Footer") + if (footerReg) { + if (typeof footerReg.component === "function" && !("displayName" in footerReg.component)) { + // It's a constructor, instantiate with options + const opts = { ...footerEntry.options } + footer = (footerReg.component as Function)( + Object.keys(opts).length > 0 ? opts : undefined, + ) as QuartzComponent + } else { + footer = footerReg.component as QuartzComponent } - } catch { - // Footer not available } } diff --git a/quartz/plugins/loader/gitLoader.ts b/quartz/plugins/loader/gitLoader.ts index cc0f3310f..1c709a59c 100644 --- a/quartz/plugins/loader/gitLoader.ts +++ b/quartz/plugins/loader/gitLoader.ts @@ -168,31 +168,102 @@ export function isPluginInstalled(name: string): boolean { } /** - * Get the entry point for a plugin + * Get the entry point for a plugin. + * Prefers compiled dist/ output over raw src/ to avoid ESM resolution issues. */ export function getPluginEntryPoint(name: string, subdir?: string): string { const pluginDir = getPluginDir(name) const searchDir = subdir ? path.join(pluginDir, subdir) : pluginDir + // Check package.json exports first (most reliable) + const pkgJsonPath = path.join(searchDir, "package.json") + if (fs.existsSync(pkgJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")) + const exportEntry = pkg.exports?.["."] + const importPath = typeof exportEntry === "string" ? exportEntry : exportEntry?.import + if (importPath) { + const resolved = path.join(searchDir, importPath) + if (fs.existsSync(resolved)) { + return resolved + } + } + // Fall back to main/module fields + const mainField = pkg.module ?? pkg.main + if (mainField) { + const resolved = path.join(searchDir, mainField) + if (fs.existsSync(resolved)) { + return resolved + } + } + } catch { + // package.json parse error, fall through to candidates + } + } - // Try common entry points + // Try common entry points — prefer compiled dist/ over raw src/ const candidates = [ - path.join(searchDir, "src", "index.ts"), - path.join(searchDir, "src", "index.js"), - path.join(searchDir, "index.ts"), - path.join(searchDir, "index.js"), path.join(searchDir, "dist", "index.js"), + path.join(searchDir, "dist", "index.mjs"), + path.join(searchDir, "index.js"), + path.join(searchDir, "index.ts"), + path.join(searchDir, "src", "index.js"), + path.join(searchDir, "src", "index.ts"), ] - for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate } } - // If no entry found, return the search dir and let Node handle it return searchDir } +/** + * Resolve a subpath export for a plugin (e.g. "./components"). + * Uses package.json exports map, then falls back to dist/ directory structure. + */ +export function getPluginSubpathEntry( + name: string, + subpath: string, + subdir?: string, +): string | null { + const pluginDir = getPluginDir(name) + const searchDir = subdir ? path.join(pluginDir, subdir) : pluginDir + + // Check package.json exports map + const pkgJsonPath = path.join(searchDir, "package.json") + if (fs.existsSync(pkgJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")) + const exportEntry = pkg.exports?.[subpath] + const importPath = typeof exportEntry === "string" ? exportEntry : exportEntry?.import + if (importPath) { + const resolved = path.join(searchDir, importPath) + if (fs.existsSync(resolved)) { + return resolved + } + } + } catch { + // fall through + } + } + + // Fall back: try dist//index.js + const subpathClean = subpath.replace(/^\.\/?/, "") + const fallbackCandidates = [ + path.join(searchDir, "dist", subpathClean, "index.js"), + path.join(searchDir, "dist", `${subpathClean}.js`), + path.join(searchDir, subpathClean, "index.js"), + ] + + for (const candidate of fallbackCandidates) { + if (fs.existsSync(candidate)) { + return candidate + } + } + + return null +} /** * Update all installed plugins */ diff --git a/quartz/plugins/loader/types.ts b/quartz/plugins/loader/types.ts index c321fc70b..e270a8245 100644 --- a/quartz/plugins/loader/types.ts +++ b/quartz/plugins/loader/types.ts @@ -6,7 +6,7 @@ import { } from "../types" import { BuildCtx } from "../../util/ctx" -export type PluginCategory = "transformer" | "filter" | "emitter" | "pageType" +export type PluginCategory = "transformer" | "filter" | "emitter" | "pageType" | "component" export type LayoutPosition = "left" | "right" | "beforeBody" | "afterBody"