Votre premier plugin natif

Sur cette page

Ce guide vous accompagne dans la construction d’un plugin natif 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 admin React, les composants Portable Text et les fragments de page.

Si vous n’avez pas encore décidé si vous voulez un plugin natif plutôt qu’un plugin sandboxé, lisez d’abord Choisir un format de plugin. Le format natif est destiné aux plugins qui ont besoin de pages admin React, de composants de rendu Portable Text ou de fragments de page.

Deux pièces, dans un ou deux fichiers

Comme les plugins sandboxés, les plugins natifs fournissent deux pièces :

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

Contrairement aux plugins sandboxés, les deux pièces peuvent résider dans le même fichier car elles ne s’exécutent pas dans des environnements différents — le plugin entier s’exécute dans le même processus. L’export "." du package pointe vers un fichier qui exporte à la fois la factory de descripteur et une fonction createPlugin (ou default) :

my-native-plugin/
├── src/
│   ├── index.ts          # Factory de descripteur + createPlugin
│   ├── admin.tsx         # Composants admin React (optionnel)
│   └── astro/            # Composants Astro pour le rendu de blocs PT (optionnel)
│       └── index.ts
├── package.json
└── tsconfig.json

Configurer le package

Le package.json suivant déclare les points d’entrée et les dépendances peer 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 dépendances peer pour 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 fichier src/index.ts suivant définit la factory de descripteur et le runtime createPlugin dans un seul 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 obligatoire. "native" est aussi la valeur par défaut, mais l’indiquer 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 conservé sur le descripteur et transmis à createPlugin. Les plugins sandboxés 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 du build ; 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 régulières de PluginContext. C’est l’opposé de la forme à deux arguments du format standard. Consultez 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_-]*$/ — commence par une lettre minuscule, puis des lettres, des chiffres, des tirets ou des underscores. L’id est utilisé comme un segment de chemin unique dans les URLs des routes de plugin et comme partie des identifiants SQL générés pour les index de stockage de plugin, donc tout ce qui est en dehors de ce pattern échoue au runtime. Les valeurs suivantes montrent quels ids sont acceptés :

// Valide
"seo";
"audit-log";
"audit_log";
"plugin-forms";

// Invalide
"@my-org/plugin-forms";  // forme scopée non autorisée au runtime
"MyPlugin";              // pas de majuscules
"42-plugin";             // ne peut pas commencer par un chiffre
"my.plugin";             // pas de points

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

Format de version

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

version: "1.0.0";       // valide
version: "1.2.3-beta";  // valide (préversion)
version: "1.0";         // invalide (patch manquant)

Enregistrer le plugin

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

Les plugins natifs peuvent utiliser admin.settingsSchema pour un formulaire de paramètres auto-généré, 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 champ : 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 sandboxés utilisent — lisez-les avec ctx.kv.get<T>("settings:<key>") depuis n’importe où.

Le formulaire généré apparaît derrière l’icône d’engrenage sur la carte du plugin dans Plugins (admins uniquement — modifier les paramètres du plugin nécessite la permission plugins:manage). Les champs secrets sont en écriture seule : l’admin ne voit jamais la valeur stockée, seulement si une valeur est définie.

Pour une UI de paramètres plus riche que ce que settingsSchema fournit, fournissez des pages React personnalisées — consultez Pages admin 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. Lancez le serveur de développement et déclenchez les 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, moquez l’interface PluginContext et appelez directement les gestionnaires de hooks.

Prochaines étapes