Mit dem Block Kit von EmDash beschreiben Sandbox-Plugins ihre Admin-UI als JSON. Der Host rendert die Blöcke – im Browser wird nie JavaScript ausgeführt, das ein Plugin mitliefert.
So funktioniert es
- Der Benutzer navigiert zur Admin-Seite eines Plugins.
- Die Administration sendet eine
page_load-Interaktion an die Admin-Route des Plugins. - Das Plugin gibt eine
BlockResponsezurück, die ein Array von Blöcken enthält. - Die Administration rendert die Blöcke mit der Komponente
BlockRenderer. - Wenn der Benutzer interagiert (auf eine Schaltfläche klickt, ein Formular absendet), sendet die Administration die Interaktion an das Plugin zurück.
- Das Plugin gibt neue Blöcke zurück, und der Zyklus beginnt von vorn.
Fügen Sie dem Plugin @emdash-cms/blocks und zod hinzu, wenn es eine Block-Kit-Seite definiert:
pnpm add @emdash-cms/blocks zod
Deklarieren Sie die Seite im Plugin-Manifest, damit die Administration einen Navigationseintrag zum Laden hat:
"admin": {
"pages": [{ "path": "/settings", "label": "Settings", "icon": "settings" }],
}
Die folgende admin-Route validiert die Interaktion, rendert beim Laden der Seite ein Formular und speichert dessen Werte beim Absenden:
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({ api_url: z.url(), enabled: z.boolean() }),
}),
]);
function renderSettings(): BlockResponse {
return {
blocks: [
{ type: "header", text: "Save Log 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" },
},
],
};
}
const plugin: SandboxedPlugin = {
routes: {
admin: {
handler: async (routeCtx, ctx) => {
const parsed = interactionSchema.safeParse(routeCtx.input);
if (!parsed.success) return { blocks: [] };
const interaction = parsed.data;
if (interaction.type === "page_load") {
return renderSettings();
}
if (interaction.type === "form_submit" && interaction.action_id === "save") {
await ctx.settings.set("apiUrl", interaction.values.api_url);
await ctx.settings.set("enabled", interaction.values.enabled);
return {
...renderSettings(),
toast: { message: "Settings saved", type: "success" },
};
}
return { blocks: [] };
},
},
},
};
export default plugin;
Die admin-Route ist standardmäßig privat. EmDash sendet den korrekten CSRF-Header, wenn die Administration sie aufruft. Der Handler validiert routeCtx.input trotzdem, weil sein TypeScript-Typ unknown ist und ein Aufrufer eine private Plugin-Route auch außerhalb der Block-Kit-Seite aufrufen kann.
EmDash validiert jede Antwort einer Sandbox-Seite und eines Sandbox-Widgets, bevor die Administration sie rendert. Ein ungültiger Block, eine unsichere URL, ein Link auf eine nicht deklarierte Plugin-Seite oder eine Antwort jenseits der Block-Kit-Limits lässt die Anfrage fehlschlagen, statt den Browser zu erreichen. Eine Antwort darf bis zu 256 KiB, 20 Verschachtelungsebenen, 2.000 Knoten, 1.000 Elemente pro Array und 64 KiB pro String enthalten.
UI-Locale und Schreibrichtung
Lesen Sie routeCtx.ui, wenn eine Seite oder ein Widget Text für die aktive Locale des Administrators zurückgeben soll. Der Host leitet diesen Wert aus dem Locale-Cookie der Administration oder der Anfragesprache ab und prüft die angeforderte Seite oder das angeforderte Widget gegen das Plugin-Manifest.
import type { SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
routes: {
admin: {
handler: async (routeCtx) => {
if (!routeCtx.ui) return { blocks: [] };
const heading = routeCtx.ui.locale === "ar" ? "حالة المحتوى" : "Content status";
return {
blocks: [{ type: "header", text: heading }],
};
},
},
},
};
export default plugin;
routeCtx.ui enthält die Oberfläche, die Locale und die Schreibrichtung. Die Locale der Administration ist von ctx.site.locale getrennt, das die Standard-Inhalts-Locale der Site beschreibt. Manifest-Beschriftungen bleiben statische Strings.
Navigationslinks
Verwenden Sie ein link-Element, um zu navigieren, ohne eine Block-Kit-Aktion auszulösen. EmDash konstruiert interne URLs aus strukturierten Zielen, sodass Plugins die Routenpfade der Administration nicht kennen müssen.
return {
blocks: [
{
type: "actions",
elements: [
{
type: "link",
label: "Edit article",
target: { kind: "content", collection: "posts", id: "01K5POSTEXAMPLE", locale: "en" },
appearance: "primary",
},
{
type: "link",
label: "Plugin settings",
target: { kind: "plugin-settings" },
},
],
},
],
};
Die verfügbaren Ziele sind:
content, mit einer Collection, der ID eines gespeicherten Eintrags und einer optionalen Inhalts-Locale;plugin-page, mit einem Pfad, den dasselbe Plugin deklariert hat;plugin-settings; undexternal, mit einer absoluten HTTP-, HTTPS- odermailto:-URL.
Externe Links öffnen sich mit noopener noreferrer in einem neuen Tab. Link-Elemente akzeptieren kein action_id und können nicht als Formularfelder erscheinen. Verwenden Sie eine Schaltfläche, wenn die Interaktion die Plugin-Route aufrufen muss.
Block-Bilder unterliegen derselben Browser-Ressourcenrichtlinie. Root-relative Bild-URLs sind erlaubt. Ein externes Bild muss HTTPS verwenden, und sein Hostname muss in den allowedHosts des Plugins stehen. Ein Plugin mit network:request:unrestricted kann ein HTTPS-Bild von jedem Hostnamen laden. Andere externe Bilder führen dazu, dass die gesamte Block-Kit-Antwort abgelehnt wird.
Zeilenaktionen in Tabellen
Setzen Sie das format einer Tabellenspalte auf element, um in jeder Zeile eine Schaltfläche, einen Link oder ein Menü zu platzieren. Jede Zeile speichert das Element unter dem Schlüssel der Spalte; eine Zeile ohne Wert lässt die Zelle leer. Verwenden Sie ein menu-Element, wenn eine Zeile mehrere Auswahlmöglichkeiten hinter einer Schaltfläche anbietet:
return {
blocks: [
{
type: "table",
page_action_id: "missing_page",
columns: [
{ key: "title", label: "Entry" },
{ key: "languages", label: "Missing" },
{ key: "action", label: "Actions", format: "element" },
],
rows: [
{
title: "Hello world",
languages: "French, Italian",
action: {
type: "menu",
action_id: "translate",
label: "Translate",
items: [
{ label: "French", value: "fr:01K5POSTEXAMPLE" },
{ label: "Italian", value: "it:01K5POSTEXAMPLE" },
],
},
},
],
},
],
};
Die Auswahl eines Menüeintrags sendet ein block_action mit der action_id des Menüs und dem value des Eintrags. Eintragswerte müssen innerhalb eines Menüs eindeutig sein. Element-Zellen akzeptieren nur button-, link- und menu-Elemente. Ein Menü kann auch in einem actions-Block, als Section-Accessory oder in Aktionen für leere Zustände erscheinen, aber nicht als Formularfeld. Der Builder elements.menu(actionId, label, items, { style }) gibt dieselbe Form zurück.
Panels und Aktionen für gespeicherte Einträge
Deklarieren Sie ein Editor-Panel, wenn ein Plugin Informationen neben einem gespeicherten Eintrag anzeigen soll. Panels starten eingeklappt und rufen ihre private Route erst auf, wenn ein Redakteur sie öffnet.
Das folgende Manifest fügt ein Panel für Beiträge und eine bestätigte Reparaturaktion hinzu:
"admin": {
"editorPanels": [
{
"id": "content-health",
"title": "Content health",
"route": "editor/content-health",
"collections": ["posts"],
"draft": {
"read": { "translatable": true },
"patch": { "fields": ["title", "excerpt", "body"] },
},
},
],
"editorActions": [
{
"id": "repair-metadata",
"label": "Repair metadata",
"route": "editor/repair-metadata",
"placement": "overflow",
"style": "danger",
"confirm": {
"title": "Repair metadata?",
"text": "This changes the saved entry.",
"confirm": "Repair",
"deny": "Cancel",
},
},
],
}
Jede referenzierte Route muss privat sein. Ihre permission steuert, welche Redakteure die Erweiterung aufrufen können. Der Host lädt außerdem den gespeicherten Eintrag neu und prüft dessen Eigentümer, bevor er das Plugin aufruft.
Routen für Editor-Erweiterungen erhalten einen beglaubigten routeCtx.ui-Wert. Für die Oberflächen content-editor-panel und content-editor-action enthält routeCtx.ui.entry die Collection, die ID des gespeicherten Eintrags, die Inhalts-Locale und die Version. routeCtx.ui.extensionId identifiziert die ausgewählte Deklaration. Verwenden Sie ctx.content mit der Capability content:read, wenn das Plugin gespeicherte Inhalte benötigt.
Ein Panel erhält beim Öffnen { type: "panel_load" }. Das Laden eines Panels enthält nie Entwurfsdaten. Seine späteren Schaltflächen- und Formularinteraktionen verwenden die üblichen Formen block_action und form_submit. Wenn das Plugin admin.editor-draft:read deklariert und die Erweiterung draft.read eingrenzt, erhält eine explizite Interaktion zusätzlich routeCtx.input.draft. Der Snapshot enthält nur ausgewählte aktuelle Werte, bereinigte Felddefinitionen, die gespeicherte Identität und die persistierte Basisrevision. Verwenden Sie fields für explizite Slugs, translatable: true für die übersetzbaren Felder der Collection oder beides. Der Entwurfszugriff erfordert eine explizite collections-Liste.
admin.editor-draft:patch ist unabhängig vom Lesezugriff. Es erlaubt einer Route, nach einer expliziten Interaktion einen Patch ganzer Felder zurückzugeben:
const draft = routeCtx.input.draft;
return {
blocks: [],
patch: {
type: "editor-draft-patch",
operations: [
{ op: "set", field: "title", value: translate(draft.fields.title) },
{ op: "clear", field: "excerpt" },
],
},
};
EmDash validiert alle Operationen gemeinsam gegen das aktuelle Server-Schema, die Capability, die Collection, die Feldauswahl, die Locale, die Basisrevision, die Eigentümerschaft sowie die Anzahl- und Byte-Limits. Der Browser wiederholt die Prüfungen von Identität, Generation und Feldern, bevor er eine vom Host gerenderte Vorschau anzeigt. Das Anwenden der Vorschau markiert das Formular als geändert und speichert nicht, erstellt keine Revision und führt keine Hooks aus. Jede Bearbeitung, die vorgenommen wird, während das Plugin arbeitet, lässt das gesamte Ergebnis ablehnen.
Editor-Aktionen, die nur für gespeicherte Einträge gelten, bleiben deaktiviert, solange das Formular ungespeicherte Änderungen enthält. Entwurfsfähige Aktionen können gegen das ungespeicherte Formular ausgeführt werden. Eine Aktion erhält { type: "editor_action" } und, wenn deklariert, denselben begrenzten Entwurfs-Snapshot. Geben Sie ein Objekt zurück, das einen optionalen Toast und höchstens einen terminalen Effekt enthält:
return {
toast: { type: "success", message: "Metadata repaired" },
refresh: true,
};
Verwenden Sie refresh: true, um den Eintrag neu zu laden, navigate mit einem strukturierten Link-Ziel oder patch, um ungespeicherte Feldänderungen vorzuschlagen. Eine Antwort darf terminale Effekte nicht kombinieren. EmDash lehnt unbekannte Befehle, unsichere Navigation, ungültige oder veraltete Patches und Antworten jenseits der Block-Kit-Limits ab, bevor ein Effekt angewendet wird.
Block Kit in nativen Plugins
Ein natives Plugin kann Block-Kit-Seiten und -Widgets rendern, ohne React mitzuliefern. Deklarieren Sie admin.pages oder admin.widgets in definePlugin(), lassen Sie admin.entry ungesetzt und fügen Sie eine Route namens admin hinzu:
import { definePlugin } from "emdash";
export function createPlugin() {
return definePlugin({
id: "plugin-status",
version: "0.1.0",
routes: {
admin: {
handler: async (ctx) => {
// Validate ctx.input as in the sandboxed example above.
return { blocks: [{ type: "header", text: "Status" }] };
},
},
},
admin: {
pages: [{ path: "/status", label: "Status", icon: "gauge" }],
},
});
}
Ein natives Plugin verwendet das Block Kit, wenn es Seiten, Widgets oder Editor-Erweiterungen und kein admin.entry deklariert. Das Setzen von admin.entry schaltet es auf React-Admin-Seiten um. Deklarieren Sie die admin-Route explizit: Die implizite admin-Route existiert nur für ältere Sandbox-Bundles. Ein nativer Handler erhält einen einzelnen RouteContext, sodass die Interaktion als ctx.input ankommt.
Zwei oben beschriebene Verhaltensweisen gelten derzeit nur für Sandbox-Plugins auf Admin-Seiten und Dashboard-Widgets:
ctx.uiist auf der Seite oder dem Widget eines nativen Pluginsundefined, sodass die Route daraus weder die Locale der Administration noch die Schreibrichtung lesen kann. Native Editor-Panels und -Aktionen erhaltenctx.uidagegen schon.- EmDash validiert die Antwort der Seite oder des Widgets eines nativen Plugins nicht, bevor die Administration sie rendert. Halten Sie native Antworten innerhalb derselben Blocktypen, Limits sowie Link- und Bildregeln, weil derselbe Renderer sie zeichnet.
Blocktypen
| Typ | Beschreibung |
|---|---|
header | Große, fette Überschrift |
section | Text mit optionalem Accessory-Element |
divider | Horizontale Trennlinie |
fields | Zweispaltiges Raster aus Beschriftung und Wert |
table | Datentabelle mit Formatierung, Sortierung und Paginierung |
actions | Horizontale Reihe aus Schaltflächen und Steuerelementen |
stats | Dashboard-Kennzahlenkarten mit Trendanzeigen |
form | Eingabefelder mit bedingter Sichtbarkeit und Absenden |
image | Bild auf Blockebene mit Alternativtext und optionalem Titel |
context | Kleiner, gedämpfter Hilfetext |
columns | Layout mit 2–3 Spalten und verschachtelten Blöcken |
empty | Titel für leere Zustände mit optionaler Beschreibung, Befehl und Aktionsschaltflächen |
accordion | Einklappbarer Abschnitt, der verschachtelte Blöcke umschließt |
chart | Linien- oder Balkenzeitreihe oder ein Diagramm mit benutzerdefinierten Optionen |
banner | Status- oder Warnmeldung mit Titel oder Beschreibung |
meter | Numerischer Wert, dargestellt gegenüber einem Minimum und Maximum |
code | Schreibgeschützter TypeScript-, TSX-, JSONC-, Bash- oder CSS-Code |
tab | Beschriftete Panels mit verschachtelten Blöcken |
Elementtypen
| Typ | Beschreibung |
|---|---|
button | Aktionsschaltfläche mit optionalem Bestätigungsdialog |
link | Vom Host aufgelöste interne oder externe Navigation |
menu | Schaltfläche, die eine Liste von Auswahlmöglichkeiten öffnet; jede löst eine Aktion aus |
text_input | Ein- oder mehrzeilige Texteingabe |
number_input | Numerische Eingabe mit Minimum/Maximum |
select | Dropdown-Auswahl |
toggle | Ein/Aus-Schalter |
secret_input | Maskierte Eingabe für API-Schlüssel und Tokens |
checkbox | Mehrere Werte aus einer festen Liste auswählen |
combobox | Durchsuchbare Einzelwertauswahl |
date_input | Datumswert |
radio | Einzelauswahl aus einer sichtbaren Optionsliste |
Der Portable-Text-Feldeditor unterstützt außerdem repeater und media_picker. Sie sind keine Formularfelder für die Admin-Seite eines Sandbox-Plugins.
Select-Optionen aus einer Plugin-Route laden
Ein select in den fields eines Portable-Text-Blocks kann optionsRoute setzen, um sein Dropdown aus einer der eigenen Routen des Plugins zu füllen. Dasselbe gilt für ein select, das in diesen Feldern in einem repeater verschachtelt ist.
definePlugin({
id: "plugin-cards",
version: "0.1.0",
storage: {
cards: { indexes: ["title"] },
},
routes: {
"cards/list": {
handler: async (ctx) => {
const result = await ctx.storage.cards.query({ limit: 100 });
return {
items: result.items.map((card) => ({ id: card.id, name: card.data.title })),
};
},
},
},
admin: {
portableTextBlocks: [
{
type: "card",
label: "Card",
fields: [
{
type: "select",
action_id: "cardId",
label: "Card",
options: [],
optionsRoute: "cards/list",
},
],
},
],
},
});
Jedes select mit optionsRoute ruft die Route beim Rendern auf. Ein Block mit zwei solchen Feldern oder ein Repeater mit mehreren Einträgen sendet also je eine Anfrage pro Feld. Das Einklappen und erneute Öffnen eines Repeater-Eintrags sendet die Anfrage erneut. Die Anfrage lautet POST /_emdash/api/plugins/<pluginId>/<optionsRoute>. Sie trägt den Header X-EmDash-Request: 1 und als Body ein leeres JSON-Objekt. Die Route ist eine normale Plugin-Route und benötigt daher die Berechtigung plugins:manage, sofern sie keine andere permission deklariert.
Der Handler gibt { items: Array<{ id: string; name: string }> } zurück. Die Route antwortet mit dem Standard-Envelope von EmDash ({ success: true, data: { items: [...] } }, siehe API-Routen), und der Block-Editor liest die Optionen aus data.items. Die Administration zeigt jedes Element als Option an, wobei id der gespeicherte Wert und name die Beschriftung ist. Zusätzliche Eigenschaften eines Elements werden ignoriert.
Während die Anfrage läuft, zeigt das Feld einen Ladezustand. Schlägt die Anfrage fehl, ist die Antwort nicht OK oder enthält die Antwort kein items-Array, fällt das Feld auf das statische options-Array zurück. Ist dieses statische options-Array leer, hat das Dropdown in diesem Fall keine Auswahlmöglichkeiten.
optionsRoute wirkt nur im Portable-Text-Block-Editor. Der Block-Kit-Renderer für Admin-Seiten, Widgets und Panels gespeicherter Einträge sowie der Renderer für deklarative Feld-Widgets lesen nur das statische options-Array und ignorieren optionsRoute. Ein select an diesen Stellen muss seine Optionen statisch auflisten, und eine Block-Kit-Antwort muss ihm mindestens eine mitgeben.
Builder-Hilfen
Das Paket @emdash-cms/blocks exportiert dieselben Formen über die Builder-Objekte blocks und elements. Builder verringern Fehler bei Eigenschaftsnamen und geben dabei gewöhnliche, JSON-kompatible Objekte zurück:
import { blocks, elements } from "@emdash-cms/blocks";
const { header, form } = blocks;
const { textInput, toggle, select, link } = elements;
return {
blocks: [
header("SEO 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" },
}),
blocks.actions([link("Open settings", { kind: "plugin-page", path: "/settings" })]),
],
};
Bedingte Felder
Formularfelder können abhängig von den Werten anderer Felder bedingt angezeigt werden:
{
"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 }
}
Das Feld api_key erscheint nur, wenn auth_enabled eingeschaltet ist. Bedingungen werden clientseitig ohne Round-Trip ausgewertet.
secret_input verwendet has_value: true, um anzuzeigen, dass bereits ein Wert existiert; beim Laden der Seite nimmt es den gespeicherten Wert weder entgegen noch gibt es ihn zurück. Das Feld maskiert die Eingabe im Browser. Deklarieren Sie den passenden Schlüssel in admin.settingsSchema als type: "secret" und speichern Sie ihn über ctx.settings, damit EmDash ihn verschlüsselt. Befolgen Sie Secret settings, bevor Sie Zugangsdaten speichern.
Ausprobieren
Verwenden Sie den Block Playground, um Block-Layouts interaktiv zu erstellen und zu testen.