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 con acceso completo al runtime, incluyendo páginas de administración 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 de administración React, componentes de renderizado Portable Text o fragmentos de página.
Dos piezas, en uno o dos archivos
Como los plugins sandboxed, los plugins nativos incluyen dos piezas:
- Una factory de descriptor — devuelve un
PluginDescriptorconformat: "native"más puntos de entrada relacionados con admin. 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 sandboxed, ambas piezas pueden vivir en el mismo archivo porque no se ejecutan en entornos diferentes — todo el plugin se ejecuta en proceso. La exportación "." del paquete apunta a un archivo que exporta tanto la factory del descriptor como una función createPlugin (o default):
my-native-plugin/
├── src/
│ ├── index.ts # Descriptor factory + createPlugin
│ ├── admin.tsx # React admin components (optional)
│ └── astro/ # Astro components for PT block rendering (optional)
│ └── index.ts
├── package.json
└── tsconfig.json
Configurar el paquete
El siguiente package.json declara los puntos de entrada y las peer dependencies que necesita un plugin nativo:
{
"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 peer dependencies para que el sitio host proporcione las versiones reales y no envíes duplicados.
Escribir el descriptor y el runtime
El siguiente src/index.ts define la factory del descriptor y el runtime de createPlugin en un 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 requerido."native"también es el valor predeterminado, pero indicarlo explícitamente en cada descriptor hace que el formato sea fácil de identificar.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.optionsfluyen 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 sandboxed no tienen esta superficie — leen la configuración desde 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 rutas 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 solo segmento de ruta en URLs de rutas de plugin y como parte de identificadores SQL generados para índices de almacenamiento de 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:
// Valid
"seo";
"audit-log";
"audit_log";
"plugin-forms";
// Invalid
"@my-org/plugin-forms"; // scoped form not allowed at runtime
"MyPlugin"; // no uppercase
"42-plugin"; // can't start with a digit
"my.plugin"; // no dots
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. Los siguientes valores muestran qué cadenas de versión son aceptadas:
version: "1.0.0"; // valid
version: "1.2.3-beta"; // valid (prerelease)
version: "1.0"; // invalid (missing patch)
Registrar el plugin
En el astro.config.mjs de tu sitio, importa la factory del 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 }),
],
}),
],
});
UI de configuración
Los plugins nativos pueden usar admin.settingsSchema para un formulario de configuración generado automáticamente, que es el camino más simple:
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 sandboxed — léelas con ctx.kv.get<T>("settings:<key>") desde cualquier lugar.
Para una UI de configuración más rica de lo que proporciona settingsSchema, envía páginas React personalizadas — consulta Páginas de administración 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:
- 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 hooks creando, actualizando o eliminando contenido.
- Verifica 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.
Qué sigue
- Páginas de administración y widgets React — envía UI React personalizada para el panel de administración.
- Componentes de renderizado Portable Text — proporciona componentes Astro que renderizan tipos de bloques definidos por plugins.
- 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.