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
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
- User navigates to plugin admin page
- Admin sends
page_loadinteraction to plugin's admin route - Plugin returns
BlockResponsewith array of blocks - Admin renders blocks using
BlockRenderer - User interacts (button click, form submit) → interaction sent back
- 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 isitems, notstatstrend—"up","down", or"neutral"; renders an arrow icon next to the valuedescription— 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 theblock_actionid when the user sorts a column or pages through results.empty_text— shown in place of the table whenrowsis emptynext_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]tuplesseries[].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 tochart.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
titleordescriptionis 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_inputnumber_inputtoggleselectmedia_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.
Links and admin locale
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"
};