Esta guía te lleva paso a paso a construir un plugin nativo desde cero. Los plugins nativos se ejecutan en el mismo proceso que tu sitio Astro con acceso completo al runtime, incluyendo páginas admin React, componentes Portable Text y fragmentos de página.
Si aún no has decidido si quieres un plugin nativo en lugar de uno sandboxed, lee primero Elegir un formato de plugin. Nativo es el formato para plugins que necesitan páginas admin React, componentes de renderizado Portable Text o fragmentos de página.
Dos piezas, en uno o dos archivos
- Una fábrica de descriptor — devuelve un
PluginDescriptorconformat: "native"más puntos de entrada relacionados con admin. Importado porastro.config.mjsen tiempo de build. - Una función
createPlugin(options)— el lado del runtime. Devuelve un resultadodefinePlugin({ id, version, capabilities, hooks, routes, admin }).
Ambas piezas pueden vivir en el mismo archivo porque no se ejecutan en entornos diferentes:
my-native-plugin/
├── src/
│ ├── index.ts # Fábrica de descriptor + createPlugin
│ ├── admin.tsx # Componentes admin React (opcional)
│ └── astro/ # Componentes Astro para renderizado de bloques PT (opcional)
│ └── index.ts
├── package.json
└── tsconfig.json
Configurar el paquete
{
"name": "@my-org/plugin-analytics",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" }
},
"files": ["dist"],
"peerDependencies": { "emdash": "*", "react": "^18.0.0" }
}
Mantén emdash y react como dependencias peer para que el sitio host proporcione las versiones reales.
Escribir el descriptor y el runtime
import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";
export interface AnalyticsOptions {
enabled?: boolean;
maxEvents?: number;
}
export function analyticsPlugin(options: AnalyticsOptions = {}): PluginDescriptor {
return {
id: "analytics",
version: "0.1.0",
format: "native",
entrypoint: "@my-org/plugin-analytics",
options,
adminEntry: "@my-org/plugin-analytics/admin",
adminPages: [{ path: "/dashboard", label: "Dashboard", icon: "chart" }],
adminWidgets: [{ id: "events-today", title: "Events Today", size: "third" }],
};
}
export function createPlugin(options: AnalyticsOptions = {}) {
const maxEvents = options.maxEvents ?? 100;
return definePlugin({
id: "analytics",
version: "0.1.0",
capabilities: ["network:request"],
allowedHosts: ["api.analytics.example.com"],
storage: { events: { indexes: ["type", "createdAt"] } },
admin: {
entry: "@my-org/plugin-analytics/admin",
settingsSchema: {
trackingId: { type: "string", label: "Tracking ID" },
enabled: { type: "boolean", label: "Enabled", default: options.enabled ?? true },
},
pages: [{ path: "/dashboard", label: "Dashboard", icon: "chart" }],
widgets: [{ id: "events-today", title: "Events Today", size: "third" }],
},
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Analytics plugin installed", { maxEvents });
},
"content:afterSave": async (event, ctx) => {
const enabled = await ctx.kv.get<boolean>("settings:enabled");
if (enabled === false) return;
await ctx.storage.events.put(`evt_${Date.now()}`, {
type: "content:save",
contentId: event.content.id,
createdAt: new Date().toISOString(),
});
},
},
routes: {
stats: {
handler: async (ctx) => {
const today = new Date().toISOString().split("T")[0];
const count = await ctx.storage.events.count({ createdAt: { gte: today } });
return { today: count };
},
},
},
});
}
export default createPlugin;
Detalles clave:
format: "native"es requerido.entrypointes la exportación principal del paquete.optionsfluyen del descriptor →createPlugin.id,versionycapabilitiesaparecen dos veces. Deben coincidir.- Los manejadores de rutas nativos toman un solo argumento —
(ctx: RouteContext).
Reglas de ID del plugin
El campo id debe coincidir con /^[a-z][a-z0-9_-]*$/.
// Válidos
"seo"; "audit-log"; "audit_log"; "plugin-forms";
// Inválidos
"@my-org/plugin-forms"; "MyPlugin"; "42-plugin"; "my.plugin";
Formato de versión
Usa versionado semántico:
version: "1.0.0"; // válido
version: "1.2.3-beta"; // válido
version: "1.0"; // inválido
Registrar el plugin
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { analyticsPlugin } from "@my-org/plugin-analytics";
export default defineConfig({
integrations: [
emdash({
plugins: [analyticsPlugin({ enabled: true, maxEvents: 500 })],
}),
],
});
UI de configuración
admin: {
settingsSchema: {
apiKey: { type: "secret", label: "API Key" },
enabled: { type: "boolean", label: "Enabled", default: true },
maxItems: { type: "number", label: "Max items", min: 1, max: 1000, default: 100 },
},
},
Tipos de campo: string, number, boolean, select, secret, url, email. Las configuraciones se persisten en el mismo almacén KV por plugin — léelas con ctx.kv.get<T>("settings:<key>").
Para UI de configuración más rica, ver Páginas admin y widgets React.
Ejemplo completo — plugin de registro de auditoría
import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";
interface AuditEntry {
timestamp: string;
action: "create" | "update" | "delete";
collection: string;
resourceId: string;
userId?: string;
}
export function auditLogPlugin(): PluginDescriptor {
return {
id: "audit-log", version: "0.1.0", format: "native",
entrypoint: "@emdash-cms/plugin-audit-log",
};
}
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: "Retención (días)", description: "Días para mantener entradas. 0 = para siempre.", default: 90, min: 0, max: 365 },
},
pages: [{ path: "/history", label: "Audit History", icon: "history" }],
widgets: [{ id: "recent-activity", title: "Recent Activity", size: "half" }],
},
hooks: {
"content:afterSave": {
priority: 200,
handler: async (event, ctx) => {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: event.isNew ? "create" : "update",
collection: event.collection,
resourceId: event.content.id as string,
};
await ctx.storage.entries.put(`${Date.now()}-${event.content.id}`, entry);
},
},
"content:afterDelete": {
priority: 200,
handler: async (event, ctx) => {
await ctx.storage.entries.put(`${Date.now()}-${event.id}`, {
timestamp: new Date().toISOString(), action: "delete",
collection: event.collection, resourceId: event.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) })) };
},
},
},
});
}
export default createPlugin;
Pruebas
- Crea un sitio de prueba con EmDash instalado.
- Registra tu plugin en
astro.config.mjs, importándolo directamente desde tu ruta de fuente local. - Ejecuta el servidor de desarrollo y activa hooks creando, actualizando o eliminando contenido.
- Revisa la consola para la salida de
ctx.logy verifica el almacenamiento a través de rutas API.
Para pruebas unitarias, simula la interfaz PluginContext y llama a los manejadores de hooks directamente.