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

16 KiB

Block Kit

Declarative JSON UI for sandboxed plugin admin pages. The host renders blocks — no plugin JavaScript runs in the browser. Inspired by Slack's Block Kit but not identical — similar concepts and naming, different block/element types and capabilities.

Trusted plugins (declared in astro.config.ts) can ship custom React components instead. Block Kit is for runtime-installed sandboxed plugins.

Native plugins also use Block Kit elements for Portable Text block editing fields. Plugin CLI and registry packages cannot register Portable Text block types.

How It Works

  1. User navigates to plugin admin page
  2. Admin sends page_load interaction to plugin's admin route
  3. Plugin returns BlockResponse with array of blocks
  4. Admin renders blocks using BlockRenderer
  5. User interacts (button click, form submit) → interaction sent back
  6. Plugin returns new blocks
import type { BlockInteraction } from "@emdash-cms/blocks";

routes: {
	admin: {
		handler: async (ctx) => {
			// EmDash parses the request body once and exposes it as ctx.input;
			// read it directly rather than ctx.request.json() (the body is consumed).
			// BlockInteraction is the discriminated union of page_load,
			// block_action, and form_submit payloads.
			const interaction = ctx.input as BlockInteraction;

			if (interaction.type === "page_load") {
				return {
					blocks: [
						{ type: "header", text: "My Plugin Settings" },
						{
							type: "form",
							block_id: "settings",
							fields: [
								{ type: "text_input", action_id: "api_url", label: "API URL" },
								{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: true },
							],
							submit: { label: "Save", action_id: "save" },
						},
					],
				};
			}

			if (interaction.type === "form_submit" && interaction.action_id === "save") {
				await ctx.kv.set("settings", interaction.values);
				return {
					blocks: [/* updated blocks */],
					toast: { message: "Settings saved", type: "success" },
				};
			}
		},
	},
}

Block Types

Type Description
header Large bold heading
section Text with optional accessory element
divider Horizontal rule
fields Two-column label/value grid
table Data table with formatting, sorting, pagination
actions Horizontal row of buttons and controls
stats Dashboard metric cards with trend indicators
form Input fields with conditional visibility and submit
image Block-level image with alt text and optional title
context Small muted help text
columns 2-3 column layout with nested blocks
chart Charts (timeseries line/bar, pie, custom ECharts)
code Syntax-highlighted code block
meter Progress/quota meter bar
banner Info, warning, or error inline messages
empty Empty state with optional command and actions
accordion Collapsible section containing nested blocks

Element Types

Type Description
button Action button with optional confirmation dialog
link Host-resolved navigation that does not dispatch an action
text_input Single-line or multiline text input
number_input Numeric input with min/max
select Dropdown select
toggle On/off switch
secret_input Masked input for API keys and tokens
checkbox Multi-select checkboxes
radio Single-select radio buttons
date_input Date picker
combobox Searchable dropdown select
repeater Array of records with scalar sub-fields
media_picker Media-library picker that stores the asset URL

Block Syntax

Header

{ "type": "header", "text": "Settings" }

Section

{
	"type": "section",
	"text": "Configure your plugin settings below.",
	"accessory": { "type": "button", "label": "Refresh", "action_id": "refresh" }
}

Divider

{ "type": "divider" }

Fields

{
	"type": "fields",
	"fields": [
		{ "label": "Status", "value": "Active" },
		{ "label": "Last Sync", "value": "2 hours ago" }
	]
}

Stats

{
	"type": "stats",
	"items": [
		{ "label": "Total", "value": "1,234", "trend": "up", "description": "+12% vs last week" },
		{ "label": "Active", "value": "567" }
	]
}
  • items — the array key is items, not stats
  • trend — "up", "down", or "neutral"; renders an arrow icon next to the value
  • description — secondary line beneath the value, for context such as "+12% vs last week"

Table

{
	"type": "table",
	"columns": [
		{ "key": "name", "label": "Name" },
		{ "key": "status", "label": "Status" },
		{ "key": "date", "label": "Date" }
	],
	"rows": [{ "name": "Item 1", "status": "Active", "date": "2025-01-01" }],
	"page_action_id": "browse_items",
	"empty_text": "No items yet."
}
  • page_action_id — required. The admin sends it as the block_action id when the user sorts a column or pages through results.
  • empty_text — shown in place of the table when rows is empty
  • next_cursor — set it to render a "Load more" control

Actions

{
	"type": "actions",
	"elements": [
		{ "type": "button", "label": "Save", "action_id": "save", "style": "primary" },
		{ "type": "button", "label": "Cancel", "action_id": "cancel" }
	]
}

Form

{
	"type": "form",
	"block_id": "settings",
	"fields": [
		{ "type": "text_input", "action_id": "name", "label": "Name" },
		{ "type": "number_input", "action_id": "count", "label": "Count", "min": 0, "max": 100 },
		{
			"type": "select",
			"action_id": "theme",
			"label": "Theme",
			"options": [
				{ "label": "Light", "value": "light" },
				{ "label": "Dark", "value": "dark" }
			]
		},
		{ "type": "toggle", "action_id": "enabled", "label": "Enabled", "initial_value": true },
		{ "type": "secret_input", "action_id": "api_key", "label": "API Key" }
	],
	"submit": { "label": "Save", "action_id": "save_settings" }
}

Columns

{
	"type": "columns",
	"columns": [
		[
			{ "type": "header", "text": "Usage" },
			{ "type": "meter", "label": "Storage used", "value": 65 }
		],
		[
			{ "type": "header", "text": "Activity" },
			{ "type": "context", "text": "Last sync 2 hours ago" }
		]
	]
}
  • columns — 2 or 3 columns, each one an array of blocks

Chart (Timeseries)

{
	"type": "chart",
	"config": {
		"chart_type": "timeseries",
		"series": [
			{
				"name": "Requests",
				"data": [
					[1709596800000, 42],
					[1709600400000, 67],
					[1709604000000, 53]
				],
				"color": "#086FFF"
			},
			{
				"name": "Errors",
				"data": [
					[1709596800000, 2],
					[1709600400000, 5],
					[1709604000000, 1]
				]
			}
		],
		"x_axis_name": "Time",
		"y_axis_name": "Count",
		"style": "line",
		"gradient": true,
		"height": 300
	}
}
  • series[].data — array of [timestamp_ms, value] tuples
  • series[].color — hex color (optional, auto-assigned from Kumo palette)
  • style — "line" (default) or "bar"
  • gradient — fill gradient beneath lines (default false)
  • height — chart height in pixels (default 350)

Chart (Custom)

For pie charts, gauges, or any ECharts visualization:

{
	"type": "chart",
	"config": {
		"chart_type": "custom",
		"options": {
			"series": [
				{
					"type": "pie",
					"data": [
						{ "value": 335, "name": "Published" },
						{ "value": 234, "name": "Draft" },
						{ "value": 120, "name": "Scheduled" }
					]
				}
			]
		},
		"height": 300
	}
}
  • options — raw ECharts option object passed through to chart.setOption()

Code

{
	"type": "code",
	"code": "const greeting = \"Hello!\";\nconsole.log(greeting);",
	"language": "ts"
}
  • language — "ts", "tsx", "jsonc", "bash", or "css" (defaults to "ts")

Meter

{
	"type": "meter",
	"label": "Storage used",
	"value": 65,
	"custom_value": "6.5 GB / 10 GB"
}
  • value — numeric value (default range 0-100)
  • max / min — custom range (defaults to 0-100)
  • custom_value — display string instead of percentage (e.g. "750 / 1,000")

Banner

{
	"type": "banner",
	"title": "API key invalid",
	"description": "Please check your API key in settings.",
	"variant": "error"
}
  • variant — "default" (info, default), "alert" (warning), or "error"
  • At least one of title or description is required

Empty

{
	"type": "empty",
	"title": "No submissions",
	"description": "New submissions appear here.",
	"command_line": "pnpm run seed",
	"size": "base",
	"actions": [{ "type": "button", "action_id": "refresh", "label": "Refresh" }]
}

Tabs

Use tab to group related blocks into labelled panels:

{
	"type": "tab",
	"panels": [
		{
			"label": "General",
			"blocks": [{ "type": "context", "text": "General settings" }]
		}
	]
}

Accordion

{
	"type": "accordion",
	"label": "Advanced settings",
	"default_open": false,
	"blocks": [{ "type": "context", "text": "Settings visible when expanded" }]
}

Repeater and media picker elements

repeater and media_picker are admin-authoring elements, not ordinary fields in a sandboxed admin-page form.

repeater captures an array of objects. Its nested fields are limited to text_input, number_input, select, and toggle:

{
	"type": "repeater",
	"action_id": "items",
	"label": "Questions",
	"item_label": "Question",
	"fields": [
		{ "type": "text_input", "action_id": "question", "label": "Question" },
		{ "type": "text_input", "action_id": "answer", "label": "Answer", "multiline": true }
	]
}

media_picker opens the media library and stores the selected asset's URL string:

{
	"type": "media_picker",
	"action_id": "hero",
	"label": "Hero image",
	"mime_type_filter": "image/"
}

Declarative field widgets

The admin field editor can render a plugin field widget from Block Kit elements. A schema field refers to pluginId:widgetName; a config-declared standard descriptor supplies name, label, compatible fieldTypes, and elements.

The field-widget renderer currently supports only these elements:

  • text_input
  • number_input
  • toggle
  • select
  • media_picker

It stores an object keyed by each element's action_id. Use a json field for this composed value; other field types are accepted by the manifest schema but are not covered by an end-to-end save test. Other element types render an unsupported-element message.

emdash-plugin.jsonc accepts this field-widget definition, and the plugin CLI preserves it in the registry manifest and generated descriptor. The artifact transport is tested; the repository's browser E2E test still covers a native React field widget rather than a registry declarative widget. Verify the rendered editor and saved value for the selected elements.

Conditional Fields

Show/hide fields based on other field values. Evaluated client-side, no round-trip.

{
	"type": "toggle",
	"action_id": "auth_enabled",
	"label": "Enable Authentication"
}
{
	"type": "secret_input",
	"action_id": "api_key",
	"label": "API Key",
	"condition": { "field": "auth_enabled", "eq": true }
}

Builder Helpers

@emdash-cms/blocks provides TypeScript helpers:

import { blocks, elements } from "@emdash-cms/blocks";

const {
	header,
	form,
	section,
	stats,
	timeseriesChart,
	customChart,
	banner: bannerBlock,
	empty,
	accordion,
} = blocks;
const { textInput, toggle, select, button, repeater, mediaPicker } = elements;

return {
	blocks: [
		header("Settings"),
		form({
			blockId: "settings",
			fields: [
				textInput("site_title", "Site Title", { initialValue: "My Site" }),
				toggle("generate_sitemap", "Generate Sitemap", { initialValue: true }),
				select("robots", "Default Robots", [
					{ label: "Index, Follow", value: "index,follow" },
					{ label: "No Index", value: "noindex,follow" },
				]),
			],
			submit: { label: "Save", actionId: "save" },
		}),
		// Timeseries chart
		timeseriesChart({
			series: [
				{
					name: "Page Views",
					data: [
						[Date.now() - 3600000, 100],
						[Date.now(), 150],
					],
				},
			],
			yAxisName: "Views",
			gradient: true,
		}),
		// Pie chart via custom ECharts options
		customChart({
			options: {
				series: [
					{
						type: "pie",
						data: [
							{ value: 335, name: "Published" },
							{ value: 234, name: "Draft" },
						],
					},
				],
			},
		}),
	],
};

Button Confirmations

{
	"type": "button",
	"label": "Delete All",
	"action_id": "delete_all",
	"style": "danger",
	"confirm": {
		"title": "Are you sure?",
		"text": "This cannot be undone.",
		"confirm": "Delete",
		"deny": "Cancel"
	}
}

Saved-entry panels and actions

admin.editorPanels and admin.editorActions point to private plugin routes. Panel routes return Block Kit and receive panel_load, block_action, or form_submit. Action routes receive editor_action and return only these bounded fields:

{
	toast?: { message: string; type: "success" | "error" | "info" };
	refresh?: true;
	navigate?: LinkTarget;
	patch?: EditorDraftPatchEffect;
}

Use only one of refresh, navigate, or patch. Navigation uses the same structured target validator as link elements. A danger action declaration must include a confirmation dialog.

For both surfaces, routeCtx.ui.entry contains the host-reloaded collection, saved entry ID, content locale, and version. routeCtx.ui.extensionId identifies the manifest declaration. Ordinary panel load contains no draft. An explicit interaction includes selected unsaved fields only when the plugin has admin.editor-draft:read and the extension declares bounded draft.read access. admin.editor-draft:patch separately permits atomic whole-field set and clear proposals for fields in draft.patch; the host previews accepted changes without saving them.

Use a structured link target instead of returning an admin URL:

{
	"type": "link",
	"label": "Edit article",
	"target": { "kind": "content", "collection": "posts", "id": "post-1", "locale": "ar" },
	"appearance": "primary"
}

Targets can identify saved content, a page declared by the same plugin, the plugin's generated settings page, or an absolute external HTTP, HTTPS, or mailto: URL. External links open in a new tab with noopener noreferrer. A link has no action_id; use a button when the interaction must call the plugin.

Every page and widget response is validated before rendering. Responses are limited to 256 KiB, 20 nested levels, 2,000 nodes, 1,000 items per array, and 64 KiB per string. Root-relative image URLs are accepted. External images require HTTPS and either network:request with the hostname in the plugin manifest's allowedHosts, or network:request:unrestricted.

The admin route receives host-attested UI context separately from the interaction:

const { locale, direction, surface } = routeCtx.ui ?? {
	locale: "en",
	direction: "ltr",
	surface: "admin-page",
};

The UI locale is the administrator's active locale. It is separate from the site's default content locale in ctx.site.locale. Runtime page labels can use it to select localized Block Kit text; manifest navigation labels remain static.

Toast Responses

Return a toast alongside blocks to show a notification:

return {
	blocks: [/* ... */],
	toast: { message: "Settings saved", type: "success" }, // "success" | "error" | "info"
};