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
- L’utilisateur navigue vers la page d’administration d’un plugin.
- L’admin envoie une interaction
page_loadà la route d’administration du plugin. - Le plugin renvoie une
BlockResponsecontenant un tableau de blocs. - L’admin rend les blocs avec le composant
BlockRenderer. - Lorsque l’utilisateur interagit (clique sur un bouton, soumet un formulaire), l’admin renvoie l’interaction au plugin.
- 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; etexternal, avec une URL absolue HTTP, HTTPS oumailto:.
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
| Type | Description |
|---|---|
header | Grand titre en gras |
section | Texte avec élément accessoire optionnel |
divider | Règle horizontale |
fields | Grille étiquette/valeur à deux colonnes |
table | Tableau de données avec formatage, tri, pagination |
actions | Rangée horizontale de boutons et contrôles |
stats | Cartes de métriques du tableau de bord avec indicateurs de tendance |
form | Champs de saisie avec visibilité conditionnelle et soumission |
image | Image au niveau du bloc avec texte alternatif et titre optionnel |
context | Petit texte d’aide atténué |
columns | Mise en page 2–3 colonnes avec blocs imbriqués |
empty | Titre d’état vide avec description, commande et boutons d’action optionnels |
accordion | Section repliable enveloppant des blocs imbriqués |
chart | Série temporelle en lignes ou barres, ou graphique avec options personnalisées |
banner | Message d’état ou d’alerte avec titre ou description |
meter | Valeur numérique affichée par rapport à un minimum et un maximum |
code | Code TypeScript, TSX, JSONC, Bash ou CSS en lecture seule |
tab | Panneaux étiquetés contenant des blocs imbriqués |
Types d’éléments
| Type | Description |
|---|---|
button | Bouton d’action avec dialogue de confirmation optionnel |
link | Navigation interne ou externe résolue par l’hôte |
menu | Bouton ouvrant une liste de choix ; chaque choix déclenche une action |
text_input | Saisie de texte une ou plusieurs lignes |
number_input | Saisie numérique avec min/max |
select | Sélection déroulante |
toggle | Interrupteur marche/arrêt |
secret_input | Saisie masquée pour clés API et jetons |
checkbox | Sélectionner plusieurs valeurs d’une liste fixe |
combobox | Sélection d’une seule valeur avec recherche |
date_input | Valeur de date |
radio | Choix 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.