Block Kit

In questa pagina

Il Block Kit di EmDash consente ai plugin in sandbox di descrivere la propria UI di amministrazione come JSON. L’host renderizza i blocchi: nel browser non viene mai eseguito JavaScript fornito da un plugin.

Come funziona

  1. L’utente naviga alla pagina di amministrazione di un plugin.
  2. L’amministrazione invia un’interazione page_load alla route admin del plugin.
  3. Il plugin restituisce un BlockResponse contenente un array di blocchi.
  4. L’amministrazione renderizza i blocchi con il componente BlockRenderer.
  5. Quando l’utente interagisce (fa clic su un pulsante, invia un modulo), l’amministrazione rinvia l’interazione al plugin.
  6. Il plugin restituisce nuovi blocchi e il ciclo si ripete.

Aggiungi @emdash-cms/blocks e zod al plugin quando definisce una pagina Block Kit:

pnpm add @emdash-cms/blocks zod

Dichiara la pagina nel manifest del plugin, in modo che l’amministrazione abbia una voce di navigazione da caricare:

"admin": {
	"pages": [{ "path": "/settings", "label": "Settings", "icon": "settings" }],
}

La seguente route admin convalida l’interazione, renderizza un modulo al caricamento della pagina e ne memorizza i valori all’invio:

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;

La route admin è privata per impostazione predefinita. EmDash invia l’header CSRF corretto quando l’amministrazione la chiama. L’handler convalida comunque routeCtx.input, perché il suo tipo TypeScript è unknown e un chiamante può invocare una route di plugin privata al di fuori della pagina Block Kit.

EmDash convalida ogni risposta di pagina e widget in sandbox prima che l’amministrazione la renderizzi. Un blocco non valido, un URL non sicuro, un collegamento a una pagina di plugin non dichiarata o una risposta che supera i limiti del Block Kit fa fallire la richiesta anziché raggiungere il browser. Una risposta può contenere fino a 256 KiB, 20 livelli di annidamento, 2.000 nodi, 1.000 elementi per array e 64 KiB per stringa.

Locale e direzione dell’UI

Leggi routeCtx.ui quando una pagina o un widget deve restituire testo per la locale attiva dell’amministratore. L’host ricava questo valore dal cookie di locale dell’amministrazione o dalla lingua della richiesta e verifica la pagina o il widget richiesto rispetto al manifest del plugin.

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 contiene la superficie, la locale e la direzione del testo. La locale dell’amministrazione è distinta da ctx.site.locale, che descrive la locale di contenuto predefinita del sito. Le etichette del manifest restano stringhe statiche.

Collegamenti di navigazione

Usa un elemento link per navigare senza inviare un’azione Block Kit. EmDash costruisce gli URL interni a partire da destinazioni strutturate, quindi i plugin non hanno bisogno di conoscere i percorsi delle route di amministrazione.

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" },
				},
			],
		},
	],
};

Le destinazioni disponibili sono:

  • content, con una collection, l’ID di una voce salvata e una locale di contenuto facoltativa;
  • plugin-page, con un percorso dichiarato dallo stesso plugin;
  • plugin-settings; e
  • external, con un URL HTTP, HTTPS o mailto: assoluto.

I collegamenti esterni si aprono in una nuova scheda con noopener noreferrer. Gli elementi link non accettano action_id e non possono comparire come campi del modulo. Usa un pulsante quando l’interazione deve chiamare la route del plugin.

Le immagini dei blocchi usano la stessa policy sulle risorse del browser. Gli URL di immagine relativi alla radice sono consentiti. Un’immagine esterna deve usare HTTPS e il suo hostname deve comparire negli allowedHosts del plugin. Un plugin con network:request:unrestricted può caricare un’immagine HTTPS da qualsiasi hostname. Le altre immagini esterne causano il rifiuto dell’intera risposta Block Kit.

Azioni di riga nelle tabelle

Imposta il format di una colonna della tabella su element per inserire un pulsante, un collegamento o un menu in ogni riga. Ogni riga memorizza l’elemento sotto la chiave della colonna; una riga senza valore lascia vuota la cella. Usa un elemento menu quando una riga offre più scelte dietro un solo pulsante:

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" },
						],
					},
				},
			],
		},
	],
};

Scegliere una voce del menu invia un block_action con l’action_id del menu e il value della voce. I valori delle voci devono essere univoci all’interno di un menu. Le celle di elemento accettano solo elementi button, link e menu. Un menu può comparire anche in un blocco actions, come accessorio di una section o nelle azioni dello stato vuoto, ma non come campo del modulo. Il builder elements.menu(actionId, label, items, { style }) restituisce la stessa forma.

Pannelli e azioni delle voci salvate

Dichiara un pannello dell’editor quando un plugin deve mostrare informazioni accanto a una voce salvata. I pannelli partono compressi e chiamano la propria route privata solo quando un editor li apre.

Il manifest seguente aggiunge un pannello per i post e un’azione di riparazione con conferma:

"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",
			},
		},
	],
}

Ogni route referenziata deve essere privata. La sua permission controlla quali editor possono invocare l’estensione. L’host ricarica inoltre la voce salvata e ne controlla il proprietario prima di chiamare il plugin.

Le route delle estensioni dell’editor ricevono un valore routeCtx.ui attestato. Per le superfici content-editor-panel e content-editor-action, routeCtx.ui.entry contiene la collection, l’ID della voce salvata, la locale di contenuto e la versione. routeCtx.ui.extensionId identifica la dichiarazione selezionata. Usa ctx.content con la capability content:read quando il plugin ha bisogno del contenuto salvato.

Un pannello riceve { type: "panel_load" } quando viene aperto. Il caricamento del pannello non include mai dati di bozza. Le sue successive interazioni di pulsanti e moduli usano le consuete forme block_action e form_submit. Quando il plugin dichiara admin.editor-draft:read e l’estensione restringe draft.read, un’interazione esplicita riceve anche routeCtx.input.draft. Lo snapshot contiene solo i valori correnti selezionati, le definizioni dei campi sanificate, l’identità salvata e la revisione di base persistita. Usa fields per slug espliciti, translatable: true per i campi traducibili della collection, oppure entrambi. L’accesso alla bozza richiede un elenco collections esplicito.

admin.editor-draft:patch è indipendente dall’accesso in lettura. Consente a una route di restituire una patch di campi interi dopo un’interazione esplicita:

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 convalida tutte le operazioni insieme rispetto allo schema corrente del server, alla capability, alla collection, al selettore dei campi, alla locale, alla revisione di base, alla proprietà e ai limiti di numero e di byte. Il browser ripete i controlli di identità, generazione e campi prima di mostrare un’anteprima renderizzata dall’host. Applicare l’anteprima segna il modulo come modificato e non salva, non crea una revisione e non esegue hook. Qualsiasi modifica effettuata mentre il plugin sta lavorando fa rifiutare l’intero risultato.

Le azioni dell’editor valide solo per voci salvate restano disabilitate finché il modulo ha modifiche non salvate. Le azioni compatibili con le bozze possono essere eseguite sul modulo non salvato. Un’azione riceve { type: "editor_action" } e, se dichiarato, lo stesso snapshot di bozza delimitato. Restituisci un oggetto contenente un toast facoltativo e al massimo un effetto terminale:

return {
	toast: { type: "success", message: "Metadata repaired" },
	refresh: true,
};

Usa refresh: true per ricaricare la voce, navigate con una destinazione di collegamento strutturata, oppure patch per proporre modifiche ai campi non salvate. Una risposta non può combinare effetti terminali. EmDash rifiuta comandi sconosciuti, navigazione non sicura, patch non valide o obsolete e risposte che superano i limiti del Block Kit prima di applicare un effetto.

Block Kit nei plugin nativi

Un plugin nativo può renderizzare pagine e widget Block Kit senza includere React. Dichiara admin.pages o admin.widgets in definePlugin(), lascia admin.entry non impostato e aggiungi una route chiamata admin:

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" }],
		},
	});
}

Un plugin nativo usa il Block Kit quando dichiara pagine, widget o estensioni dell’editor e nessun admin.entry. Impostare admin.entry lo fa passare alle pagine di amministrazione React. Dichiara esplicitamente la route admin: la route admin implicita esiste solo per i vecchi bundle in sandbox. Un handler nativo riceve un singolo RouteContext, quindi l’interazione arriva come ctx.input.

Due comportamenti descritti sopra si applicano attualmente solo ai plugin in sandbox nelle pagine di amministrazione e nei widget della dashboard:

  • ctx.ui è undefined nella pagina o nel widget di un plugin nativo, quindi la route non può leggere da lì la locale dell’amministrazione o la direzione del testo. I pannelli e le azioni nativi dell’editor ricevono invece ctx.ui.
  • EmDash non convalida la risposta della pagina o del widget di un plugin nativo prima che l’amministrazione la renderizzi. Mantieni le risposte native entro gli stessi tipi di blocco, limiti e regole su collegamenti e immagini, perché le disegna lo stesso renderer.

Tipi di blocco

TipoDescrizione
headerIntestazione grande in grassetto
sectionTesto con un elemento accessorio facoltativo
dividerLinea orizzontale
fieldsGriglia a due colonne etichetta/valore
tableTabella di dati con formattazione, ordinamento e paginazione
actionsRiga orizzontale di pulsanti e controlli
statsSchede di metriche della dashboard con indicatori di tendenza
formCampi di input con visibilità condizionale e invio
imageImmagine a livello di blocco con testo alternativo e titolo facoltativo
contextPiccolo testo di aiuto attenuato
columnsLayout a 2–3 colonne con blocchi annidati
emptyTitolo dello stato vuoto con descrizione, comando e pulsanti di azione facoltativi
accordionSezione comprimibile che racchiude blocchi annidati
chartSerie temporale a linee o a barre, oppure un grafico con opzioni personalizzate
bannerMessaggio di stato o di avviso con un titolo o una descrizione
meterValore numerico mostrato rispetto a un minimo e a un massimo
codeCodice TypeScript, TSX, JSONC, Bash o CSS in sola lettura
tabPannelli etichettati che contengono blocchi annidati

Tipi di elemento

TipoDescrizione
buttonPulsante di azione con finestra di conferma facoltativa
linkNavigazione interna o esterna risolta dall’host
menuPulsante che apre un elenco di scelte; ogni scelta invia un’azione
text_inputInput di testo a riga singola o multiriga
number_inputInput numerico con minimo/massimo
selectSelezione a discesa
toggleInterruttore on/off
secret_inputInput mascherato per chiavi API e token
checkboxSelezione di più valori da un elenco fisso
comboboxSelezione di un singolo valore con ricerca
date_inputValore di data
radioScelta singola da un elenco di opzioni visibile

L’editor di campi Portable Text supporta anche repeater e media_picker. Non sono campi del modulo per la pagina di amministrazione di un plugin in sandbox.

Caricare le opzioni di un select da una route del plugin

Un select nei fields di un blocco Portable Text può impostare optionsRoute per popolare il proprio menu a tendina da una delle route del plugin stesso. Lo stesso vale per un select annidato in un repeater in quei campi.

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",
					},
				],
			},
		],
	},
});

Ogni select con optionsRoute chiama la route quando viene renderizzato, quindi un blocco con due di questi campi, o un repeater con più elementi, invia una richiesta per ciascuno. Comprimere e riaprire un elemento del repeater invia di nuovo la richiesta. La richiesta è POST /_emdash/api/plugins/<pluginId>/<optionsRoute>. Porta l’header X-EmDash-Request: 1 e un oggetto JSON vuoto come corpo. La route è una normale route di plugin, quindi richiede il permesso plugins:manage, a meno che dichiari un altro permission.

L’handler restituisce { items: Array<{ id: string; name: string }> }. La route risponde con l’envelope standard di EmDash ({ success: true, data: { items: [...] } }, vedi Route API), e l’editor di blocchi legge le opzioni da data.items. L’amministrazione mostra ogni elemento come un’opzione, con id come valore memorizzato e name come etichetta. Le proprietà aggiuntive di un elemento vengono ignorate.

Mentre la richiesta è in corso, il campo mostra uno stato di caricamento. Se la richiesta fallisce, la risposta non è OK o la risposta non contiene un array items, il campo ripiega sull’array statico options. Se quell’array statico options è vuoto, in tal caso il menu a tendina resta senza scelte.

optionsRoute ha effetto solo nell’editor di blocchi Portable Text. Il renderer Block Kit per pagine di amministrazione, widget e pannelli delle voci salvate, e il renderer dei widget di campo dichiarativi, leggono solo l’array statico options e ignorano optionsRoute. Un select in quei punti deve elencare le proprie opzioni in modo statico, e una risposta Block Kit deve fornirgliene almeno una.

Helper builder

Il pacchetto @emdash-cms/blocks esporta le stesse forme tramite gli oggetti builder blocks e elements. I builder riducono gli errori nei nomi delle proprietà e restituiscono comunque normali oggetti compatibili con JSON:

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" })]),
	],
};

Campi condizionali

I campi del modulo possono essere mostrati in modo condizionale in base ai valori di altri campi:

{
	"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 }
}

Il campo api_key compare solo quando auth_enabled è attivo. Le condizioni vengono valutate lato client, senza round trip.

secret_input usa has_value: true per indicare che esiste già un valore; non accetta né restituisce il valore memorizzato al caricamento della pagina. Il campo maschera ciò che viene digitato nel browser. Dichiara la chiave corrispondente come type: "secret" in admin.settingsSchema e salvala tramite ctx.settings, in modo che EmDash la cifri. Segui Secret settings prima di memorizzare le credenziali.

Provalo

Usa il Block Playground per creare e testare layout di blocchi in modo interattivo.