Files
dckc 4b4ef8fea6 capsite: potluck, a fresh EmDash site (cloudflare:blog out of the box)
pnpm create emdash@latest potluck --pm pnpm --install --yes, per
https://docs.emdashcms.com/ ("Create your first EmDash site"); the
--yes defaults are cloudflare + blog. Replaces the old madmode/
starter (never committed).

Generated-by: create-emdash 1.2.0, run by Claude Opus 5.5
2026-10-07 19:57:43 -05:00

8.1 KiB

Admin UI and field widgets

Sandboxed plugins return declarative Block Kit from a private route. Native plugins may instead ship React components. Keep these paths separate: sandboxed plugin JavaScript never runs in the browser.

Sandboxed pages and dashboard widgets

Declare navigation and widget cards in emdash-plugin.jsonc:

{
	"admin": {
		"pages": [
			{ "path": "/settings", "label": "Settings", "icon": "settings" },
			{ "path": "/reports", "label": "Reports", "icon": "chart" },
		],
		"widgets": [{ "id": "status", "title": "Plugin status", "size": "half" }],
	},
}

Pages mount at /_emdash/admin/plugins/<plugin-id>/<path>. Widget sizes are full, half, and third.

Any sandboxed plugin that declares a page or widget must define an admin route. The admin sends a page_load, block_action, or form_submit interaction as routeCtx.input:

import type { SandboxedPlugin } from "emdash/plugin";
import type { BlockResponse } from "@emdash-cms/blocks";
import { z } from "zod";

const interactionSchema = z.discriminatedUnion("type", [
	z.object({ type: z.literal("page_load"), page: z.string() }),
	z.object({
		type: z.literal("block_action"),
		action_id: z.string(),
		block_id: z.string().optional(),
		value: z.unknown().optional(),
	}),
	z.object({
		type: z.literal("form_submit"),
		action_id: z.string(),
		block_id: z.string().optional(),
		values: z.object({ enabled: z.boolean() }),
	}),
]);

function settingsForm(enabled: boolean): BlockResponse {
	return {
		blocks: [
			{ type: "header", text: "Settings" },
			{
				type: "form",
				block_id: "settings",
				fields: [
					{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: enabled },
				],
				submit: { action_id: "save", label: "Save" },
			},
		],
	};
}

const plugin: SandboxedPlugin = {
	routes: {
		admin: {
			permission: "plugins:manage",
			handler: async (routeCtx, ctx) => {
				const parsed = interactionSchema.safeParse(routeCtx.input);
				if (!parsed.success) return { blocks: [] };
				const interaction = parsed.data;
				if (interaction.type === "form_submit" && interaction.action_id === "save") {
					await ctx.settings.set("enabled", interaction.values.enabled === true);
					return {
						...settingsForm(interaction.values.enabled === true),
						toast: { type: "success", message: "Settings saved" },
					};
				}

				const enabled = (await ctx.settings.get<boolean>("enabled")) ?? false;
				return settingsForm(enabled);
			},
		},
	},
};

export default plugin;

Validate interactions before production side effects; routeCtx.input is unknown. Read Block Kit for exact interaction, block, and element shapes.

The plugin CLI preserves admin.settingsSchema in the registry manifest and generated descriptor, so the host can generate a settings form. Both sandbox bridges route ctx.settings through the same options records as that form. Read a generated setting with ctx.settings.get("<key>"); writes, deletes, list operations, and revision-based operations use the same namespace on Cloudflare and Node/workerd.

The secret settings field is write-only in the admin response and encrypted before persistence. The site must provide EMDASH_ENCRYPTION_KEY; missing, wrong, or tampered key material fails closed. Keep the encryption-key list with database backups. Existing ctx.kv.get("settings:<key>") reads remain compatible throughout EmDash 1.x.

Sandboxed saved-entry extensions

Declare saved-entry panels and actions in emdash-plugin.jsonc:

{
	"admin": {
		"editorPanels": [
			{
				"id": "health",
				"title": "Content health",
				"route": "editor/health",
				"collections": ["posts"],
				"draft": {
					"read": { "translatable": true },
					"patch": { "fields": ["title", "excerpt"] },
				},
			},
		],
		"editorActions": [
			{
				"id": "repair",
				"label": "Repair metadata",
				"route": "editor/repair",
				"placement": "overflow",
				"style": "danger",
				"confirm": {
					"title": "Repair?",
					"text": "This changes the saved entry.",
					"confirm": "Repair",
					"deny": "Cancel",
				},
			},
		],
	},
}

Every referenced route must be private. EmDash reloads the saved entry and checks ownership plus the route permission before invoking it. routeCtx.ui.entry contains only the canonical collection, ID, locale, and version.

Panels start collapsed. panel_load never includes draft values. After an explicit block_action, form_submit, or editor_action, admin.editor-draft:read can attach only the fields selected by the extension's draft.read declaration. fields selects explicit slugs and translatable: true selects current schema fields marked translatable. Draft declarations require explicit collection scope.

admin.editor-draft:patch permits a separate draft.patch field selector and does not imply read. Return patch: { type: "editor-draft-patch", operations } with whole-field set or clear operations. The host rejects the complete patch on an unknown, forbidden, invalid, unsupported, oversized, or stale operation. Accepted patches receive a host-rendered preview, update the form atomically, mark it dirty, and remain unsaved. A response may contain a toast and one terminal effect: patch, refresh, or navigation.

Use createPluginRuntimeTestHost().admin to exercise this boundary. captureEditorDraft() creates a saved-entry draft request, the existing panel/action helpers invoke the production route, and applyEditorDraftPatch() applies only a current response through the host validator.

Sandboxed declarative field widgets

Core and the admin contain a declarative field-widget path. Declare the widget in the registry manifest:

{
	"admin": {
		"fieldWidgets": [
			{
				"name": "event-picker",
				"label": "Event",
				"fieldTypes": ["json"],
				"elements": [
					{ "type": "text_input", "action_id": "eventId", "label": "Event ID" },
					{ "type": "toggle", "action_id": "featured", "label": "Featured" },
				],
			},
		],
	},
}

A schema field selects it with widget: "pluginId:widgetName". The editor stores an object keyed by each element's action_id. Use a json field for this object. The manifest schema accepts other compatible field types, but the repository has no end-to-end test proving that the composed object saves through them.

The current field-widget renderer supports:

  • text_input
  • number_input
  • toggle
  • select
  • media_picker

Other Block Kit element types display an unsupported-element message in this surface.

emdash-plugin.jsonc accepts admin.fieldWidgets, and the plugin CLI carries the definitions through the bundle manifest and generated descriptor for registry installation. The artifact round-trip is covered by plugin CLI, shared manifest, and plugin-test tests. The browser E2E fixture still tests a native React color picker rather than a registry-installed declarative widget, so verify the real editor render and value persistence for the chosen elements.

The sandbox admin route receives routeCtx.ui with the host-attested admin locale, text direction, and surface. Use it to select localized text in a runtime Block Kit response. Labels in manifest metadata remain static strings; registry plugins do not hand translation catalogs to the host.

Native React pages, widgets, and fields

Native plugins may set admin.entry and export React components:

export const pages = {
	"/settings": SettingsPage,
};

export const widgets = {
	status: StatusWidget,
};

export const fields = {
	picker: ColorPickerField,
};

The plugin definition points to the entry and declares its surfaces:

definePlugin({
	id: "color",
	version: "1.0.0",
	admin: {
		entry: "@my-org/plugin-color/admin",
		pages: [{ path: "/settings", label: "Settings" }],
		widgets: [{ id: "status", title: "Status", size: "half" }],
		fieldWidgets: [{ name: "picker", label: "Color picker", fieldTypes: ["string"] }],
	},
});

Native admin code must follow the repository's Kumo, localization, accessibility, and RTL rules. It runs with the site's authority and is not registry-installable.