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 — 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:

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

  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. Esegui il server di sviluppo e attiva gli hook creando, aggiornando o eliminando contenuti.
  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 di hook.

Prossimi passi