Tu primer plugin nativo

En esta página

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

  1. Una fábrica de descriptor — devuelve un PluginDescriptor con format: "native" más puntos de entrada relacionados con admin. Importado por astro.config.mjs en tiempo de build.
  2. Una función createPlugin(options) — el lado del runtime. Devuelve un resultado definePlugin({ 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.
  • entrypoint es la exportación principal del paquete.
  • options fluyen del descriptor → createPlugin.
  • id, version y capabilities aparecen 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

  1. Crea un sitio de prueba con EmDash instalado.
  2. Registra tu plugin en astro.config.mjs, importándolo directamente desde tu ruta de fuente local.
  3. Ejecuta el servidor de desarrollo y activa hooks creando, actualizando o eliminando contenido.
  4. Revisa la consola para la salida de ctx.log y verifica el almacenamiento a través de rutas API.

Para pruebas unitarias, simula la interfaz PluginContext y llama a los manejadores de hooks directamente.

Próximos pasos