Votre premier plugin natif

Sur cette page

Ce guide vous guide dans la construction d’un plugin natif à partir de zéro. Les plugins natifs s’exécutent dans le même processus que votre site Astro avec un accès complet au runtime, y compris les pages d’administration React, les composants Portable Text et les fragments de page.

Si vous n’avez pas encore décidé si vous souhaitez un plugin natif plutôt qu’un plugin sandboxed, lisez d’abord Choisir un format de plugin. Natif est le format pour les plugins qui nécessitent des pages d’administration React, des composants de rendu Portable Text ou des fragments de page.

Deux pièces, dans un ou deux fichiers

Comme les plugins sandboxed, les plugins natifs se composent de deux pièces :

  1. Une factory de descripteur — renvoie un PluginDescriptor avec format: "native" plus des points d’entrée liés à l’administration. Importé par astro.config.mjs au moment de la construction.
  2. Une fonction createPlugin(options) — le côté runtime. Renvoie un résultat definePlugin({ id, version, capabilities, hooks, routes, admin }).

Contrairement aux plugins sandboxed, les deux pièces peuvent vivre dans le même fichier car elles ne s’exécutent pas dans des environnements différents — tout le plugin s’exécute en processus. L’export "." du package pointe vers un fichier qui exporte à la fois la factory du descripteur et une fonction createPlugin (ou 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

Configurer le package

Le package.json suivant déclare les points d’entrée et les peer dependencies dont un plugin natif a besoin :

{
	"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"
	}
}

Gardez emdash et react comme peer dependencies afin que le site hôte fournisse les versions réelles et que vous n’expédiez pas de doublons.

Écrire le descripteur et le runtime

Le src/index.ts suivant définit la factory du descripteur et le runtime createPlugin dans un fichier :

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;

Détails clés de cette configuration :

  • format: "native" est requis. "native" est également la valeur par défaut, mais le spécifier explicitement sur chaque descripteur rend le format facile à repérer.
  • entrypoint est l’export principal du package. EmDash l’importe au runtime et appelle l’export par défaut pour construire le plugin résolu.
  • options passent du descripteur à createPlugin. Tout ce que l’utilisateur passe lors de l’enregistrement du plugin (analyticsPlugin({ enabled: false })) est préservé sur le descripteur et transmis à createPlugin. Les plugins sandboxed n’ont pas cette surface — ils lisent les paramètres depuis KV à la place.
  • id, version et capabilities apparaissent deux fois. Une fois sur le descripteur, une fois sur definePlugin(). Ils doivent correspondre. La copie du descripteur est ce que astro.config.mjs voit au moment de la construction ; la copie de definePlugin() est ce qui s’exécute au moment de la requête.
  • Les gestionnaires de route natifs prennent un seul argument(ctx: RouteContext)ctx.input, ctx.request et ctx.requestMeta sont fusionnés avec les propriétés PluginContext régulières. C’est l’opposé de la forme à deux arguments du format standard. Voir Routes API pour la surface complète (tout le reste est identique).

Règles d’ID de plugin

Le champ id doit correspondre à /^[a-z][a-z0-9_-]*$/ — commencer par une lettre minuscule, puis des lettres, des chiffres, des tirets ou des underscores. L’id est utilisé comme un seul segment de chemin dans les URL de routes de plugin et comme partie d’identifiants SQL générés pour les index de stockage de plugin, donc tout ce qui est en dehors de ce modèle échoue au runtime. Les valeurs suivantes montrent quels IDs sont acceptés :

// 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

Associez un id sans portée avec un nom de package npm avec portée dans entrypoint — le nom du package et l’id du plugin sont des préoccupations séparées.

Format de version

Utilisez le versioning sémantique. Les valeurs suivantes montrent quelles chaînes de version sont acceptées :

version: "1.0.0";       // valid
version: "1.2.3-beta";  // valid (prerelease)
version: "1.0";         // invalid (missing patch)

Enregistrer le plugin

Dans le astro.config.mjs de votre site, importez la factory du descripteur et passez-la dans le tableau plugins: [] — les plugins natifs s’exécutent toujours en processus, jamais dans 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 paramètres

Les plugins natifs peuvent utiliser admin.settingsSchema pour un formulaire de paramètres généré automatiquement, ce qui est le chemin le plus 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 },
	},
},

Types de champs : string, number, boolean, select, secret, url, email. Chacun accepte label, description, default, plus des extras spécifiques au type comme min/max/options. Les paramètres sont persistés dans le même magasin KV par plugin que les plugins sandboxed utilisent — lisez-les avec ctx.kv.get<T>("settings:<key>") de n’importe où.

Pour une UI de paramètres plus riche que ce que fournit settingsSchema, expédiez des pages React personnalisées — voir Pages d’administration et widgets React.

Exemple complet — plugin de journal d’audit

Le plugin suivant enregistre chaque création, mise à jour et suppression de contenu dans un stockage indexé et expose une route d’activité récente :

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;

Tests

Testez un plugin natif en créant un site Astro minimal avec le plugin enregistré :

  1. Créez un site de test avec EmDash installé.
  2. Enregistrez votre plugin dans astro.config.mjs, en l’important directement depuis votre chemin source local.
  3. Exécutez le serveur de développement et déclenchez des hooks en créant, mettant à jour ou supprimant du contenu.
  4. Vérifiez la console pour la sortie de ctx.log et vérifiez le stockage via les routes API.

Pour les tests unitaires, simulez l’interface PluginContext et appelez directement les gestionnaires de hooks.

Quelle est la suite