@bnomei/emdash-actions
v0.4.0
Published
Native action surface for EmDash provider plugins.
Downloads
1,034
Maintainers
Readme
@bnomei/emdash-actions
Action buttons for EmDash fields and dashboards.
@bnomei/emdash-actions adds configurable buttons to the EmDash admin. A button
can copy a field value in the browser, call an EmDash plugin route, write a
returned value back into a field, or trigger a dashboard-level provider action.
The core split is simple:
emdash-actionsowns the admin UI trigger surfaces.- Provider plugins own the backend work behind those triggers.
That means a provider can be native, standard trusted, or sandboxed, as long as it exposes normal EmDash plugin API routes.
Install
npm install @bnomei/emdash-actionsQuick Start
This quick start wires a single dashboard button that runs backend TypeScript in
an EmDash provider route. The example action clears a cache placeholder, writes
plugin-local state with ctx.kv, logs through ctx.log, and returns feedback to
the clicked button.
1. Create A Provider
Create src/emdash/cache-actions.ts:
import { definePlugin, type RouteContext } from "emdash";
import { defineActionsManifest, type ActionsManifest } from "@bnomei/emdash-actions";
export function cacheActions() {
return definePlugin({
id: "cache-actions",
version: "0.1.0",
routes: {
actions: {
handler: cacheActionsManifestRoute,
},
"cache/clear": {
handler: clearCacheRoute,
},
},
});
}
async function cacheActionsManifestRoute(): Promise<ActionsManifest> {
return defineActionsManifest({
actions: [
{
id: "cache.clear",
label: "Clear cache",
route: "cache/clear",
method: "POST",
placement: "dashboard",
icon: "bolt",
tone: "warning",
confirm: "Clear the site cache?",
},
],
});
}
async function clearCacheRoute(ctx: RouteContext) {
const serverTime = new Date().toISOString();
const clearCount = ((await ctx.kv.get<number>("state:clearCount")) ?? 0) + 1;
await clearSiteCache(ctx);
await ctx.kv.set("state:clearCount", clearCount);
ctx.log.info("Cache clear action completed", {
clearCount,
site: ctx.site.name,
serverTime,
});
return {
ok: true,
status: 200,
severity: "success",
message: `Cache cleared on the server at ${serverTime}.`,
toast: {
type: "success",
title: "Cache cleared",
message: `${ctx.site.name} cache has been cleared ${clearCount} time(s).`,
},
};
}
async function clearSiteCache(ctx: RouteContext) {
// Replace this with your cache provider, CDN, build, or deployment logic.
await ctx.kv.set("state:lastClearedAt", new Date().toISOString());
}2. Register The UI And Provider
Add actionsPlugin() and the provider plugin to the EmDash plugin list in
astro.config.mjs:
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { actionsPlugin } from "@bnomei/emdash-actions";
import { cacheActions } from "./src/emdash/cache-actions";
const cacheActionsProvider = {
pluginId: "cache-actions",
label: "Cache actions",
manifestRoute: "actions",
};
export default defineConfig({
integrations: [
emdash({
plugins: [
actionsPlugin({
providers: [cacheActionsProvider],
}),
cacheActions(),
],
}),
],
});3. Click The Button
Open the EmDash dashboard. The Actions widget loads:
GET /_emdash/api/plugins/cache-actions/actionsIt renders Clear cache. Clicking the button calls:
POST /_emdash/api/plugins/cache-actions/cache/clearThe provider route runs on the server, uses EmDash ctx, and returns the inline
button feedback plus toast.
[!NOTE] This quick start uses a dashboard action because it shows the whole provider loop without needing collection schema changes. Field buttons use the same provider route model.
What To Use
| Goal | Start here | | --------------------------------------------------- | -------------------------------------------------------------------- | | Copy a value without backend code | Clipboard Field Button | | Call one backend route from a field | Direct Route Field Action | | Use provider-owned action UI and routes | Manifest Field Action | | Use one safe provider-owned runner route | Runner Field Action | | Add a runner-backed dashboard action | Runner Dashboard Action | | Collect a few scalar inputs before submit | Inline Form Action | | Target a host-provided nested row | Row Target Action | | Add a dashboard action | Dashboard Action | | Return clipboard, open, download, or reload effects | Response effect examples | | Show a Kumo toast | Toast Notification | | Patch a clicked button after success | Action Patch | | Poll long-running work | Async Job | | Run the provider in a sandbox | Sandboxed Provider |
The full recipe index is in examples.
Core Concepts
Surfaces
actions:button: field widget for content forms. It supportsclipboardmode for browser-native copying andrunmode for provider-backed actions.Actions: dashboard widget for provider/global actions. It reads configured provider manifests and renders matching action buttons.
Action Labels And Icons
Every action must have a human-readable label. The action label is the
user-facing command text for the rendered button. icon is optional decoration
only; it must not be the only visible affordance for the action.
Field labels and action labels describe different things. The collection field
label names the field or slot, while the manifest action label or field
options.label names the command the button runs. They may match, but the
button still renders the resolved action label as visible text.
Idle buttons show the resolved action label. The button title, tooltip text,
and aria-label use that same resolved label. Feedback, progress, success, and
error text may temporarily replace or supplement the label after interaction,
but the idle state should always remain clear.
Providers And Manifests
A provider manifest describes buttons:
{
actions: [
{
id: "cache.clear",
label: "Clear cache",
route: "cache/clear",
method: "POST",
placement: "dashboard",
},
],
}Use clear labels for every action surface:
{
actions: [
{
id: "cache.clear",
label: "Clear cache",
route: "cache/clear",
method: "POST",
placement: "dashboard",
target: { surfaces: ["dashboard"] },
icon: "bolt",
},
{
id: "entry.rebuild",
label: "Rebuild entry",
runner: { route: "actions/run-entry" },
placement: "entry",
target: { surfaces: ["entry"], idFrom: "entryId" },
icon: "refresh",
},
{
id: "field.summarize",
label: "Summarize field",
runner: true,
placement: "field",
target: { surfaces: ["field"], idFrom: "entryId" },
icon: "bolt",
},
{
id: "row.translate",
label: "Translate row",
runner: true,
placement: "field",
target: { surfaces: ["row"], idFrom: "rowId" },
icon: "repeat",
},
],
}emdash-actions loads the manifest from a provider route such as:
GET /_emdash/api/plugins/cache-actions/actionsClicking the action calls the manifest route target:
POST /_emdash/api/plugins/cache-actions/cache/clearField buttons can also skip the manifest and call a direct route from field
options. Dashboard discovery uses actionsPlugin({ providers }).
Manifest actions can also use runner mode:
{
actions: [
{
id: "field.summarize",
runner: true,
label: "Summarize",
placement: "field",
target: { surfaces: ["field"], idFrom: "entryId" },
payload: { format: "short" },
form: {
mode: "inline",
fields: [{ name: "format", type: "select", options: ["short", "long"] }],
},
},
],
}Runner actions always call the provider-owned runner route, defaulting to:
POST /_emdash/api/plugins/<provider>/.well-known/actions/runwith a normalized invocation:
{
"invocationId": "inv_...",
"actionId": "field.summarize",
"payload": {
"format": "long"
},
"target": {
"type": "field",
"surface": "field",
"collection": "posts",
"entryId": "post-1",
"fieldName": "summary",
"value": "Current field value"
}
}Use direct route mode when the browser should call one explicit provider route
such as field/slugify. Use runner mode when the provider should keep a fixed
server-side action registry and avoid exposing one callable route per button.
Runner providers must treat actionId as an identifier only, look it up in that
registry, authorize it server-side, and re-read target documents before mutating
content or protected state.
Runner actions may use the provider default runner route or override it per action:
{ id: "entry.rebuild", runner: { route: "actions/run-entry" }, label: "Rebuild" }payload is only static action input defaults. Inline form values and field
option payload values are merged into the request payload at submit time, and
user-provided form values win over defaults. context and target stay
top-level in runner invocations.
target.idFrom and target.idKeys can produce client-side missing-target
warnings before a request is sent. They are ergonomics only: provider runners
must re-read and validate the authoritative target server-side.
[!IMPORTANT] Field JSON belongs in the target collection schema's
fieldsarray. It does not go insideactionsPlugin()orastro.config.mjs.
[!WARNING] Provider routes must be relative plugin routes such as
cache/clearor.well-known/actions. Absolute URLs, query strings, hashes, encoded paths, traversal segments, spaces, and backslashes are rejected.
Action Responses
Provider routes return JSON that controls the clicked button and optional browser effects:
message,severity,color,backgroundColor, andborderColordrive temporary inline feedback.actionpatches the stable clicked button descriptor, useful for toggles.effectsor top-level aliases can runclipboard,open,download, andreload.effects.reloadacceptstrueor{ scope, delayMs }, wherescopeisfield,entry,dashboard, orpage. Scoped reloads dispatch the cancelableemdash-actions:reloadbrowser event so hosts can refresh a narrower surface; if unhandled, they fall back to a page reload.toastshows a Kumo toast.status: 202,jobStatus, andstatusRoutestart async polling.- An action result body with
status: 409,severity: "warning", and a reload effect is the canonical stale-target conflict result. Return this as the normal JSON action response body so the widget can run result effects. resultValueKeyon a field button can write a returned value back into the field.
See the focused response recipes for complete payloads: Clipboard Effect, Open Effect, Download Effect, Toast Notification, Feedback And Colors, Action Patch, and Async Job.
Entry Context
Field buttons can use inferred entry context. For direct-route actions,
contextKey and contextValueKey merge that context into the flat request
payload for compatibility. Runner actions keep the same context top-level in the
ActionInvocation body instead of hiding it inside payload.
Without a host-provided field-widget context, the plugin infers what it can from the admin route and saved EmDash APIs: collection, entry id, new/edit state, field name, current field value, saved entry data, and current user when available.
[!WARNING] Entry context is best-effort. It cannot infer live unsaved values from sibling fields. Pass the current field value with
valueKey, use staticpayload, or have the provider read the latest saved entry server-side.
Provider Formats
emdash-actions is a native EmDash UI plugin and belongs in plugins: [].
Provider plugins can use different formats:
- Native providers use
definePlugin()fromemdashand receive one mergedRouteContextargument. - Standard trusted providers can expose the same manifest and routes in
plugins: []. - Standard sandboxed providers export a
SandboxedPluginroute map, run insandboxed: [], and receive(routeCtx, ctx).
See Sandboxed Provider for the sandboxed shape and caveats.
Configuration At A Glance
actionsPlugin() Options
providers: provider plugins the dashboard widget should discover.placement: dashboard manifest placement to render. Defaults todashboard. Set tonullto show all placements.title: dashboard widget title. Defaults toActions.size: dashboard widget size,full,half, orthird.entrypointandadminEntry: advanced descriptor overrides.
Provider Options
pluginId: provider plugin id.label: human-readable provider label. This names the provider, not an action button.manifestRoute: provider route that returns the manifest. Defaults to.well-known/actions.runnerRoute: provider route forrunneractions. Defaults to.well-known/actions/run.allowedTargetPluginIds: cross-plugin route targets this provider may call.
Field Button Options
The most common field options are:
mode:clipboardorrun.providerorpluginId: provider plugin id.route: direct provider route.action: provider manifest action id.label: field-local button command text. Withaction, this overrides the manifest action label for this field. Without it, manifest-backed buttons use the manifest action label.description,icon,tone,confirm: button UI.iconis optional decoration and the text label still renders.payload: static JSON body values.valueKey: include the current field value in the request body.contextKeyandcontextValueKey: include inferred context in direct-route payloads. Runner actions receive context top-level.resultValueKey: write a returned result value back into the field.clipboardText,clipboardValueKey,clipboardContextValueKey,clipboardSuccess: clipboard mode.feedback,buttonStyle,cooldownMs: temporary feedback and styling.resultEffect,pollIntervalMs,pollTimeoutMs: response shortcuts and async polling.
The exported TypeScript contracts live in src/types.ts.
Special Cases
[!NOTE] Clipboard mode uses
navigator.clipboard.writeText()in the browser. It does not call the backend, and browsers require HTTPS or localhost plus clipboard permission.
[!CAUTION] Use
confirmfor destructive dashboard actions such as cache clearing, maintenance toggles, deploys, exports, or purge operations.
- Direct field routes can specify
options.providerandoptions.routein the field JSON. They do not need to be listed inactionsPlugin({ providers })unless the dashboard should discover them too. - Cross-plugin target routes require
allowedTargetPluginIds; otherwise a provider manifest can only target its own plugin id. DELETEactions do not receive a JSON body, so payload, value, and context keys are useful withPOST,PUT, andPATCH.- Protected downloads can use
effects.download.routeto fetch through the action target plugin with EmDash auth headers. Sandboxed providers should prefer public or signed URLs for binary downloads, or delegate streaming to a trusted native route. - Async jobs need a provider-owned
statusRoute. Without one, the widget can show that work was accepted but cannot infer queued, running, failed, or completed state.
Development
npm ci
npm run check
npm run typecheck
npm run build
npm run pack:check