Il tuo primo plugin nativo

In questa pagina

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:

  1. Una factory del descrittore — restituisce un PluginDescriptor con format: "native" più punti di ingresso relativi all’admin. Importata da astro.config.mjs al momento della build.
  2. Una funzione createPlugin(options) — il lato runtime. Restituisce un risultato definePlugin({ 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.
  • options passano dal descrittore → createPlugin. Tutto ciò che l’utente passa quando registra il plugin (analyticsPlugin({ enabled: false })) viene preservato nel descrittore e inoltrato a createPlugin. I plugin sandbox non hanno questa superficie — leggono le impostazioni da KV invece.
  • id, version e capabilities appaiono due volte. Una volta sul descrittore, una volta su definePlugin(). Devono corrispondere. La copia del descrittore è ciò che astro.config.mjs vede al momento della build; la copia di definePlugin() è ciò che viene eseguito al momento della richiesta.
  • I gestori di route nativi prendono un singolo argomento(ctx: RouteContext) dove ctx.input, ctx.request e ctx.requestMeta vengono fusi con le proprietà regolari di PluginContext. 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:

  1. Crea un sito di test con EmDash installato.
  2. Registra il tuo plugin in astro.config.mjs, importandolo direttamente dal tuo percorso sorgente locale.
  3. Avvia il server di sviluppo e attiva gli hook creando, aggiornando o eliminando contenuto.
  4. Controlla la console per l’output di ctx.log e verifica lo storage tramite le route API.

Per i test unitari, mocka l’interfaccia PluginContext e chiama direttamente i gestori degli hook.

Prossimi passi