Tu primer plugin nativo

En esta página

Esta guía te acompaña en la construcción de 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 sandbox, 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

Como los plugins sandbox, los plugins nativos entregan dos piezas:

  1. Una factoría de descriptor — devuelve un PluginDescriptor con format: "native" más puntos de entrada relacionados con admin. Importado por astro.config.mjs en tiempo de compilación.
  2. Una función createPlugin(options) — el lado del runtime. Devuelve un resultado de definePlugin({ id, version, capabilities, hooks, routes, admin }).

A diferencia de los plugins sandbox, ambas piezas pueden vivir en el mismo archivo porque no se ejecutan en entornos diferentes — todo el plugin se ejecuta en el mismo proceso. La exportación "." del paquete apunta a un archivo que exporta tanto la factoría de descriptor como una función createPlugin (o default):

my-native-plugin/
├── src/
│   ├── index.ts          # Factoría 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

El siguiente package.json declara los puntos de entrada y las dependencias peer que un plugin nativo necesita:

{
	"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 y no envíes duplicados.

Escribir el descriptor y el runtime

La siguiente src/index.ts define la factoría de descriptor y el runtime de createPlugin en un solo archivo:

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 de esta configuración:

  • format: "native" es obligatorio. "native" también es el valor por defecto, pero indicarlo explícitamente en cada descriptor facilita la identificación del formato.
  • entrypoint es la exportación principal del paquete. EmDash lo importa en tiempo de ejecución y llama a la exportación por defecto para construir el plugin resuelto.
  • options fluyen descriptor → createPlugin. Todo lo que el usuario pasa al registrar el plugin (analyticsPlugin({ enabled: false })) se preserva en el descriptor y se reenvía a createPlugin. Los plugins sandbox no tienen esta superficie — leen configuraciones desde KV en su lugar.
  • id, version y capabilities aparecen dos veces. Una en el descriptor, otra en definePlugin(). Deben coincidir. La copia del descriptor es lo que astro.config.mjs ve en tiempo de compilación; la copia de definePlugin() es lo que se ejecuta en tiempo de solicitud.
  • Los manejadores de ruta nativos toman un solo argumento(ctx: RouteContext) donde ctx.input, ctx.request y ctx.requestMeta se fusionan con las propiedades regulares de PluginContext. Esto es lo opuesto a la forma de dos argumentos del formato estándar. Consulta Rutas API para la superficie completa (todo lo demás es idéntico).

Reglas de ID del plugin

El campo id debe coincidir con /^[a-z][a-z0-9_-]*$/ — comienza con una letra minúscula, luego letras, dígitos, guiones o guiones bajos. El id se usa como un segmento de ruta único en las URLs de rutas del plugin y como parte de identificadores SQL generados para índices de almacenamiento del plugin, por lo que cualquier cosa fuera de ese patrón falla en tiempo de ejecución. Los siguientes valores muestran qué ids son aceptados:

// Válido
"seo";
"audit-log";
"audit_log";
"plugin-forms";

// Inválido
"@my-org/plugin-forms";  // forma con scope no permitida en tiempo de ejecución
"MyPlugin";              // sin mayúsculas
"42-plugin";             // no puede empezar con un dígito
"my.plugin";             // sin puntos

Combina un id sin scope con un nombre de paquete npm con scope en entrypoint — el nombre del paquete y el id del plugin son preocupaciones separadas.

Formato de versión

Usa versionado semántico. Los siguientes valores muestran qué cadenas de versión son aceptadas:

version: "1.0.0";       // válido
version: "1.2.3-beta";  // válido (prelanzamiento)
version: "1.0";         // inválido (falta patch)

Registrar el plugin

En el astro.config.mjs de tu sitio, importa la factoría de descriptor y pásala al array plugins: [] — los plugins nativos siempre se ejecutan en el mismo proceso, nunca en sandboxed: []:

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

Los plugins nativos pueden usar admin.settingsSchema para un formulario de configuración auto-generado, que es el camino más sencillo:

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. Cada uno acepta label, description, default, más extras específicos del tipo como min/max/options. Las configuraciones se persisten en el mismo almacén KV por plugin que usan los plugins sandbox — léelas con ctx.kv.get<T>("settings:<key>") desde cualquier lugar.

El formulario generado aparece detrás del icono de engranaje en la tarjeta del plugin en Plugins (solo admins — editar configuraciones del plugin requiere el permiso plugins:manage). Los campos secretos son solo escritura: el admin nunca ve el valor almacenado, solo si uno está configurado.

Para una UI de configuración más rica de lo que settingsSchema proporciona, envía páginas React personalizadas — consulta Páginas admin y widgets React.

Ejemplo completo — plugin de registro de auditoría

El siguiente plugin registra cada creación, actualización y eliminación de contenido en almacenamiento indexado y expone una ruta de actividad reciente:

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: "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: {
			"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

Prueba un plugin nativo creando un sitio Astro mínimo con el plugin registrado:

  1. Crea un sitio de prueba con EmDash instalado.
  2. Registra tu plugin en astro.config.mjs, importándolo directamente desde tu ruta de código 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, mockea la interfaz PluginContext y llama a los manejadores de hooks directamente.

Próximos pasos