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
This commit is contained in:
1 parent
af8dd75fd1
commit
4b4ef8fea6
52 files changed
+18118
No files matched your search
@@ -0,0 +1,211 @@
|
||||
# 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`:
|
||||
|
||||
```jsonc title="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`:
|
||||
|
||||
```typescript title="src/plugin.ts"
|
||||
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](./block-kit.md) 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`:
|
||||
|
||||
```jsonc title="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:
|
||||
|
||||
```jsonc title="emdash-plugin.jsonc"
|
||||
{
|
||||
"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:
|
||||
|
||||
```typescript title="src/admin.tsx"
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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.
|
||||
Reference in new issue
Block a user