Esta guía te lleva paso a paso por la construcción de un plugin completo de EmDash. Aprenderás a estructurar el código, definir hooks y almacenamiento, y exportar componentes de UI de administración.
Estructura del plugin
Todo plugin tiene dos partes que se ejecutan en contextos diferentes:
- Descriptor del plugin (
PluginDescriptor) — devuelto por la función factory, le indica a EmDash cómo cargar el plugin. Se ejecuta en tiempo de build en Vite (importado enastro.config.mjs). Debe estar libre de efectos secundarios y no puede usar APIs de runtime. - Definición del plugin (
definePlugin()) — contiene la lógica de runtime (hooks, rutas, almacenamiento). Se ejecuta en tiempo de solicitud en el servidor desplegado. Tiene acceso al contexto completo del plugin (ctx).
Estos deben estar en entrypoints separados porque se ejecutan en entornos completamente diferentes:
my-plugin/
├── src/
│ ├── descriptor.ts # Descriptor del plugin (se ejecuta en Vite en tiempo de build)
│ ├── index.ts # Definición del plugin con definePlugin() (se ejecuta en tiempo de despliegue)
│ ├── admin.tsx # Exports de UI admin (componentes React) — opcional
│ └── astro/ # Opcional: componentes Astro para renderizado del lado del sitio
│ └── index.ts # Debe exportar `blockComponents`
├── package.json
└── tsconfig.json
Crear el plugin
Descriptor (tiempo de build)
El descriptor le indica a EmDash dónde encontrar el plugin y qué UI de administración proporciona. Este archivo se importa en astro.config.mjs y se ejecuta en 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" }],
};
}
Definición (runtime)
La definición contiene la lógica de runtime — hooks, rutas, almacenamiento y configuración de administración. Este archivo se carga en tiempo de solicitud en el servidor desplegado.
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;
Reglas del ID del plugin
El campo id debe seguir estas reglas:
- Solo caracteres alfanuméricos en minúsculas y guiones
- Simple (
my-plugin) o con scope (@my-org/my-plugin) - Único entre todos los plugins instalados
// IDs válidos
"seo";
"audit-log";
"@emdash-cms/plugin-forms";
// IDs inválidos
"MyPlugin"; // Sin mayúsculas
"my_plugin"; // Sin guiones bajos
"my.plugin"; // Sin puntos
Formato de versión
Usa versionado semántico:
version: "1.0.0"; // Válido
version: "1.2.3-beta"; // Válido (prelanzamiento)
version: "1.0"; // Inválido (falta el patch)
Exports del paquete
Configura los exports de package.json para que EmDash pueda cargar cada entrypoint. El descriptor y la definición son exports separados porque se ejecutan en entornos diferentes:
{
"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 | Contexto | Propósito |
|---|---|---|
"." | Servidor (runtime) | createPlugin() / definePlugin() — cargado por entrypoint en tiempo de solicitud |
"./descriptor" | Vite (build time) | Factory PluginDescriptor — importado en astro.config.mjs |
"./admin" | Navegador | Componentes React para páginas/widgets de admin |
"./astro" | Servidor (SSR) | Componentes Astro para renderizado de bloques del lado del sitio |
Solo incluye los exports ./admin y ./astro si el plugin los usa.
Ejemplo completo: Plugin de registro de auditoría
Este ejemplo demuestra almacenamiento, hooks de ciclo de vida, hooks de contenido y rutas 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;
Probar plugins
Prueba los plugins creando un sitio Astro mínimo con el plugin registrado:
-
Crea un sitio de prueba con EmDash instalado.
-
Registra tu plugin en
astro.config.mjs:import myPlugin from "../path/to/my-plugin/src"; export default defineConfig({ integrations: [ emdash({ plugins: [myPlugin()], }), ], }); -
Ejecuta el servidor de desarrollo y activa los hooks creando/actualizando contenido.
-
Revisa la consola para la salida de
ctx.logy verifica el almacenamiento a través de las rutas API.
Para pruebas unitarias, simula la interfaz PluginContext y llama a los handlers de hook directamente.
Tipos de bloque Portable Text
Los plugins pueden añadir tipos de bloque personalizados al editor de Portable Text. Estos aparecen en el menú de comandos con barra del editor y se pueden insertar en cualquier campo portableText.
Declarar tipos de bloque
En createPlugin(), declara los bloques en admin.portableTextBlocks:
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video", // Icono con nombre: video, code, link, link-external
placeholder: "Paste YouTube URL...",
fields: [ // Campos Block Kit para la UI de edición
{ 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" },
],
},
],
}
Cada tipo de bloque define:
type— Nombre del tipo de bloque (usado en_typede Portable Text)label— Nombre para mostrar en el menú de comandos con barraicon— Clave de icono (video,code,link,link-external). Si no se encuentra, usa un cubo genérico.placeholder— Texto placeholder del campo de entradafields— Campos de formulario Block Kit para edición. Si se omite, se muestra un campo de URL simple.
Renderizado del lado del sitio
Para renderizar tus tipos de bloque en el sitio, exporta componentes Astro desde un componentsEntry:
import YouTube from "./YouTube.astro";
import CodePen from "./CodePen.astro";
export const blockComponents = {
youtube: YouTube,
codepen: CodePen,
};
Establece componentsEntry en el descriptor de tu plugin:
export function myPlugin(options = {}): PluginDescriptor {
return {
id: "my-plugin",
entrypoint: "@my-org/my-plugin",
componentsEntry: "@my-org/my-plugin/astro",
// ...
};
}
Los componentes de bloque de plugins se fusionan automáticamente en <PortableText> — los autores del sitio no necesitan importar nada. Los componentes proporcionados por el usuario tienen prioridad sobre los predeterminados del plugin.
Exports del paquete
Añade el export ./astro a 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" }
}
}
Próximos pasos
- Referencia de hooks — Todos los hooks disponibles con firmas
- API de almacenamiento — Colecciones de documentos y consultas
- Ajustes — Esquema de ajustes y almacén KV
- UI de administración — Páginas y widgets
- Rutas API — Endpoints REST