Ce guide vous accompagne dans la création d’un plugin EmDash complet. Vous apprendrez à structurer le code, définir les hooks et le stockage, et exporter les composants d’interface d’administration.
Structure d’un plugin
Chaque plugin comporte deux parties qui s’exécutent dans des contextes différents :
- Descripteur de plugin (
PluginDescriptor) — renvoyé par la fonction fabrique, indique à EmDash comment charger le plugin. S’exécute au moment du build dans Vite (importé dansastro.config.mjs). Doit être sans effets de bord et ne peut pas utiliser les API runtime. - Définition de plugin (
definePlugin()) — contient la logique runtime (hooks, routes, stockage). S’exécute au moment de la requête sur le serveur déployé. A accès au contexte complet du plugin (ctx).
Ces deux parties doivent être dans des points d’entrée séparés car elles s’exécutent dans des environnements complètement différents :
my-plugin/
├── src/
│ ├── descriptor.ts # Descripteur de plugin (s'exécute dans Vite au build)
│ ├── index.ts # Définition du plugin avec definePlugin() (s'exécute au déploiement)
│ ├── admin.tsx # Exports UI admin (composants React) — optionnel
│ └── astro/ # Optionnel : composants Astro pour le rendu côté site
│ └── index.ts # Doit exporter `blockComponents`
├── package.json
└── tsconfig.json
Créer le plugin
Descripteur (build time)
Le descripteur indique à EmDash où trouver le plugin et quelle interface d’administration il fournit. Ce fichier est importé dans astro.config.mjs et s’exécute dans Vite.
import type { PluginDescriptor } from "emdash";
export interface MyPluginOptions {
enabled?: boolean;
maxItems?: number;
}
export function myPlugin(options: MyPluginOptions = {}): PluginDescriptor {
return {
id: "my-plugin",
version: "1.0.0",
entrypoint: "@my-org/plugin-example",
options,
adminEntry: "@my-org/plugin-example/admin",
componentsEntry: "@my-org/plugin-example/astro",
adminPages: [{ path: "/settings", label: "Settings", icon: "settings" }],
adminWidgets: [{ id: "status", title: "Status", size: "half" }],
};
}
Définition (runtime)
La définition contient la logique runtime — hooks, routes, stockage et configuration admin. Ce fichier est chargé au moment de la requête sur le serveur déployé.
import { definePlugin } from "emdash";
import type { MyPluginOptions } from "./descriptor.js";
export function createPlugin(options: MyPluginOptions = {}) {
const maxItems = options.maxItems ?? 100;
return definePlugin({
id: "my-plugin",
version: "1.0.0",
capabilities: ["read:content"],
storage: {
items: {
indexes: ["status", "createdAt", ["status", "createdAt"]],
},
},
admin: {
entry: "@my-org/plugin-example/admin",
settingsSchema: {
maxItems: {
type: "number",
label: "Maximum Items",
description: "Limit stored items",
default: maxItems,
min: 1,
max: 1000,
},
enabled: {
type: "boolean",
label: "Enabled",
default: options.enabled ?? true,
},
},
pages: [{ path: "/settings", label: "Settings", icon: "settings" }],
widgets: [{ id: "status", title: "Status", size: "half" }],
},
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Plugin installed");
},
"content:afterSave": async (event, ctx) => {
const enabled = await ctx.kv.get<boolean>("settings:enabled");
if (enabled === false) return;
ctx.log.info("Content saved", {
collection: event.collection,
id: event.content.id,
});
},
},
routes: {
status: {
handler: async (ctx) => {
const count = await ctx.storage.items!.count();
return { count, maxItems };
},
},
},
});
}
export default createPlugin;
Règles d’identifiant de plugin
Le champ id doit respecter ces règles :
- Caractères alphanumériques minuscules et tirets uniquement
- Soit simple (
my-plugin) soit scopé (@my-org/my-plugin) - Unique parmi tous les plugins installés
// ID valides
"seo";
"audit-log";
"@emdash-cms/plugin-forms";
// ID invalides
"MyPlugin"; // Pas de majuscules
"my_plugin"; // Pas de tirets bas
"my.plugin"; // Pas de points
Format de version
Utilisez le versionnage sémantique :
version: "1.0.0"; // Valide
version: "1.2.3-beta"; // Valide (préversion)
version: "1.0"; // Invalide (patch manquant)
Exports du package
Configurez les exports dans package.json pour qu’EmDash puisse charger chaque point d’entrée. Le descripteur et la définition sont des exports séparés car ils s’exécutent dans des environnements différents :
{
"name": "@my-org/plugin-example",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./descriptor": {
"types": "./dist/descriptor.d.ts",
"import": "./dist/descriptor.js"
},
"./admin": {
"types": "./dist/admin.d.ts",
"import": "./dist/admin.js"
},
"./astro": {
"types": "./dist/astro/index.d.ts",
"import": "./dist/astro/index.js"
}
},
"files": ["dist"],
"peerDependencies": {
"emdash": "^0.1.0",
"react": "^18.0.0"
}
}
| Export | Contexte | Rôle |
|---|---|---|
"." | Serveur (runtime) | createPlugin() / definePlugin() — chargé par entrypoint au moment de la requête |
"./descriptor" | Vite (build time) | Fabrique PluginDescriptor — importé dans astro.config.mjs |
"./admin" | Navigateur | Composants React pour les pages/widgets admin |
"./astro" | Serveur (SSR) | Composants Astro pour le rendu côté site des blocs |
N’incluez les exports ./admin et ./astro que si le plugin les utilise.
Exemple complet : plugin de journal d’audit
Cet exemple illustre le stockage, les hooks de cycle de vie, les hooks de contenu et les routes API :
import { definePlugin } from "emdash";
interface AuditEntry {
timestamp: string;
action: "create" | "update" | "delete";
collection: string;
resourceId: string;
userId?: string;
}
export function createPlugin() {
return definePlugin({
id: "audit-log",
version: "0.1.0",
storage: {
entries: {
indexes: [
"timestamp",
"action",
"collection",
["collection", "timestamp"],
["action", "timestamp"],
],
},
},
admin: {
settingsSchema: {
retentionDays: {
type: "number",
label: "Retention (days)",
description: "Days to keep entries. 0 = forever.",
default: 90,
min: 0,
max: 365,
},
},
pages: [{ path: "/history", label: "Audit History", icon: "history" }],
widgets: [{ id: "recent-activity", title: "Recent Activity", size: "half" }],
},
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Audit log plugin installed");
},
"content:afterSave": {
priority: 200,
timeout: 2000,
handler: async (event, ctx) => {
const { content, collection, isNew } = event;
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: isNew ? "create" : "update",
collection,
resourceId: content.id as string,
};
const entryId = `${Date.now()}-${content.id}`;
await ctx.storage.entries!.put(entryId, entry);
ctx.log.info(`Logged ${entry.action} on ${collection}/${content.id}`);
},
},
"content:afterDelete": {
priority: 200,
timeout: 1000,
handler: async (event, ctx) => {
const { id, collection } = event;
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: "delete",
collection,
resourceId: id,
};
const entryId = `${Date.now()}-${id}`;
await ctx.storage.entries!.put(entryId, entry);
ctx.log.info(`Logged delete on ${collection}/${id}`);
},
},
},
routes: {
recent: {
handler: async (ctx) => {
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit: 10,
});
return {
entries: result.items.map((item) => ({
id: item.id,
...(item.data as AuditEntry),
})),
};
},
},
history: {
handler: async (ctx) => {
const url = new URL(ctx.request.url);
const limit = parseInt(url.searchParams.get("limit") || "50", 10);
const cursor = url.searchParams.get("cursor") || undefined;
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit,
cursor,
});
return {
entries: result.items.map((item) => ({
id: item.id,
...(item.data as AuditEntry),
})),
cursor: result.cursor,
hasMore: result.hasMore,
};
},
},
},
});
}
export default createPlugin;
Tester les plugins
Testez les plugins en créant un site Astro minimal avec le plugin enregistré :
-
Créez un site de test avec EmDash installé.
-
Enregistrez votre plugin dans
astro.config.mjs:import myPlugin from "../path/to/my-plugin/src"; export default defineConfig({ integrations: [ emdash({ plugins: [myPlugin()], }), ], }); -
Lancez le serveur de développement et déclenchez les hooks en créant/mettant à jour du contenu.
-
Vérifiez la console pour la sortie de
ctx.loget vérifiez le stockage via les routes API.
Pour les tests unitaires, simulez l’interface PluginContext et appelez les gestionnaires de hooks directement.
Types de blocs Portable Text
Les plugins peuvent ajouter des types de blocs personnalisés à l’éditeur Portable Text. Ceux-ci apparaissent dans le menu slash de l’éditeur et peuvent être insérés dans tout champ portableText.
Déclarer les types de blocs
Dans createPlugin(), déclarez les blocs sous admin.portableTextBlocks :
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video", // Icône nommée : video, code, link, link-external
placeholder: "Paste YouTube URL...",
fields: [ // Champs Block Kit pour l'UI d'édition
{ type: "text_input", action_id: "id", label: "YouTube URL" },
{ type: "text_input", action_id: "title", label: "Title" },
{ type: "text_input", action_id: "poster", label: "Poster Image URL" },
],
},
],
}
Chaque type de bloc définit :
type— Nom du type de bloc (utilisé dans le_typePortable Text)label— Nom d’affichage dans le menu slashicon— Clé d’icône (video,code,link,link-external). Utilise un cube générique par défaut.placeholder— Texte indicatif du champ de saisiefields— Champs de formulaire Block Kit pour l’édition. Si omis, un simple champ URL est affiché.
Rendu côté site
Pour afficher vos types de blocs sur le site, exportez des composants Astro depuis un componentsEntry :
import YouTube from "./YouTube.astro";
import CodePen from "./CodePen.astro";
export const blockComponents = {
youtube: YouTube,
codepen: CodePen,
};
Définissez componentsEntry dans votre descripteur de plugin :
export function myPlugin(options = {}): PluginDescriptor {
return {
id: "my-plugin",
entrypoint: "@my-org/my-plugin",
componentsEntry: "@my-org/my-plugin/astro",
// ...
};
}
Les composants de blocs de plugins sont automatiquement fusionnés dans <PortableText> — les auteurs du site n’ont rien à importer. Les composants fournis par l’utilisateur ont la priorité sur les composants par défaut des plugins.
Exports du package
Ajoutez l’export ./astro dans package.json :
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" },
"./astro": { "types": "./dist/astro/index.d.ts", "import": "./dist/astro/index.js" }
}
}
Prochaines étapes
- Référence des hooks — Tous les hooks disponibles avec signatures
- API de stockage — Collections de documents et requêtes
- Paramètres — Schéma de paramètres et store KV
- Interface d’administration — Pages et widgets
- Routes API — Points de terminaison REST