Block Kit

Sur cette page

Le Block Kit d’EmDash permet aux plugins sandboxés de décrire leur UI d’administration en JSON. L’hôte rend les blocs — aucun JavaScript fourni par le plugin ne s’exécute jamais dans le navigateur.

Fonctionnement

  1. L’utilisateur navigue vers la page d’administration d’un plugin.
  2. L’admin envoie une interaction page_load à la route d’administration du plugin.
  3. Le plugin renvoie une BlockResponse contenant un tableau de blocs.
  4. L’admin rend les blocs avec le composant BlockRenderer.
  5. Lorsque l’utilisateur interagit (clique sur un bouton, soumet un formulaire), l’admin renvoie l’interaction au plugin.
  6. Le plugin renvoie de nouveaux blocs, et le cycle se répète.

Ajoutez @emdash-cms/blocks et zod au plugin lorsqu’il définit une page Block Kit :

pnpm add @emdash-cms/blocks zod

Déclarez la page dans le manifeste du plugin pour que l’admin ait une entrée de navigation à charger :

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

La route admin suivante valide l’interaction, rend un formulaire au chargement de la page et stocke ses valeurs à la soumission :

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 est privée par défaut. EmDash envoie le bon en-tête CSRF lorsque l’admin l’appelle. Le gestionnaire valide quand même routeCtx.input car son type TypeScript est unknown et un appelant peut invoquer une route privée de plugin hors de la page Block Kit.

EmDash valide chaque réponse de page et de widget avant que l’admin ne la rende. Un bloc invalide, une URL dangereuse, un lien vers une page de plugin non déclarée ou une réponse au-delà des limites Block Kit fait échouer la requête au lieu d’atteindre le navigateur. Une réponse peut contenir jusqu’à 256 KiB, 20 niveaux imbriqués, 2 000 nœuds, 1 000 éléments par tableau et 64 KiB par chaîne.

Locale et direction de l’UI

Lisez routeCtx.ui lorsqu’une page ou un widget doit renvoyer du texte pour la locale active de l’administrateur. L’hôte dérive cette valeur du cookie de locale d’administration ou de la langue de la requête et vérifie la page ou le widget demandé par rapport au manifeste du 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 contient la surface, la locale et la direction du texte. La locale d’administration est distincte de ctx.site.locale, qui décrit la locale de contenu par défaut du site. Les libellés du manifeste restent des chaînes statiques.

Liens de navigation

Utilisez un élément link pour naviguer sans envoyer une action Block Kit. EmDash construit les URL internes à partir de cibles structurées, de sorte que les plugins n’ont pas besoin de connaître les chemins de routes d’administration.

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

Les cibles disponibles sont :

  • content, avec une collection, un ID d’entrée enregistrée et une locale de contenu optionnelle ;
  • plugin-page, avec un chemin déclaré par le même plugin ;
  • plugin-settings ; et
  • external, avec une URL absolue HTTP, HTTPS ou mailto:.

Les liens externes s’ouvrent dans un nouvel onglet avec noopener noreferrer. Les éléments link n’acceptent pas action_id et ne peuvent pas apparaître comme champs de formulaire. Utilisez un bouton lorsque l’interaction doit appeler la route du plugin.

Les images de bloc utilisent la même politique de ressources du navigateur. Les URL d’image relatives à la racine sont autorisées. Une image externe doit utiliser HTTPS et son nom d’hôte doit figurer dans les allowedHosts du plugin. Un plugin avec network:request:unrestricted peut charger une image HTTPS depuis n’importe quel nom d’hôte. Les autres images externes entraînent le rejet de la réponse Block Kit complète.

Actions de ligne dans les tableaux

Définissez le format d’une colonne de tableau sur element pour placer un bouton, un lien ou un menu dans chaque ligne. Chaque ligne stocke l’élément sous la clé de la colonne ; une ligne sans valeur laisse la cellule vide. Utilisez un élément menu lorsqu’une ligne propose plusieurs choix derrière un bouton :

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

Choisir un élément de menu envoie un block_action avec l’action_id du menu et le value de l’élément. Les valeurs des éléments doivent être uniques dans un menu. Les cellules d’élément n’acceptent que les éléments button, link et menu. Un menu peut aussi apparaître dans un bloc actions, comme accessoire de section ou dans les actions d’état vide, mais pas comme champ de formulaire. Le builder elements.menu(actionId, label, items, { style }) renvoie la même forme.

Panneaux et actions d’entrées enregistrées

Déclarez un panneau d’éditeur lorsqu’un plugin doit afficher des informations à côté d’une entrée enregistrée. Les panneaux démarrent repliés et n’appellent leur route privée que lorsqu’un éditeur les ouvre.

Le manifeste suivant ajoute un panneau pour les articles et une action de réparation confirmée :

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

Chaque route référencée doit être privée. Sa permission contrôle quels éditeurs peuvent invoquer l’extension. L’hôte recharge aussi l’entrée enregistrée et vérifie son propriétaire avant d’appeler le plugin.

Les routes d’extension d’éditeur reçoivent une valeur routeCtx.ui attestée. Pour les surfaces content-editor-panel et content-editor-action, routeCtx.ui.entry contient la collection, l’ID d’entrée enregistrée, la locale de contenu et la version. routeCtx.ui.extensionId identifie la déclaration sélectionnée. Utilisez ctx.content avec la capacité content:read lorsque le plugin a besoin du contenu enregistré.

Un panneau reçoit { type: "panel_load" } à son ouverture. Le chargement du panneau n’inclut jamais de données de brouillon. Ses interactions ultérieures de bouton et de formulaire utilisent les formes habituelles block_action et form_submit. Lorsque le plugin déclare admin.editor-draft:read et que l’extension restreint draft.read, une interaction explicite reçoit aussi routeCtx.input.draft. L’instantané ne contient que les valeurs actuelles sélectionnées, des définitions de champs assainies, l’identité enregistrée et la révision de base persistée. Utilisez fields pour des slugs explicites, translatable: true pour les champs traduisibles de la collection, ou les deux. L’accès au brouillon exige une liste collections explicite.

admin.editor-draft:patch est indépendant de l’accès en lecture. Il permet à une route de renvoyer un correctif de champ entier après une interaction explicite :

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 valide chaque opération ensemble par rapport au schéma serveur actuel, à la capacité, à la collection, au sélecteur de champ, à la locale, à la révision de base, à la propriété, aux limites de nombre et aux limites d’octets. Le navigateur répète les contrôles d’identité, de génération et de champ avant d’afficher un aperçu rendu par l’hôte. Appliquer l’aperçu marque le formulaire comme modifié et n’enregistre pas, ne crée pas de révision et n’exécute pas de hooks. Toute modification faite pendant que le plugin travaille rejette le résultat complet.

Les actions d’éditeur uniquement pour le contenu enregistré restent désactivées tant que le formulaire a des modifications non enregistrées. Les actions conscientes du brouillon peuvent s’exécuter contre le formulaire non enregistré. Une action reçoit { type: "editor_action" } et, lorsqu’elle est déclarée, le même instantané de brouillon borné. Renvoyez un objet contenant un toast optionnel et au plus un effet terminal :

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

Utilisez refresh: true pour recharger l’entrée, navigate avec une cible de lien structurée, ou patch pour proposer des modifications de champs non enregistrées. Une réponse ne peut pas combiner des effets terminaux. EmDash rejette les commandes inconnues, la navigation dangereuse, les correctifs invalides ou obsolètes et les réponses au-delà des limites Block Kit avant d’appliquer un effet.

Types de blocs

TypeDescription
headerGrand titre en gras
sectionTexte avec élément accessoire optionnel
dividerRègle horizontale
fieldsGrille étiquette/valeur à deux colonnes
tableTableau de données avec formatage, tri, pagination
actionsRangée horizontale de boutons et contrôles
statsCartes de métriques du tableau de bord avec indicateurs de tendance
formChamps de saisie avec visibilité conditionnelle et soumission
imageImage au niveau du bloc avec texte alternatif et titre optionnel
contextPetit texte d’aide atténué
columnsMise en page 2–3 colonnes avec blocs imbriqués
emptyTitre d’état vide avec description, commande et boutons d’action optionnels
accordionSection repliable enveloppant des blocs imbriqués
chartSérie temporelle en lignes ou barres, ou graphique avec options personnalisées
bannerMessage d’état ou d’alerte avec titre ou description
meterValeur numérique affichée par rapport à un minimum et un maximum
codeCode TypeScript, TSX, JSONC, Bash ou CSS en lecture seule
tabPanneaux étiquetés contenant des blocs imbriqués

Types d’éléments

TypeDescription
buttonBouton d’action avec dialogue de confirmation optionnel
linkNavigation interne ou externe résolue par l’hôte
menuBouton ouvrant une liste de choix ; chaque choix déclenche une action
text_inputSaisie de texte une ou plusieurs lignes
number_inputSaisie numérique avec min/max
selectSélection déroulante
toggleInterrupteur marche/arrêt
secret_inputSaisie masquée pour clés API et jetons
checkboxSélectionner plusieurs valeurs d’une liste fixe
comboboxSélection d’une seule valeur avec recherche
date_inputValeur de date
radioChoix unique dans une liste d’options visible

L’éditeur de champs Portable Text prend aussi en charge repeater et media_picker. Ce ne sont pas des champs de formulaire pour une page d’administration de plugin sandboxé.

Helpers de builder

Le package @emdash-cms/blocks exporte les mêmes formes via les objets builder blocks et elements. Les builders réduisent les erreurs de noms de propriétés tout en renvoyant des objets ordinaires compatibles 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" })]),
	],
};

Champs conditionnels

Les champs de formulaire peuvent être affichés conditionnellement selon d’autres valeurs de champ :

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

Le champ api_key n’apparaît que lorsque auth_enabled est activé. Les conditions sont évaluées côté client sans aller-retour.

secret_input utilise has_value: true pour indiquer qu’une valeur existe déjà ; il n’accepte ni ne renvoie la valeur stockée au chargement de la page. Le champ masque la saisie dans le navigateur. Déclarez la clé correspondante comme type: "secret" dans admin.settingsSchema et enregistrez-la via ctx.settings pour qu’EmDash la chiffre. Suivez Secret settings avant de stocker des identifiants.

Essayer

Utilisez le Block Playground pour construire et tester des mises en page de blocs de façon interactive.