Block Kit

Auf dieser Seite

EmDashs Block Kit lässt Sandbox-Plugins ihre Admin-UI als JSON beschreiben. Der Host rendert die Blöcke — es läuft nie plugin-eigenes JavaScript im Browser.

So funktioniert es

  1. Der Benutzer navigiert zur Admin-Seite eines Plugins.
  2. Die Admin sendet eine page_load-Interaktion an die Admin-Route des Plugins.
  3. Das Plugin gibt eine BlockResponse mit einem Array von Blöcken zurück.
  4. Die Admin rendert die Blöcke mit der BlockRenderer-Komponente.
  5. Wenn der Benutzer interagiert (auf einen Button klickt, ein Formular absendet), sendet die Admin die Interaktion zurück an das Plugin.
  6. Das Plugin gibt neue Blöcke zurück, und der Zyklus wiederholt sich.

Fügen Sie @emdash-cms/blocks und zod zum Plugin hinzu, wenn es eine Block-Kit-Seite definiert:

pnpm add @emdash-cms/blocks zod

Deklarieren Sie die Seite im Plugin-Manifest, damit die Admin 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 beim Absenden dessen Werte:

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 Admin sie aufruft. Der Handler validiert dennoch routeCtx.input, weil sein TypeScript-Typ unknown ist und ein Aufrufer eine private Plugin-Route außerhalb der Block-Kit-Seite aufrufen kann.

EmDash validiert jede Seiten- und Widget-Antwort, bevor die Admin sie rendert. Ein ungültiger Block, unsichere URL, Link zu einer nicht deklarierten Plugin-Seite oder eine Antwort über den Block-Kit-Limits lässt die Anfrage fehlschlagen, statt den Browser zu erreichen. Eine Antwort kann bis zu 256 KiB, 20 Verschachtelungsebenen, 2.000 Knoten, 1.000 Elemente pro Array und 64 KiB pro Zeichenkette 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 muss. Der Host leitet diesen Wert aus dem Admin-Locale-Cookie oder der Anfragesprache ab und prüft die angeforderte Seite oder das 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 Oberfläche, Locale und Textrichtung. Die Admin-Locale ist getrennt von ctx.site.locale, das die Standard-Inhaltslocale der Site beschreibt. Manifest-Labels bleiben statische Zeichenketten.

Verwenden Sie ein link-Element, um zu navigieren, ohne eine Block-Kit-Aktion auszulösen. EmDash konstruiert interne URLs aus strukturierten Zielen, sodass Plugins Admin-Routenpfade 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, gespeicherter Eintrags-ID und optionaler Inhaltslocale;
  • plugin-page, mit einem vom selben Plugin deklarierten Pfad;
  • plugin-settings; und
  • external, mit einer absoluten HTTP-, HTTPS- oder mailto:-URL.

Externe Links öffnen in einem neuen Tab mit noopener noreferrer. Link-Elemente akzeptieren kein action_id und können nicht als Formularfelder erscheinen. Verwenden Sie einen Button, wenn die Interaktion die Plugin-Route aufrufen muss.

Block-Bilder nutzen dieselbe 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 lassen die gesamte Block-Kit-Antwort ablehnen.

Zeilenaktionen in Tabellen

Setzen Sie das format einer Tabellenspalte auf element, um in jeder Zeile einen Button, 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 einem Button bietet:

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 eine block_action mit der action_id des Menüs und dem value des Eintrags. Eintragswerte müssen innerhalb eines Menüs eindeutig sein. Elementzellen akzeptieren nur button-, link- und menu-Elemente. Ein Menü kann auch in einem actions-Block, als Section-Accessory oder in Empty-State-Aktionen erscheinen, aber nicht als Formularfeld. Der Builder elements.menu(actionId, label, items, { style }) gibt dieselbe Struktur zurück.

Panels und Aktionen für gespeicherte Einträge

Deklarieren Sie ein Editor-Panel, wenn ein Plugin Informationen neben einem gespeicherten Eintrag zeigen muss. Panels starten eingeklappt und rufen ihre private Route nur auf, wenn ein Editor 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 Editoren die Erweiterung aufrufen können. Der Host lädt den gespeicherten Eintrag erneut und prüft seinen Eigentümer, bevor er das Plugin aufruft.

Editor-Erweiterungsrouten 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, gespeicherte Eintrags-ID, Inhaltslocale und Version. routeCtx.ui.extensionId identifiziert die ausgewählte Deklaration. Verwenden Sie ctx.content mit der Capability content:read, wenn das Plugin gespeicherten Inhalt braucht.

Ein Panel empfängt { type: "panel_load" }, wenn es geöffnet wird. Panel-Load enthält nie Entwurfsdaten. Spätere Button- und Formularinteraktionen nutzen die üblichen Formen block_action und form_submit. Wenn das Plugin admin.editor-draft:read deklariert und die Erweiterung draft.read einschränkt, erhält eine explizite Interaktion auch routeCtx.input.draft. Der Snapshot enthält nur ausgewählte aktuelle Werte, bereinigte Felddefinitionen, 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. Entwurfszugriff erfordert eine explizite collections-Liste.

admin.editor-draft:patch ist unabhängig vom Lesezugriff. Es erlaubt einer Route, nach einer expliziten Interaktion einen Ganzfeld-Patch 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 jede Operation zusammen gegen das aktuelle Server-Schema, Capability, Collection, Feldselektor, Locale, Basisrevision, Eigentümerschaft, Anzahl-Limits und Byte-Limits. Der Browser wiederholt Identitäts-, Generations- und Feldprüfungen, bevor er eine vom Host gerenderte Vorschau zeigt. Das Anwenden der Vorschau markiert das Formular als geändert und speichert nicht, erzeugt keine Revision und führt keine Hooks aus. Jede Bearbeitung, während das Plugin arbeitet, lehnt das Gesamtergebnis ab.

Nur-gespeicherte Editor-Aktionen bleiben deaktiviert, solange das Formular ungespeicherte Änderungen hat. Entwurfsbewusste Aktionen können gegen das ungespeicherte Formular laufen. Eine Aktion empfängt { type: "editor_action" } und, wenn deklariert, denselben begrenzten Entwurfs-Snapshot. Geben Sie ein Objekt mit optionalem Toast und höchstens einem terminalen Effekt zurück:

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 kann terminale Effekte nicht kombinieren. EmDash lehnt unbekannte Befehle, unsichere Navigation, ungültige oder veraltete Patches und Antworten über den Block-Kit-Limits ab, bevor es einen Effekt anwendet.

Blocktypen

TypeDescription
headerGroße fette Überschrift
sectionText mit optionalem Accessory-Element
dividerHorizontale Linie
fieldsZweispaltiges Label/Wert-Raster
tableDatentabelle mit Formatierung, Sortierung, Pagination
actionsHorizontale Reihe von Buttons und Steuerelementen
statsDashboard-Metrikkarten mit Trendindikatoren
formEingabefelder mit bedingter Sichtbarkeit und Absenden
imageBlock-Ebenen-Bild mit Alt-Text und optionalem Titel
contextKleiner gedämpfter Hilfetext
columns2–3-Spalten-Layout mit verschachtelten Blöcken
emptyLeerzustands-Titel mit optionaler Beschreibung, Befehl und Aktionsbuttons
accordionEinklappbarer Abschnitt mit verschachtelten Blöcken
chartLinien- oder Balkenzeitserie oder Diagramm mit benutzerdefinierten Optionen
bannerStatus- oder Alert-Nachricht mit Titel oder Beschreibung
meterNumerischer Wert gegen Minimum und Maximum angezeigt
codeSchreibgeschützter TypeScript-, TSX-, JSONC-, Bash- oder CSS-Code
tabBeschriftete Panels mit verschachtelten Blöcken

Elementtypen

TypeDescription
buttonAktionsbutton mit optionalem Bestätigungsdialog
linkVom Host aufgelöste interne oder externe Navigation
menuButton, der eine Liste von Optionen öffnet; jede Option löst eine Aktion aus
text_inputEinzeilige oder mehrzeilige Texteingabe
number_inputNumerische Eingabe mit min/max
selectDropdown-Auswahl
toggleEin/Aus-Schalter
secret_inputMaskierte Eingabe für API-Schlüssel und Tokens
checkboxMehrere Werte aus einer festen Liste wählen
comboboxDurchsuchbare Einzelwertauswahl
date_inputDatumswert
radioEinzelwahl aus einer sichtbaren Optionsliste

Der Portable-Text-Feldeditor unterstützt auch repeater und media_picker. Sie sind keine Formularfelder für eine Sandbox-Plugin-Admin-Seite.

Builder-Hilfen

Das Paket @emdash-cms/blocks exportiert dieselben Formen über die Builder-Objekte blocks und elements. Builder reduzieren Fehler bei Eigenschaftsnamen und geben 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 bedingt basierend auf anderen Feldwerten 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 zu zeigen, dass bereits ein Wert existiert; es akzeptiert oder gibt den gespeicherten Wert beim Laden der Seite nicht zurück. Das Feld maskiert die Eingabe im Browser. Deklarieren Sie den passenden Schlüssel als type: "secret" in admin.settingsSchema und speichern Sie ihn über ctx.settings, damit EmDash ihn verschlüsselt. Folgen Sie Secret settings, bevor Sie Anmeldedaten speichern.

Ausprobieren

Nutzen Sie den Block Playground, um Block-Layouts interaktiv zu bauen und zu testen.