Questa guida ti accompagna nella costruzione di un plugin nativo da zero. I plugin nativi vengono eseguiti nello stesso processo del tuo sito Astro con accesso completo al runtime, incluse pagine admin React, componenti Portable Text e frammenti di pagina.
Se non hai ancora deciso se vuoi un plugin nativo invece di uno sandbox, leggi prima Scegliere un formato di plugin. Nativo è il formato per i plugin che necessitano di pagine admin React, componenti di rendering Portable Text o frammenti di pagina.
Due pezzi, in uno o due file
Come i plugin sandbox, i plugin nativi forniscono due pezzi:
- Una factory del descrittore — restituisce un
PluginDescriptorconformat: "native"più punti di ingresso relativi all’admin. Importata daastro.config.mjsal momento della build. - Una funzione
createPlugin(options)— il lato runtime. Restituisce un risultatodefinePlugin({ id, version, capabilities, hooks, routes, admin }).
A differenza dei plugin sandbox, entrambi i pezzi possono risiedere nello stesso file perché non vengono eseguiti in ambienti diversi — l’intero plugin viene eseguito nello stesso processo. L’export "." del pacchetto punta a un file che esporta sia la factory del descrittore che una funzione createPlugin (o default):
my-native-plugin/
├── src/
│ ├── index.ts # Factory del descrittore + createPlugin
│ ├── admin.tsx # Componenti admin React (opzionale)
│ └── astro/ # Componenti Astro per il rendering dei blocchi PT (opzionale)
│ └── index.ts
├── package.json
└── tsconfig.json
Configurare il pacchetto
Il seguente package.json dichiara i punti di ingresso e le dipendenze peer di cui un plugin nativo ha bisogno:
{
"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 dipendenze peer in modo che il sito host fornisca le versioni effettive e tu non invii duplicati.
Scrivere il descrittore e il runtime
Il seguente src/index.ts definisce la factory del descrittore e il runtime createPlugin in un unico file:
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;
Dettagli chiave di questa configurazione:
format: "native"è obbligatorio."native"è anche il valore predefinito, ma indicarlo esplicitamente su ogni descrittore rende il formato facile da individuare.entrypointè l’export principale del pacchetto. EmDash lo importa a runtime e chiama l’export predefinito per costruire il plugin risolto.optionspassano dal descrittore →createPlugin. Tutto ciò che l’utente passa quando registra il plugin (analyticsPlugin({ enabled: false })) viene preservato nel descrittore e inoltrato acreatePlugin. I plugin sandbox non hanno questa superficie — leggono le impostazioni da KV invece.id,versionecapabilitiesappaiono due volte. Una volta sul descrittore, una volta sudefinePlugin(). Devono corrispondere. La copia del descrittore è ciò cheastro.config.mjsvede al momento della build; la copia didefinePlugin()è ciò che viene eseguito al momento della richiesta.- I gestori di route nativi prendono un singolo argomento —
(ctx: RouteContext)dovectx.input,ctx.requestectx.requestMetavengono fusi con le proprietà regolari diPluginContext. 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_-]*$/ — inizia con una lettera minuscola, poi lettere, cifre, trattini o underscore. L’id viene usato come un singolo segmento di percorso negli URL delle 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 a runtime. I seguenti valori mostrano quali id sono accettati:
// Valido
"seo";
"audit-log";
"audit_log";
"plugin-forms";
// Non valido
"@my-org/plugin-forms"; // forma con scope non permessa a runtime
"MyPlugin"; // nessuna maiuscola
"42-plugin"; // non può iniziare con una cifra
"my.plugin"; // nessun punto
Abbina 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. I seguenti valori mostrano quali stringhe di versione sono accettate:
version: "1.0.0"; // valido
version: "1.2.3-beta"; // valido (prerelease)
version: "1.0"; // non valido (patch mancante)
Registrare il plugin
Nel astro.config.mjs del tuo sito, importa la factory del descrittore e passala nell’array plugins: [] — i plugin nativi vengono sempre eseguiti nello stesso processo, 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 }),
],
}),
],
});
UI delle impostazioni
I plugin nativi possono usare admin.settingsSchema per un modulo di impostazioni auto-generato, che è il percorso più semplice:
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 },
},
},
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 sandbox — leggile con ctx.kv.get<T>("settings:<key>") da qualsiasi luogo.
Il modulo generato appare dietro l’icona dell’ingranaggio sulla card del plugin in Plugins (solo admin — modificare le impostazioni del plugin richiede il permesso plugins:manage). I campi segreti sono in sola scrittura: l’admin non vede mai il valore memorizzato, solo se uno è impostato.
Per un’UI delle impostazioni più ricca di quella che settingsSchema fornisce, fornisci pagine React personalizzate — vedi Pagine admin e widget React.
Esempio completo — plugin registro audit
Il seguente plugin registra ogni creazione, aggiornamento ed eliminazione di contenuto nello storage indicizzato ed espone una route per le attività recenti:
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;
Test
Testa un plugin nativo creando un sito Astro minimale 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. - Avvia il server di sviluppo e attiva gli hook creando, aggiornando o eliminando contenuto.
- 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 degli hook.
Prossimi passi
- Pagine admin e widget React — fornisci UI React personalizzata per il pannello admin.
- Componenti di rendering Portable Text — fornisci componenti Astro che renderizzano tipi di blocco definiti dai plugin.
- Frammenti di pagina — inietta script, fogli di stile o HTML nelle pagine pubbliche.
- Distribuire plugin nativi — packaging npm e versionamento.