creating-plugins

Par emdash-cms · emdash

Créez des plugins EmDash CMS avec des hooks, du stockage, des paramètres, une interface d'administration, des routes API et des types de blocs Portable Text. Utilisez cette skill lorsqu'on vous demande de créer, scaffolder ou implémenter un plugin EmDash, ou lors de la création de fonctionnalités de plugin telles que des types de blocs personnalisés, des pages d'administration ou des hooks de contenu.

npx skills add https://github.com/emdash-cms/emdash --skill creating-plugins

Créer des plugins EmDash

Les plugins EmDash étendent le CMS avec des hooks, du stockage, des paramètres, une UI admin, des routes API et des types de blocs Portable Text personnalisés. Tous les plugins sont des packages TypeScript.

Types de plugins

EmDash a deux formats de plugin :

Type Format UI Admin Où il s'exécute
Standard definePlugin({ hooks, routes }) Block Kit Isolate sur Cloudflare, in-process ailleurs
Native createPlugin() / definePlugin() avec id+version React ou Block Kit Toujours dans l'isolate de l'hôte

Standard est le format par défaut. La plupart des plugins devraient l'utiliser. Les plugins standard peuvent être publiés sur la marketplace et fonctionnent en mode de confiance et en mode sandboxé.

Native est une échappatoire pour les plugins qui ont besoin de composants admin React, d'accès direct à la DB ou de composants Astro personnalisés. Les plugins native ne peuvent s'exécuter que dans plugins: [] -- ils ne peuvent pas être sandboxés ni publiés sur la marketplace.

Anatomie d'un plugin

Chaque plugin a deux parties qui s'exécutent dans des contextes différents :

  1. Descripteur de plugin (PluginDescriptor) — retourné par la fonction factory dans index.ts. Déclare les métadonnées (id, version, capacités, stockage). S'exécute au moment de la compilation dans Vite (importé dans astro.config.mjs). Doit être sans effets secondaires.
  2. Définition de plugin (definePlugin()) — contient la logique d'exécution (hooks, routes). S'exécute au moment de la requête sur le serveur déployé. A accès au contexte complet du plugin (ctx). Se trouve dans un fichier séparé (généralement sandbox-entry.ts).

Ils doivent être dans des points d'entrée séparés car ils s'exécutent dans des environnements complètement différents :

my-plugin/
├── src/
│   ├── index.ts            # Factory du descripteur (s'exécute dans Vite au moment de la compilation)
│   ├── sandbox-entry.ts    # Définition de plugin avec definePlugin() (s'exécute au moment du déploiement)
│   ├── admin.tsx            # Exports de l'UI admin (React) — optionnel, native uniquement
│   └── astro/               # Composants de rendu côté site — optionnel, native uniquement
│       └── index.ts         # Doit exporter `blockComponents`
├── package.json
└── tsconfig.json

Plugin minimal (format standard)

Le plugin le plus simple possible -- juste des hooks :

// src/index.ts — factory du descripteur, s'exécute dans Vite au moment de la compilation
import type { PluginDescriptor } from "emdash";

export function myPlugin(): PluginDescriptor {
    return {
        id: "my-plugin",
        version: "1.0.0",
        format: "standard",
        entrypoint: "@my-org/my-plugin/sandbox",
        options: {},
    };
}
// src/sandbox-entry.ts — définition de plugin, s'exécute au moment de la requête
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";

export default definePlugin({
    hooks: {
        "content:afterSave": {
            handler: async (event: any, ctx: PluginContext) => {
                ctx.log.info(`Saved ${event.collection}/${event.content.id}`);
            },
        },
    },
});

Le descripteur est ce qui est importé dans astro.config.mjs. Le champ entrypoint pointe vers le module contenant le default export definePlugin(). Pour les plugins standard, c'est l'export ./sandbox depuis package.json.

Différences clés par rapport au format native :

  • Pas de id, version ou capabilities dans definePlugin() -- ceux-ci se trouvent dans le descripteur
  • definePlugin() est une fonction identité qui fournit l'inférence de type
  • Les handlers de hook utilisent le pattern à deux arguments (event, ctx)
  • Les handlers de route utilisent le pattern à deux arguments (routeCtx, ctx)
  • Exporté comme default (pas une fonction factory)

Règles de l'ID de plugin

  • Alphanumériques minuscules + tirets uniquement
  • Simple (my-plugin) ou scopé (@my-org/my-plugin)
  • Unique parmi tous les plugins installés

Enregistrement

Le descripteur est importé dans astro.config.mjs (contexte Vite) :

import { myPlugin } from "@my-org/my-plugin";

export default defineConfig({
    integrations: [
        emdash({
            plugins: [myPlugin()], // s'exécute in-process
            // OU
            sandboxed: [myPlugin()], // s'exécute en isolate sur Cloudflare
        }),
    ],
});

Les plugins standard fonctionnent dans l'un ou l'autre tableau. Les plugins native ne fonctionnent que dans plugins: [].

Plugins de confiance vs plugins sandboxés

EmDash a deux modes d'exécution. Le code du plugin est identique dans les deux -- seule l'application change.

De confiance Sandboxé
S'exécute dans Processus principal Isolate V8 isolé (Dynamic Worker Loader)
Méthode d'installation astro.config.mjs (changement de code + déploiement) UI admin (installation en un clic depuis la marketplace)
Capacités Consultatif (non appliqué) Appliqué à l'exécution via RPC bridge
Limites de ressources Aucune CPU 50ms, 10 subrequêtes, 30s temps écoulé, ~128MB mémoire
Accès réseau Sans restriction Bloqué ; uniquement via ctx.http avec allowedHosts
Accès aux données Accès complet à la base de données Limité aux capacités déclarées
APIs Node.js Accès complet Non disponible (isolate V8 uniquement)
Disponible sur Toutes les plateformes Cloudflare Workers uniquement
Idéal pour Code propriétaire, packages npm vérifiés Extensions tierces, plugins de la marketplace

Mode de confiance

Les plugins de confiance sont des packages npm ou des fichiers locaux ajoutés dans astro.config.mjs. Ils s'exécutent in-process avec votre site Astro.

  • Les capacités ne sont que de la documentation. Déclarer ["content:read"] documente l'intention mais ne s'applique pas -- le plugin a accès complet au processus.
  • Installer uniquement depuis des sources de confiance. Un plugin malveillant de confiance a le même accès que le code de votre application.

Mode sandboxé

Les plugins sandboxés s'exécutent dans des isolates V8 isolées sur Cloudflare Workers via Dynamic Worker Loader. Chaque plugin obtient son propre isolate.

  • Les capacités s'appliquent. Si un plugin déclare ["content:read"], il ne peut appeler que ctx.content.get() et ctx.content.list(). Tenter ctx.content.create() lève une erreur de permission.
  • Le réseau est bloqué par défaut. Les appels directs fetch() échouent. Les plugins doivent utiliser ctx.http.fetch(), qui valide contre allowedHosts.
  • Le stockage est limité. Un plugin ne peut accéder qu'à son propre KV et ses propres collections de stockage.
  • L'UI admin utilise Block Kit. Les plugins sandboxés décrivent leur UI comme des blocs JSON -- aucun JavaScript du plugin ne s'exécute dans le navigateur. Voir la référence Block Kit.
  • Pas de types de blocs Portable Text. Les blocs PT nécessitent des composants Astro pour le rendu côté site (componentsEntry), qui sont chargés au moment de la compilation depuis npm. Les plugins sandboxés sont installés à l'exécution et ne peuvent pas livrer de composants. Les blocs PT sont une fonctionnalité réservée aux plugins native.
  • Les routes fonctionnent. Les routes de plugin standard sont disponibles en mode de confiance et sandboxé via l'RPC invokeRoute() du sandbox runner.

La sandbox n'est pas disponible sur Node.js. Tous les plugins s'exécutent en mode de confiance sur les plates-formes non-Cloudflare.

Développer pour les deux modes

Écrire le même code. Développer localement en mode de confiance (itération plus rapide, débogage plus facile). Déployer en mode sandboxé en production sans changement de code. Avec le format standard, le même point d'entrée sert les deux modes -- aucune sandbox entry séparée n'est nécessaire.

// src/sandbox-entry.ts -- fonctionne en mode de confiance et sandboxé
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";

export default definePlugin({
    hooks: {
        "content:afterSave": {
            handler: async (event: any, ctx: PluginContext) => {
                // De confiance : ctx.http présent car le descripteur déclare network:request
                // Sandboxé : ctx.http présent et appliqué via RPC bridge
                if (!ctx.http) return;
                await ctx.http.fetch("https://api.analytics.example.com/track", {
                    method: "POST",
                    body: JSON.stringify({ contentId: event.content.id }),
                });
            },
        },
    },
});

Contrainte clé pour la compatibilité sandbox : pas de built-ins Node.js (fs, path, child_process, etc.) dans le code backend. Utiliser les APIs Web à la place.

Capacités

Les capacités contrôlent quelles APIs sont disponibles sur ctx. Toujours déclarer ce que votre plugin nécessite -- même en mode de confiance, elles documentent l'intention et sont requises pour l'exécution sandboxée.

Capacité Accorde Propriété ctx
content:read ctx.content.get(), ctx.content.list() content
content:write ctx.content.create(), ctx.content.update(), ctx.content.delete() content
media:read ctx.media.get(), ctx.media.list() media
media:write ctx.media.getUploadUrl(), ctx.media.delete() media
network:request ctx.http.fetch() (limité à allowedHosts) http
network:request:unrestricted ctx.http.fetch() (sans restriction -- pour les URLs configurées par l'utilisateur) http
users:read ctx.users.get(), ctx.users.list(), ctx.users.getByEmail() users
email:send ctx.email.send() -- envoyer un email via le pipeline email
hooks.email-transport:register Peut enregistrer le hook exclusif email:deliver (fournisseur de transport)
hooks.email-events:register Peut enregistrer les hooks email:beforeSend / email:afterSend
hooks.page-fragments:register Peut enregistrer le hook page:fragments (injecter des scripts/styles dans les pages)

Le stockage (ctx.storage) et KV (ctx.kv) sont toujours disponibles -- aucune capacité nécessaire. Ils sont automatiquement limités au plugin.

Les capacités email sont distinctes :

  • email:send -- pour les plugins qui consomment email (appeler ctx.email.send())
  • hooks.email-transport:register -- pour les plugins qui livrent email (implémenter le transport, ex. Resend, SMTP)
  • hooks.email-events:register -- pour les plugins qui observent ou transforment email (hooks middleware)
// Dans le descripteur (index.ts)
export function myPlugin(): PluginDescriptor {
    return {
        id: "my-plugin",
        version: "1.0.0",
        format: "standard",
        entrypoint: "@my-org/my-plugin/sandbox",
        options: {},
        capabilities: ["content:read", "network:request"],
        allowedHosts: ["api.example.com", "*.googleapis.com"], // Les wildcards sont supportées
    };
}

Quand un plugin marketplace est installé, l'admin voit un dialogue de consentement aux capacités listant ce que le plugin peut accéder. Les utilisateurs doivent approuver avant l'installation.

Publier sur la marketplace

Les plugins standard peuvent être publiés sur la EmDash Marketplace pour une installation en un clic :

emdash plugin bundle --dir packages/plugins/my-plugin  # crée .tar.gz
emdash plugin login                                      # authentifier via GitHub
emdash plugin publish --tarball dist/my-plugin-1.0.0.tar.gz

Voir la Référence de publication pour le format de bundle, la validation et les détails de l'audit de sécurité.

Exports de package

Configurer les exports package.json pour qu'EmDash puisse charger chaque point d'entrée :

{
    "name": "@my-org/my-plugin",
    "type": "module",
    "exports": {
        ".": "./src/index.ts",
        "./sandbox": "./src/sandbox-entry.ts",
        "./admin": "./src/admin.tsx"
    },
    "peerDependencies": {
        "emdash": "^0.1.0"
    }
}
Export Contexte Objectif
"." Vite (compilation) Factory du descripteur -- importé dans astro.config.mjs
"./sandbox" Serveur (exécution) definePlugin({ hooks, routes }) -- chargé par entrypoint à l'exécution
"./admin" Navigateur Composants React pour les pages/widgets admin (plugins native uniquement)
"./astro" Serveur (SSR) Composants Astro pour le rendu des blocs côté site (plugins native uniquement)

L'export "." contient le descripteur. L'export "./sandbox" contient l'implémentation. Le champ entrypoint du descripteur pointe vers "./sandbox". N'inclure ./admin et ./astro exports que pour les plugins format native.

Fonctionnalités du plugin

Chaque fonctionnalité est optionnelle. Ajouter uniquement ce que votre plugin nécessite :

Fonctionnalité Standard Native Objectif
Hooks definePlugin({ hooks }) Oui Oui Réagir aux événements de contenu/média/cycle de vie
Stockage descripteur storage Oui Oui Collections de documents avec requêtes indexées
KV ctx.kv dans hooks/routes Oui Oui Magasin clé-valeur pour l'état interne
Routes API definePlugin({ routes }) Oui Oui Endpoints REST à /_emdash/api/plugins/<id>/<route>
Pages Admin Route admin Block Kit Oui Oui Pages admin via Block Kit (blocs JSON)
Widgets Route admin Block Kit Oui Oui Cartes du tableau de bord via Block Kit
Admin React admin.entry + export React Non Oui Pages et widgets admin basés sur React (native uniquement)
Blocs PT admin.portableTextBlocks Non Oui Types de blocs personnalisés dans l'éditeur Portable Text
Composants site componentsEntry Non Oui Composants Astro pour rendre les blocs sur le site

Voir les fichiers de référence pour la syntaxe détaillée :

  • Référence Hooks -- Tous les types de hooks, signatures, configuration
  • Stockage & Paramètres -- Collections, KV, schéma de paramètres
  • UI Admin -- Pages, widgets, structure du point d'entrée
  • Routes API -- Handlers de route, validation, contexte
  • Block Kit -- UI déclarative pour plugins sandboxés (similaire à Slack Block Kit mais pas identique)
  • Blocs Portable Text -- Types de blocs personnalisés + rendu frontend
  • Publication -- Format de bundle, validation, publication sur la marketplace

Exemple complet : Plugin standard avec hooks, routes et stockage

// src/index.ts — factory du descripteur, s'exécute dans Vite au moment de la compilation
import type { PluginDescriptor } from "emdash";

export function submissionsPlugin(): PluginDescriptor {
    return {
        id: "submissions",
        version: "1.0.0",
        format: "standard",
        entrypoint: "@my-org/plugin-submissions/sandbox",
        options: {},
        capabilities: ["content:read"],
        storage: {
            submissions: {
                indexes: ["formId", "status", "createdAt"],
            },
        },
        adminPages: [{ path: "/submissions", label: "Submissions", icon: "list" }],
        adminWidgets: [{ id: "recent-submissions", title: "Recent Submissions", size: "half" }],
    };
}
// src/sandbox-entry.ts — définition de plugin, s'exécute au moment de la requête
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";

export default definePlugin({
    hooks: {
        "plugin:install": {
            handler: async (_event: any, ctx: PluginContext) => {
                ctx.log.info("Submissions plugin installed");
                await ctx.kv.set("settings:maxSubmissions", 1000);
            },
        },
    },

    routes: {
        submit: {
            public: true, // Pas d'authentification requise
            handler: async (routeCtx: any, ctx: PluginContext) => {
                const { formId, ...data } = routeCtx.input as Record<string, unknown>;

                const count = await ctx.storage.submissions.count({ formId });
                const max = (await ctx.kv.get<number>("settings:maxSubmissions")) ?? 1000;

                if (count >= max) {
                    return { success: false, error: "Submission limit reached" };
                }

                const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
                await ctx.storage.submissions.put(id, {
                    formId,
                    data,
                    status: "pending",
                    createdAt: new Date().toISOString(),
                });

                return { success: true, id };
            },
        },

        list: {
            handler: async (routeCtx: any, ctx: PluginContext) => {
                const url = new URL(routeCtx.request.url);
                const limit = Math.max(
                    1,
                    Math.min(parseInt(url.searchParams.get("limit") || "50", 10) || 50, 100),
                );
                const cursor = url.searchParams.get("cursor") || undefined;

                const result = await ctx.storage.submissions.query({
                    orderBy: { createdAt: "desc" },
                    limit,
                    cursor,
                });

                return {
                    items: result.items.map((item: any) => ({ id: item.id, ...item.data })),
                    cursor: result.cursor,
                    hasMore: result.hasMore,
                };
            },
        },

        // Handler admin Block Kit pour pages et widgets
        admin: {
            handler: async (routeCtx: any, ctx: PluginContext) => {
                const interaction = routeCtx.input as { type: string; page?: string };

                if (interaction.type === "page_load" && interaction.page === "/submissions") {
                    const result = await ctx.storage.submissions.query({
                        orderBy: { createdAt: "desc" },
                        limit: 50,
                    });
                    return {
                        blocks: [
                            { type: "header", text: "Submissions" },
                            {
                                type: "table",
                                blockId: "submissions-table",
                                columns: [
                                    { key: "formId", label: "Form", format: "text" },
                                    { key: "status", label: "Status", format: "badge" },
                                    { key: "createdAt", label: "Date", format: "relative_time" },
                                ],
                                rows: result.items.map((item: any) => item.data),
                            },
                        ],
                    };
                }

                return { blocks: [] };
            },
        },
    },
});

Contexte du plugin

Tous les hooks et routes reçoivent ctx (PluginContext) :

interface PluginContext {
    plugin: { id: string; version: string };
    storage: Record<string, StorageCollection>; // Collections déclarées
    kv: KVAccess; // Magasin clé-valeur
    log: LogAccess; // Logger structuré
    content?: ContentAccess; // Si capacité "content:read"
    media?: MediaAccess; // Si capacité "media:read"
    http?: HttpAccess; // Si capacité "network:request"
    users?: UserAccess; // Si capacité "users:read"
    cron?: CronAccess; // Toujours disponible -- limité au plugin
    email?: EmailAccess; // Si capacité "email:send" ET un fournisseur configuré
}

Les capacités sont déclarées dans le descripteur (pas dans definePlugin() pour le format standard) :

// Dans le descripteur
export function myPlugin(): PluginDescriptor {
    return {
        id: "my-plugin",
        version: "1.0.0",
        format: "standard",
        entrypoint: "@my-org/my-plugin/sandbox",
        options: {},
        capabilities: ["content:read", "network:request"],
        allowedHosts: ["api.example.com"],
        storage: { events: { indexes: ["timestamp"] } },
    };
}

Checklist de sortie

Quand créer un plugin format standard, fournir :

  1. src/index.ts -- Factory du descripteur (s'exécute dans Vite au moment de la compilation)
  2. src/sandbox-entry.ts -- definePlugin({ hooks, routes }) comme default export (s'exécute au moment de la requête)
  3. package.json -- Avec exports "." (descripteur) et "./sandbox" (implémentation)
  4. tsconfig.json -- Config TypeScript standard

Pour les plugins format native (admin React, blocs PT, composants Astro), fournir aussi :

  1. src/admin.tsx -- Point d'entrée admin avec composants React
  2. src/astro/index.ts -- Export de composants de bloc (si blocs PT)

Skills similaires