Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/bright-panels-edit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@emdash-cms/admin": minor
"@emdash-cms/blocks": minor
"@emdash-cms/plugin-cli": minor
"@emdash-cms/plugin-test": minor
"@emdash-cms/plugin-types": minor
"emdash": minor
---

Adds saved-entry panels and actions for sandboxed plugins. Declare collection-filtered `admin.editorPanels` and `admin.editorActions` entries that point to private plugin routes.

Panels load Block Kit only when an editor opens them. Actions support confirmation and can return a toast, request an entry refresh, or navigate through a structured link target. EmDash reloads and ownership-authorizes the saved entry before invocation, then exposes only its canonical identity, locale, and version through `routeCtx.ui`; unsaved editor values never cross the sandbox boundary.

`createPluginRuntimeTestHost()` includes panel and action helpers that exercise the production authorization, response-validation, and Worker Loader path.
81 changes: 80 additions & 1 deletion apps/release-action/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8081,11 +8081,56 @@ const dashboardWidgetSchema = object({
]).optional(),
title: string().optional()
});
const editorExtensionIdPattern = /^[a-z][a-z0-9_-]*$/;
const editorCollectionsSchema = array(string().max(63).regex(/^[a-z][a-z0-9_]*$/, "Invalid collection slug")).max(64).refine((collections) => new Set(collections).size === collections.length, { message: "Editor extension collections must be unique" });
const editorPanelSchema = object({
id: string().min(1).max(64).regex(editorExtensionIdPattern, "Invalid editor panel id"),
title: string().min(1).max(128),
route: string().min(1).max(128).regex(routeNamePattern, "Route name must be a safe path segment"),
collections: editorCollectionsSchema.optional(),
order: number().int().min(-1e3).max(1e3).optional()
});
const editorActionConfirmSchema = object({
title: string().min(1).max(128),
text: string().min(1).max(1024),
confirm: string().min(1).max(64),
deny: string().min(1).max(64),
style: literal("danger").optional()
});
const editorActionSchema = object({
id: string().min(1).max(64).regex(editorExtensionIdPattern, "Invalid editor action id"),
label: string().min(1).max(128),
route: string().min(1).max(128).regex(routeNamePattern, "Route name must be a safe path segment"),
placement: _enum(["toolbar", "overflow"]),
collections: editorCollectionsSchema.optional(),
style: _enum(["default", "danger"]).optional(),
confirm: editorActionConfirmSchema.optional()
}).refine((action) => action.style !== "danger" || action.confirm !== void 0, {
message: "Danger editor actions require confirmation",
path: ["confirm"]
});
function uniqueExtensionIds(items, ctx, path) {
const seen = /* @__PURE__ */ new Set();
for (const [index, item] of (items ?? []).entries()) {
if (seen.has(item.id)) ctx.addIssue({
code: "custom",
message: `Duplicate ${path} id`,
path: [
path,
index,
"id"
]
});
seen.add(item.id);
}
}
const pluginAdminConfigSchema = object({
entry: string().optional(),
settingsSchema: record(string(), settingFieldSchema).optional(),
pages: array(adminPageSchema).optional(),
widgets: array(dashboardWidgetSchema).optional(),
editorPanels: array(editorPanelSchema).max(32).optional(),
editorActions: array(editorActionSchema).max(32).optional(),
fieldWidgets: array(object({
name: string().min(1),
label: string().min(1),
Expand All @@ -8096,6 +8141,9 @@ const pluginAdminConfigSchema = object({
label: string().optional()
}).loose()).optional()
})).optional()
}).superRefine((admin, ctx) => {
uniqueExtensionIds(admin.editorPanels, ctx, "editorPanels");
uniqueExtensionIds(admin.editorActions, ctx, "editorActions");
});
/**
* An operation's constraint object. Open vocabulary: keys the runtime
Expand Down Expand Up @@ -8155,7 +8203,7 @@ const declaredAccessSchema = object({
* to make them consistent (declaredAccess authoritative when present). Kept a
* plain object (no `.transform`) because callers `.pick()`/`.extend()` it.
*/
const pluginManifestSchema = object({
const pluginManifestBaseSchema = object({
id: string().min(1),
version: string().min(1),
declaredAccess: declaredAccessSchema.optional(),
Expand All @@ -8167,6 +8215,37 @@ const pluginManifestSchema = object({
mcp: pluginMcpConfigSchema.optional(),
admin: pluginAdminConfigSchema
});
function validateEditorExtensionRoutes(manifest, ctx) {
for (const [kind, extensions] of [["editorPanels", manifest.admin.editorPanels], ["editorActions", manifest.admin.editorActions]]) for (const [index, extension] of (extensions ?? []).entries()) {
const matches = manifest.routes.filter((route) => (typeof route === "string" ? route : route.name) === extension.route);
if (matches.length !== 1) {
ctx.addIssue({
code: "custom",
message: matches.length === 0 ? "Editor extension route is not declared" : "Editor extension route must be declared exactly once",
path: [
"admin",
kind,
index,
"route"
]
});
continue;
}
const route = matches[0];
if (!route) continue;
if (typeof route !== "string" && route.public === true) ctx.addIssue({
code: "custom",
message: "Editor extension routes must be private",
path: [
"admin",
kind,
index,
"route"
]
});
}
}
const pluginManifestSchema = pluginManifestBaseSchema.superRefine(validateEditorExtensionRoutes);
/**
* Reconcile a parsed manifest's trust contract with its enforcement currency.
* `declaredAccess` is authoritative: when present, `capabilities`/`allowedHosts`
Expand Down
55 changes: 53 additions & 2 deletions docs/src/content/docs/plugins/creating-plugins/block-kit.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Block Kit
description: Declarative UI blocks for sandboxed plugin admin pages and widgets.
description: Declarative UI blocks for sandboxed plugin pages, widgets, and saved-entry extensions.
---

import { Aside } from "@astrojs/starlight/components";
Expand Down Expand Up @@ -140,7 +140,7 @@ const plugin: SandboxedPlugin = {
export default plugin;
```

`routeCtx.ui` contains the surface (`admin-page` or `dashboard-widget`), locale, and text direction. The admin locale is separate from `ctx.site.locale`, which describes the site's default content locale. Manifest page labels remain static strings.
`routeCtx.ui` contains the surface, locale, and text direction. The admin locale is separate from `ctx.site.locale`, which describes the site's default content locale. Manifest labels remain static strings.

## Navigation links

Expand Down Expand Up @@ -180,6 +180,57 @@ External links open in a new tab with `noopener noreferrer`. Link elements do no

Block images use the same browser-resource policy. Root-relative image URLs are allowed. An external image must use HTTPS and its hostname must appear in the plugin's `allowedHosts`. A plugin with `network:request:unrestricted` can load an HTTPS image from any hostname. Other external images cause the complete Block Kit response to be rejected.

## Saved-entry panels and actions

Declare an editor panel when a plugin needs to show information beside a saved entry. Panels start collapsed and call their private route only when an editor opens them.

The following manifest adds a panel for posts and a confirmed repair action:

```jsonc title="emdash-plugin.jsonc"
"admin": {
"editorPanels": [
{
"id": "content-health",
"title": "Content health",
"route": "editor/content-health",
"collections": ["posts"],
},
],
"editorActions": [
{
"id": "repair-metadata",
"label": "Repair metadata",
"route": "editor/repair-metadata",
"placement": "overflow",
"style": "danger",
"confirm": {
"title": "Repair metadata?",
"text": "This changes the saved entry.",
"confirm": "Repair",
"deny": "Cancel",
},
},
],
}
```

Each referenced route must be private. Its `permission` controls which editors can invoke the extension. The host also reloads the saved entry and checks its owner before calling the plugin.

Editor extension routes receive an attested `routeCtx.ui` value. For `content-editor-panel` and `content-editor-action` surfaces, `routeCtx.ui.entry` contains the collection, saved entry ID, content locale, and version. `routeCtx.ui.extensionId` identifies the selected declaration. The context does not contain field values or unsaved editor state. Use `ctx.content` with the `content:read` capability when the plugin needs the saved content.

A panel receives `{ type: "panel_load" }` when it opens. Its later button and form interactions use the usual `block_action` and `form_submit` shapes. Return a validated `BlockResponse` from every panel interaction.

Editor actions are disabled while the form has unsaved changes or a save is running. After the saved state is current, an action receives `{ type: "editor_action" }`. Return an object containing an optional toast and at most one terminal effect:

```typescript title="src/plugin.ts"
return {
toast: { type: "success", message: "Metadata repaired" },
refresh: true,
};
```

Use `refresh: true` to reload the entry or `navigate` with a structured link target. A response cannot request both. EmDash rejects unknown commands, unsafe navigation, and responses over the Block Kit limits before applying an effect.

## Block types

| Type | Description |
Expand Down
6 changes: 5 additions & 1 deletion docs/src/content/docs/plugins/creating-plugins/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,8 @@ The default Vitest configuration uses Worker Loader because it is the fastest pr

Neither host renders the EmDash admin application or reproduces Cloudflare's deployed CPU, memory, and subrequest limits. Use a disposable EmDash site for browser journeys, and verify limit-sensitive behavior on a Cloudflare preview or staging deployment.

For Block Kit handlers, use `host.admin.loadPage()` or `loadWidget()` to exercise the private route, host-attested locale context, response validation, and Worker Loader isolate. Use `admin.act()` and `admin.submit()` for interactions. These helpers do not render React; use the [Block Playground](https://emdash-blocks.cto.cloudflare.dev/) or a browser journey to verify Kumo rendering, keyboard operation, and right-to-left layout.
For Block Kit handlers, use `host.admin.loadPage()` or `loadWidget()` to exercise the private route, host-attested locale context, response validation, and Worker Loader isolate. Use `admin.act()` and `admin.submit()` for page interactions.

Saved-entry extensions use the production ownership and route-permission boundary. Create the collection, user, and content with fixtures, then call `admin.loadEditorPanel()`, `actEditorPanel()`, `submitEditorPanel()`, or `invokeEditorAction()`. Pass `locale` for the admin UI language and `contentLocale` when selecting a translated entry. These helpers reload the saved entry before invoking the isolate and never send unsaved field values.

The admin helpers do not render React. Use the [Block Playground](https://emdash-blocks.cto.cloudflare.dev/) or a browser journey to verify Kumo rendering, confirmation dialogs, keyboard operation, and right-to-left layout.
38 changes: 37 additions & 1 deletion e2e/fixture/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* Uses env vars for the database path and optional marketplace URL
* so each test run gets an isolated database.
*/
import { fileURLToPath } from "node:url";

import node from "@astrojs/node";
import react from "@astrojs/react";
import { colorPlugin } from "@emdash-cms/plugin-color";
Expand All @@ -13,6 +15,40 @@ import { sqlite } from "emdash/db";

const dbUrl = process.env.EMDASH_TEST_DB || "file:./test.db";
const marketplaceUrl = process.env.EMDASH_MARKETPLACE_URL || undefined;
const editorExtensionsPlugin = {
id: "editor-extensions-test",
version: "1.0.0",
format: "standard",
entrypoint: fileURLToPath(new URL("./src/editor-extensions-plugin.ts", import.meta.url)),
capabilities: [],
allowedHosts: [],
storage: {},
editorPanels: [
{
id: "entry-health",
title: "Plugin content health",
route: "entry-health",
collections: ["posts"],
order: 20,
},
],
editorActions: [
{
id: "entry-recheck",
label: "Recheck saved entry",
route: "entry-recheck",
placement: "toolbar",
collections: ["posts"],
style: "danger",
confirm: {
title: "Recheck saved entry?",
text: "The plugin will inspect the latest saved version.",
confirm: "Recheck",
deny: "Cancel",
},
},
],
};

export default defineConfig({
output: "server",
Expand All @@ -22,7 +58,7 @@ export default defineConfig({
emdash({
database: sqlite({ url: dbUrl }),
middleware: { outer: "./src/outer-middleware.ts" },
plugins: [colorPlugin()],
plugins: [colorPlugin(), editorExtensionsPlugin],
marketplace: marketplaceUrl,
sandboxRunner: marketplaceUrl ? "./noop-sandbox.mjs" : undefined,
}),
Expand Down
40 changes: 40 additions & 0 deletions e2e/fixture/src/editor-extensions-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { SandboxedPlugin } from "emdash/plugin";

const plugin: SandboxedPlugin = {
routes: {
"entry-health": {
permission: "content:edit_own",
handler: async (route) => {
if (route.ui?.surface !== "content-editor-panel") return { blocks: [] };
return {
blocks: [
{
type: "fields",
fields: [
{ label: "Collection", value: route.ui.entry.collection },
{ label: "Saved entry", value: route.ui.entry.id },
{ label: "Content locale", value: route.ui.entry.locale ?? "Default" },
{ label: "Saved version", value: String(route.ui.entry.version) },
],
},
{
type: "banner",
variant: "default",
title: "Saved content only",
description: "Unsaved editor changes are not sent to this plugin.",
},
],
};
},
},
"entry-recheck": {
permission: "content:edit_own",
handler: async () => ({
refresh: true,
toast: { type: "success", message: "Saved entry rechecked" },
}),
},
},
};

export default plugin;
2 changes: 1 addition & 1 deletion e2e/fixtures/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ export class AdminPage {
* form so we click the first match.
*/
async clickSave(): Promise<void> {
await this.page.locator('button:has-text("Save")').first().click();
await this.page.locator('form button[type="submit"]').first().click();
}

/**
Expand Down
Loading
Loading