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:
dckc committed 2026-10-07 19:57:43 -05:00
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.