Esta guía te guía a través de la construcción de un plugin nativo desde cero. Los plugins nativos se ejecutan en el mismo proceso que tu sitio Astro — sin límites de sandbox, acceso completo al runtime y acceso a características que el sandbox no puede proporcionar (páginas de administración React, componentes Portable Text, fragmentos de página).
Si aún no has decidido si quieres un plugin nativo en lugar de uno en sandbox, lee primero Elegir un formato de plugin. La ruta nativa es para los casos en que el sandbox realmente no puede hacer lo que necesitas.
Dos partes, en uno o dos archivos
Al igual que los plugins en sandbox, los plugins nativos incluyen dos partes:
- Una fábrica de descriptor — devuelve un
PluginDescriptorconformat: "native"más puntos de entrada relacionados con la administración. Importado porastro.config.mjsen tiempo de compilación. - Una función
createPlugin(options)— el lado del runtime. Devuelve un resultadodefinePlugin({ id, version, capabilities, hooks, routes, admin }).
A diferencia de los plugins en sandbox, ambas partes pueden vivir en el mismo archivo porque no se ejecutan en diferentes entornos — todo el plugin se ejecuta en el proceso. La exportación "." del paquete apunta a un archivo que exporta tanto la fábrica de descriptor como una función createPlugin (o default):
my-native-plugin/
├── src/
│ ├── index.ts # Fábrica de descriptor + createPlugin
│ ├── admin.tsx # Componentes de administración 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 y no envíes duplicados.
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: "ID de seguimiento" },
enabled: { type: "boolean", label: "Habilitado", default: options.enabled ?? true },
},
pages: [{ path: "/dashboard", label: "Panel", icon: "chart" }],
widgets: [{ id: "events-today", title: "Eventos hoy", size: "third" }],
},
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Plugin de analytics instalado", { 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;
Algunos detalles que vale la pena conocer:
format: "native"es obligatorio. El valor predeterminado también sería"native"— pero ser explícito en cada descriptor facilita identificar con qué formato estás trabajando.entrypointes la exportación principal del paquete. EmDash lo importa en tiempo de ejecución y llama a la exportación predeterminada para construir el plugin resuelto.optionsfluye del descriptor acreatePlugin. Todo lo que el usuario pasa al registrar el plugin (analyticsPlugin({ enabled: false })) se conserva en el descriptor y se reenvía acreatePlugin. Los plugins en sandbox no tienen esta superficie — leen la configuración de KV en su lugar.id,versionycapabilitiesaparecen dos veces. Una vez en el descriptor, una vez endefinePlugin(). Deben coincidir. La copia del descriptor es lo queastro.config.mjsve en tiempo de compilación; la copia dedefinePlugin()es lo que se ejecuta en tiempo de solicitud.- Los manejadores de ruta nativos toman un solo argumento —
(ctx: RouteContext)dondectx.input,ctx.requestyctx.requestMetase fusionan con las propiedades regulares dePluginContext. 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 de plugin
El campo id debe coincidir con /^[a-z][a-z0-9_-]*$/ — comenzar con una letra minúscula, luego letras, dígitos, guiones o guiones bajos. El id se usa como un único segmento de ruta en las URL de ruta de plugin y como parte de los identificadores SQL generados para los índices de almacenamiento de plugin, por lo que cualquier cosa fuera de ese patrón falla en tiempo de ejecución.
// Válido
"seo";
"audit-log";
"audit_log";
"plugin-forms";
// Inválido
"@my-org/plugin-forms"; // forma con ámbito no permitida en tiempo de ejecución
"MyPlugin"; // sin mayúsculas
"42-plugin"; // no puede comenzar con un dígito
"my.plugin"; // sin puntos
Empareja un id sin ámbito con un nombre de paquete npm con ámbito en entrypoint — el nombre del paquete y el id del plugin son preocupaciones separadas.
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 parche)
Registrar el plugin
En el astro.config.mjs de tu sitio, importa la fábrica de descriptor y pásala al array plugins: [] — los plugins nativos siempre se ejecutan en 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 }),
],
}),
],
});
Interfaz de configuración
Los plugins nativos pueden usar admin.settingsSchema para un formulario de configuración autogenerado, que es la ruta más simple:
admin: {
settingsSchema: {
apiKey: { type: "secret", label: "Clave API" },
enabled: { type: "boolean", label: "Habilitado", default: true },
maxItems: { type: "number", label: "Máx. elementos", 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. La configuración se persiste en el mismo almacén KV por plugin que usan los plugins en sandbox — léelos con ctx.kv.get<T>("settings:<key>") desde cualquier lugar.
Para una interfaz de configuración más rica de lo que proporciona settingsSchema, envía páginas React personalizadas — consulta Páginas y widgets de administración 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: "Historial de auditoría", icon: "history" }],
widgets: [{ id: "recent-activity", title: "Actividad reciente", 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:
- Crea un sitio de prueba con EmDash instalado.
- Registra tu plugin en
astro.config.mjs, importándolo directamente desde tu ruta de origen local. - Ejecuta el servidor de desarrollo y activa los hooks creando, actualizando o eliminando contenido.
- Verifica 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 directamente a los manejadores de hooks.
Próximos pasos
- Páginas y widgets de administración React — envía interfaz React personalizada para el panel de administración.
- Componentes de renderizado Portable Text — proporciona componentes Astro que renderizan tipos de bloque definidos por el plugin.
- Fragmentos de página — inyecta scripts, hojas de estilo o HTML en páginas públicas.
- Distribución de plugins nativos — empaquetado npm y versionado.