diff --git a/.changeset/forward-plugin-blocks.md b/.changeset/forward-plugin-blocks.md new file mode 100644 index 000000000..aad37907d --- /dev/null +++ b/.changeset/forward-plugin-blocks.md @@ -0,0 +1,9 @@ +--- +"emdash": minor +--- + +Forward declarative `portableTextBlocks` and `fieldWidgets` from standard and sandboxed plugins + +Standard- and sandboxed-format plugins could already declare admin pages and dashboard widgets, but their declarative Portable Text block types and field widgets were dropped during adaptation — only native-format plugins surfaced them. Since the admin editor reads these from the manifest, the slash-menu entries and Block Kit forms never appeared for non-native plugins. + +`adaptSandboxEntry` now forwards both, and the admin manifest is emitted for sandboxed and marketplace plugins too, so a plugin of any format can contribute Portable Text blocks and field widgets. The site-side render component (`componentsEntry`) still requires native format, which is unchanged. diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 61d504a9c..00c200a01 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -10,7 +10,11 @@ import type { AuthDescriptor, AuthProviderDescriptor } from "../../auth/types.js"; import type { DatabaseDescriptor } from "../../db/adapters.js"; import type { MediaProviderDescriptor } from "../../media/types.js"; -import type { ResolvedPlugin } from "../../plugins/types.js"; +import type { + FieldWidgetConfig, + PortableTextBlockConfig, + ResolvedPlugin, +} from "../../plugins/types.js"; import type { ExperimentalConfig } from "../../registry/types.js"; import type { StorageDescriptor } from "../storage/types.js"; @@ -98,6 +102,15 @@ export interface PluginDescriptor> { adminPages?: PluginAdminPage[]; /** Dashboard widgets */ adminWidgets?: PluginDashboardWidget[]; + /** + * Portable Text block types this plugin contributes to the editor. + * Declarative (Block Kit) — surfaced in the admin slash menu and consumed + * from the manifest, so standard/sandboxed plugins can contribute blocks + * without a native render component. + */ + portableTextBlocks?: PortableTextBlockConfig[]; + /** Field widget types this plugin contributes for schema-field editing UIs. */ + fieldWidgets?: FieldWidgetConfig[]; // === Sandbox-specific fields (for sandboxed plugins) === diff --git a/packages/core/src/astro/integration/virtual-modules.ts b/packages/core/src/astro/integration/virtual-modules.ts index 812d393be..8c0f62727 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -220,6 +220,8 @@ export function generatePluginsModule(descriptors: PluginDescriptor[]): string { storage: descriptor.storage, adminPages: descriptor.adminPages, adminWidgets: descriptor.adminWidgets, + portableTextBlocks: descriptor.portableTextBlocks, + fieldWidgets: descriptor.fieldWidgets, })})`, ); } else { @@ -589,6 +591,8 @@ export const sandboxedPlugins = []; storage: ${JSON.stringify(descriptor.storage ?? {})}, adminPages: ${JSON.stringify(descriptor.adminPages ?? [])}, adminWidgets: ${JSON.stringify(descriptor.adminWidgets ?? [])}, + portableTextBlocks: ${JSON.stringify(descriptor.portableTextBlocks ?? [])}, + fieldWidgets: ${JSON.stringify(descriptor.fieldWidgets ?? [])}, adminEntry: ${JSON.stringify(descriptor.adminEntry)}, // Code read from: ${filePath} code: ${JSON.stringify(code)}, diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 247b8bbaa..5ac877894 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -41,6 +41,8 @@ import type { PublicPageContext, PageMetadataContribution, PageFragmentContribution, + PortableTextBlockConfig, + FieldWidgetConfig, } from "./plugins/types.js"; import type { FieldType } from "./schema/types.js"; import { hashString } from "./utils/hash.js"; @@ -211,6 +213,10 @@ export interface SandboxedPluginEntry { adminPages?: Array<{ path: string; label?: string; icon?: string }>; /** Dashboard widgets */ adminWidgets?: Array<{ id: string; title?: string; size?: string }>; + /** Portable Text block types contributed to the editor (declarative Block Kit) */ + portableTextBlocks?: PortableTextBlockConfig[]; + /** Field widget types contributed for schema-field editing UIs */ + fieldWidgets?: FieldWidgetConfig[]; /** Admin entry module */ adminEntry?: string; /** @@ -383,7 +389,12 @@ const marketplaceManifestCache = new Map< { id: string; version: string; - admin?: { pages?: PluginAdminPage[]; widgets?: PluginDashboardWidget[] }; + admin?: { + pages?: PluginAdminPage[]; + widgets?: PluginDashboardWidget[]; + portableTextBlocks?: PortableTextBlockConfig[]; + fieldWidgets?: FieldWidgetConfig[]; + }; } >(); /** Route metadata for sandboxed plugins: pluginId -> routeName -> RouteMeta */ @@ -928,6 +939,8 @@ export class EmDashRuntime { size: w.size === "full" || w.size === "half" || w.size === "third" ? w.size : undefined, })), + portableTextBlocks: bundle.manifest.admin?.portableTextBlocks, + fieldWidgets: bundle.manifest.admin?.fieldWidgets, }); newPlugins.push(adapted); this.allPipelinePlugins.push(adapted); @@ -1569,6 +1582,8 @@ export class EmDashRuntime { storage: entry.storage as never, adminPages, adminWidgets, + portableTextBlocks: entry.portableTextBlocks, + fieldWidgets: entry.fieldWidgets, }); plugins.push(resolved); console.log( @@ -1865,6 +1880,8 @@ export class EmDashRuntime { size: w.size === "full" || w.size === "half" || w.size === "third" ? w.size : undefined, })), + portableTextBlocks: bundle.manifest.admin?.portableTextBlocks, + fieldWidgets: bundle.manifest.admin?.fieldWidgets, }); resolved.push(adapted); console.log( @@ -2095,9 +2112,6 @@ export class EmDashRuntime { } // Add sandboxed plugins (use entries for admin config) - // TODO: sandboxed plugins need fieldWidgets extracted from their manifest - // to support Block Kit field widgets. Currently only trusted plugins carry - // fieldWidgets through the admin.fieldWidgets path. for (const entry of this.sandboxedPluginEntries) { const status = this.pluginStates.get(entry.id); const enabled = status === undefined || status === "active"; @@ -2112,6 +2126,8 @@ export class EmDashRuntime { adminMode: hasAdminPages || hasWidgets ? "blocks" : "none", adminPages: entry.adminPages ?? [], dashboardWidgets: entry.adminWidgets ?? [], + portableTextBlocks: entry.portableTextBlocks, + fieldWidgets: entry.fieldWidgets, }; } @@ -2135,6 +2151,8 @@ export class EmDashRuntime { adminMode: hasAdminPages || hasWidgets ? "blocks" : "none", adminPages: pages ?? [], dashboardWidgets: widgets ?? [], + portableTextBlocks: meta.admin?.portableTextBlocks, + fieldWidgets: meta.admin?.fieldWidgets, }; } diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index aa5c8773e..760b4e42c 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -278,7 +278,11 @@ export function adaptSandboxEntry( }; } - // Build admin config from descriptor + // Build admin config from descriptor. + // Portable Text blocks and field widgets are declarative (Block Kit), so they + // are forwarded for standard/sandboxed plugins just like pages and widgets — + // the admin editor consumes them from the manifest. Only the site-side render + // component (`componentsEntry`) stays native-only. const admin: PluginAdminConfig = {}; if (descriptor.adminPages) { admin.pages = descriptor.adminPages; @@ -286,6 +290,12 @@ export function adaptSandboxEntry( if (descriptor.adminWidgets) { admin.widgets = descriptor.adminWidgets; } + if (descriptor.portableTextBlocks) { + admin.portableTextBlocks = descriptor.portableTextBlocks; + } + if (descriptor.fieldWidgets) { + admin.fieldWidgets = descriptor.fieldWidgets; + } return { id: pluginId, diff --git a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts index 4e393ea2b..d0451f39a 100644 --- a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts +++ b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts @@ -121,6 +121,63 @@ describe("adaptSandboxEntry", () => { expect(result.admin.widgets).toEqual([{ id: "status", title: "Status", size: "half" }]); }); + + it("carries portable text blocks from descriptor", () => { + const def: SandboxedPlugin = {}; + const descriptor = createDescriptor({ + portableTextBlocks: [ + { + type: "faq", + label: "FAQ", + icon: "list", + category: "Sections", + fields: [ + { + type: "repeater", + action_id: "items", + label: "Questions", + item_label: "Question", + fields: [ + { type: "text_input", action_id: "q", label: "Question" }, + { type: "text_input", action_id: "a", label: "Answer", multiline: true }, + ], + }, + ], + }, + ], + }); + + const result = adaptSandboxEntry(def, descriptor); + + expect(result.admin.portableTextBlocks).toEqual(descriptor.portableTextBlocks); + }); + + it("carries field widgets from descriptor", () => { + const def: SandboxedPlugin = {}; + const descriptor = createDescriptor({ + fieldWidgets: [ + { + name: "color-picker", + label: "Color Picker", + fieldTypes: ["string"], + }, + ], + }); + + const result = adaptSandboxEntry(def, descriptor); + + expect(result.admin.fieldWidgets).toEqual(descriptor.fieldWidgets); + }); + + it("leaves admin block config undefined when the descriptor omits it", () => { + const def: SandboxedPlugin = {}; + const descriptor = createDescriptor(); + + const result = adaptSandboxEntry(def, descriptor); + + expect(result.admin.portableTextBlocks).toBeUndefined(); + expect(result.admin.fieldWidgets).toBeUndefined(); + }); }); describe("hook adaptation", () => {