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 un plugin n’est jamais exécuté dans le navigateur.
Fonctionnement
- L’utilisateur navigue vers la page d’administration d’un plugin.
- L’administration envoie une interaction
page_loadà la route admin du plugin. - Le plugin renvoie un
BlockResponsecontenant un tableau de blocs. - L’administration rend les blocs avec le composant
BlockRenderer. - Lorsque l’utilisateur interagit (clique sur un bouton, soumet un formulaire), l’administration renvoie l’interaction au plugin.
- Le plugin renvoie de nouveaux blocs, et le cycle recommence.
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 afin que l’administration dispose d’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 l’en-tête CSRF correct lorsque l’administration l’appelle. Le handler valide tout de même routeCtx.input, car son type TypeScript est unknown et un appelant peut invoquer une route de plugin privée en dehors de la page Block Kit.
EmDash valide chaque réponse de page et de widget sandboxés avant que l’administration ne la rende. Un bloc invalide, une URL dangereuse, un lien vers une page de plugin non déclarée ou une réponse dépassant les limites du Block Kit fait échouer la requête au lieu d’atteindre le navigateur. Une réponse peut contenir jusqu’à 256 KiB, 20 niveaux d’imbrication, 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 de l’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 de l’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 d’action Block Kit. EmDash construit les URL internes à partir de cibles structurées ; les plugins n’ont donc pas besoin de connaître les chemins des 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, l’ID d’une entrée enregistrée et une locale de contenu facultative ;plugin-page, avec un chemin déclaré par le même plugin ;plugin-settings; etexternal, avec une URL HTTP, HTTPS oumailto:absolue.
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 toute la réponse Block Kit.
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 seul 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 du menu envoie un block_action avec l’action_id du menu et la value de l’élément. Les valeurs des éléments doivent être uniques au sein d’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 d’une 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 avec confirmation :
"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 détermine 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 de l’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 d’un 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 courantes sélectionnées, les 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 nécessite une liste collections explicite.
admin.editor-draft:patch est indépendant de l’accès en lecture. Il permet à une route de renvoyer un patch de champs entiers 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 toutes les opérations ensemble par rapport au schéma serveur actuel, à la capacité, à la collection, au sélecteur de champs, à 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 vérifications d’identité, de génération et de champs avant d’afficher un aperçu rendu par l’hôte. L’application de l’aperçu marque le formulaire comme modifié et n’enregistre rien, ne crée pas de révision et n’exécute aucun hook. Toute modification effectuée pendant que le plugin travaille fait rejeter le résultat entier.
Les actions d’éditeur réservées aux entrées enregistrées restent désactivées tant que le formulaire contient des modifications non enregistrées. Les actions compatibles avec les brouillons peuvent s’exécuter sur le formulaire non enregistré. Une action reçoit { type: "editor_action" } et, lorsqu’il est déclaré, le même instantané de brouillon borné. Renvoyez un objet contenant un toast facultatif 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 patchs invalides ou périmés et les réponses dépassant les limites du Block Kit avant d’appliquer un effet.
Block Kit dans les plugins natifs
Un plugin natif peut rendre des pages et des widgets Block Kit sans fournir de React. Déclarez admin.pages ou admin.widgets dans definePlugin(), laissez admin.entry non défini et ajoutez une route nommée 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 natif utilise le Block Kit lorsqu’il déclare des pages, des widgets ou des extensions d’éditeur et aucun admin.entry. Définir admin.entry le fait basculer vers des pages d’administration React. Déclarez explicitement la route admin : la route admin implicite n’existe que pour les anciens bundles sandboxés. Un handler natif reçoit un seul RouteContext ; l’interaction arrive donc sous la forme de ctx.input.
Deux comportements décrits plus haut ne s’appliquent actuellement qu’aux plugins sandboxés sur les pages d’administration et les widgets de tableau de bord :
ctx.uivautundefinedsur la page ou le widget d’un plugin natif ; la route ne peut donc pas y lire la locale de l’administration ni la direction du texte. Les panneaux et actions d’éditeur natifs reçoivent bienctx.ui.- EmDash ne valide pas la réponse de la page ou du widget d’un plugin natif avant que l’administration ne la rende. Gardez les réponses natives dans les mêmes types de blocs, limites et règles de liens et d’images, car c’est le même moteur de rendu qui les dessine.
Types de blocs
| Type | Description |
|---|---|
header | Grand titre en gras |
section | Texte avec un élément accessoire facultatif |
divider | Règle horizontale |
fields | Grille à deux colonnes libellé/valeur |
table | Tableau de données avec mise en forme, tri et pagination |
actions | Rangée horizontale de boutons et de contrôles |
stats | Cartes de métriques de 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 facultatif |
context | Petit texte d’aide atténué |
columns | Mise en page de 2 à 3 colonnes avec blocs imbriqués |
empty | Titre d’état vide avec description, commande et boutons d’action facultatifs |
accordion | Section repliable enveloppant des blocs imbriqués |
chart | Série temporelle en courbes ou en barres, ou graphique avec options personnalisées |
banner | Message d’état ou d’alerte avec un titre ou une 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 libellés contenant des blocs imbriqués |
Types d’éléments
| Type | Description |
|---|---|
button | Bouton d’action avec boîte de dialogue de confirmation facultative |
link | Navigation interne ou externe résolue par l’hôte |
menu | Bouton qui ouvre une liste de choix ; chaque choix envoie une action |
text_input | Saisie de texte sur une ou plusieurs lignes |
number_input | Saisie numérique avec minimum/maximum |
select | Sélection déroulante |
toggle | Interrupteur activé/désactivé |
secret_input | Saisie masquée pour les clés d’API et les jetons |
checkbox | Sélection de plusieurs valeurs dans 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 la page d’administration d’un plugin sandboxé.
Charger les options d’un select depuis une route du plugin
Un select dans les fields d’un bloc Portable Text peut définir optionsRoute pour remplir sa liste déroulante à partir de l’une des routes du plugin lui-même. Il en va de même pour un select imbriqué dans un repeater au sein de ces champs.
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",
},
],
},
],
},
});
Chaque select avec optionsRoute appelle la route lors de son rendu ; un bloc comportant deux de ces champs, ou un repeater avec plusieurs éléments, envoie donc une requête pour chacun. Réduire puis rouvrir un élément de repeater renvoie la requête. La requête est POST /_emdash/api/plugins/<pluginId>/<optionsRoute>. Elle porte l’en-tête X-EmDash-Request: 1 et un objet JSON vide comme corps. La route est une route de plugin ordinaire ; elle exige donc la permission plugins:manage, sauf si elle déclare une autre permission.
Le gestionnaire renvoie { items: Array<{ id: string; name: string }> }. La route répond avec l’enveloppe standard d’EmDash ({ success: true, data: { items: [...] } }, voir Routes d’API), et l’éditeur de blocs lit les options dans data.items. L’administration affiche chaque élément comme une option, avec id comme valeur stockée et name comme libellé. Les propriétés supplémentaires d’un élément sont ignorées.
Pendant l’exécution de la requête, le champ affiche un état de chargement. Si la requête échoue, si la réponse n’est pas OK ou si la réponse ne contient pas de tableau items, le champ se rabat sur le tableau statique options. Si ce tableau statique options est vide, la liste déroulante n’a alors aucun choix.
optionsRoute n’a d’effet que dans l’éditeur de blocs Portable Text. Le moteur de rendu Block Kit des pages d’administration, des widgets et des panneaux d’entrées enregistrées, ainsi que le moteur de rendu des widgets de champ déclaratifs, ne lisent que le tableau statique options et ignorent optionsRoute. Un select à ces endroits doit lister ses options de façon statique, et une réponse Block Kit doit lui en fournir au moins une.
Helpers de builder
Le paquet @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 de façon conditionnelle selon les valeurs d’autres champs :
{
"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 avec 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 créer et tester des mises en page de blocs de façon interactive.