Questa guida ti accompagna nella costruzione di un plugin nativo da zero. I plugin nativi vengono eseguiti nello stesso processo del tuo sito Astro — nessun confine sandbox, accesso completo al runtime e accesso a funzionalità che il sandbox non può fornire (pagine di amministrazione React, componenti Portable Text, frammenti di pagina).
Se non hai ancora deciso se vuoi un plugin nativo invece di uno in sandbox, leggi prima Scegliere un formato di plugin. Il percorso nativo è per i casi in cui il sandbox non può davvero fare ciò di cui hai bisogno.
Due parti, in uno o due file
Come i plugin in sandbox, i plugin nativi includono due parti:
- Una factory di descriptor — restituisce un
PluginDescriptorconformat: "native"più punti di ingresso relativi all’amministrazione. Importato daastro.config.mjsal momento della compilazione. - Una funzione
createPlugin(options)— il lato runtime. Restituisce un risultatodefinePlugin({ id, version, capabilities, hooks, routes, admin }).
A differenza dei plugin in sandbox, entrambe le parti possono vivere nello stesso file perché non vengono eseguite in ambienti diversi — l’intero plugin viene eseguito in-process. L’export "." del pacchetto punta a un file che esporta sia la factory di descriptor che una funzione createPlugin (o default):
my-native-plugin/
├── src/
│ ├── index.ts # Factory di descriptor + createPlugin
│ ├── admin.tsx # Componenti di amministrazione React (opzionale)
│ └── astro/ # Componenti Astro per il rendering di blocchi PT (opzionale)
│ └── index.ts
├── package.json
└── tsconfig.json
Configurare il pacchetto
{
"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"
}
}
Mantieni emdash e react come peer dependencies in modo che il sito host fornisca le versioni effettive e tu non spedisca duplicati.
Scrivere il descriptor e il 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 di tracciamento" },
enabled: { type: "boolean", label: "Abilitato", default: options.enabled ?? true },
},
pages: [{ path: "/dashboard", label: "Dashboard", icon: "chart" }],
widgets: [{ id: "events-today", title: "Eventi oggi", size: "third" }],
},
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Plugin di analytics installato", { 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;
Alcuni dettagli da conoscere:
format: "native"è obbligatorio. Il valore predefinito sarebbe comunque"native"— ma essere espliciti su ogni descriptor rende facile identificare con quale formato stai lavorando.entrypointè l’export principale del pacchetto. EmDash lo importa al runtime e chiama l’export predefinito per costruire il plugin risolto.optionsscorre dal descriptor acreatePlugin. Tutto ciò che l’utente passa quando registra il plugin (analyticsPlugin({ enabled: false })) viene preservato sul descriptor e inoltrato acreatePlugin. I plugin in sandbox non hanno questa superficie — leggono le impostazioni da KV invece.id,versionecapabilitiesappaiono due volte. Una volta sul descriptor, una volta sudefinePlugin(). Dovrebbero corrispondere. La copia del descriptor è ciò cheastro.config.mjsvede al momento della compilazione; la copia didefinePlugin()è ciò che viene eseguito al momento della richiesta.- I gestori di route nativi accettano un singolo argomento —
(ctx: RouteContext)dovectx.input,ctx.requestectx.requestMetavengono uniti alle proprietàPluginContextregolari. Questo è l’opposto della forma a due argomenti del formato standard. Vedi Route API per la superficie completa (tutto il resto è identico).
Regole per l’id del plugin
Il campo id deve corrispondere a /^[a-z][a-z0-9_-]*$/ — iniziare con una lettera minuscola, poi lettere, cifre, trattini o underscore. L’id viene utilizzato come singolo segmento di percorso negli URL di route del plugin e come parte degli identificatori SQL generati per gli indici di storage del plugin, quindi qualsiasi cosa al di fuori di quel pattern fallisce al runtime.
// Valido
"seo";
"audit-log";
"audit_log";
"plugin-forms";
// Non valido
"@my-org/plugin-forms"; // forma con scope non consentita al runtime
"MyPlugin"; // niente maiuscole
"42-plugin"; // non può iniziare con una cifra
"my.plugin"; // niente punti
Accoppia un id senza scope con un nome di pacchetto npm con scope in entrypoint — il nome del pacchetto e l’id del plugin sono preoccupazioni separate.
Formato della versione
Usa il versionamento semantico:
version: "1.0.0"; // valido
version: "1.2.3-beta"; // valido (pre-release)
version: "1.0"; // non valido (patch mancante)
Registrare il plugin
Nel astro.config.mjs del tuo sito, importa la factory di descriptor e passala all’array plugins: [] — i plugin nativi vengono sempre eseguiti in-process, mai in 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 }),
],
}),
],
});
Interfaccia delle impostazioni
I plugin nativi possono usare admin.settingsSchema per un modulo di impostazioni generato automaticamente, che è il percorso più semplice:
admin: {
settingsSchema: {
apiKey: { type: "secret", label: "Chiave API" },
enabled: { type: "boolean", label: "Abilitato", default: true },
maxItems: { type: "number", label: "Max elementi", min: 1, max: 1000, default: 100 },
},
},
Tipi di campo: string, number, boolean, select, secret, url, email. Ognuno accetta label, description, default, più extra specifici del tipo come min/max/options. Le impostazioni vengono persistite nello stesso store KV per plugin che usano i plugin in sandbox — leggile con ctx.kv.get<T>("settings:<key>") da qualsiasi posto.
Per un’interfaccia di impostazioni più ricca di quanto fornisce settingsSchema, spedisci pagine React personalizzate — vedi Pagine e widget di amministrazione React.
Esempio completo: plugin di audit log
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: "Conservazione (giorni)",
description: "Giorni per conservare le voci. 0 = per sempre.",
default: 90,
min: 0,
max: 365,
},
},
pages: [{ path: "/history", label: "Cronologia audit", icon: "history" }],
widgets: [{ id: "recent-activity", title: "Attività recente", 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;
Test
Testa un plugin nativo creando un sito Astro minimo con il plugin registrato:
- Crea un sito di test con EmDash installato.
- Registra il tuo plugin in
astro.config.mjs, importandolo direttamente dal tuo percorso sorgente locale. - Esegui il server di sviluppo e attiva gli hook creando, aggiornando o eliminando contenuti.
- Controlla la console per l’output di
ctx.loge verifica lo storage tramite le route API.
Per i test unitari, mocka l’interfaccia PluginContext e chiama direttamente i gestori di hook.
Prossimi passi
- Pagine e widget di amministrazione React — spedisci UI React personalizzate per il pannello di amministrazione.
- Componenti di rendering Portable Text — fornisci componenti Astro che renderizzano tipi di blocco definiti dal plugin.
- Frammenti di pagina — inietta script, fogli di stile o HTML nelle pagine pubbliche.
- Distribuzione di plugin nativi — packaging npm e versionamento.