Votre premier plugin natif

Sur cette page

Ce guide vous accompagne dans la création d’un plugin natif à partir de zéro. Les plugins natifs s’exécutent dans le même processus que votre site Astro — pas de frontière de sandbox, accès complet au runtime et accès aux fonctionnalités que le sandbox ne peut pas fournir (pages d’administration React, composants Portable Text, fragments de page).

Si vous n’avez pas encore décidé si vous voulez un plugin natif plutôt qu’un plugin en sandbox, lisez d’abord Choisir un format de plugin. Le chemin natif est pour les cas où le sandbox ne peut vraiment pas faire ce dont vous avez besoin.

Deux parties, dans un ou deux fichiers

Comme les plugins en sandbox, les plugins natifs comportent deux parties :

  1. Une usine 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 compilation.
  2. Une fonction createPlugin(options) — le côté runtime. Renvoie un résultat definePlugin({ id, version, capabilities, hooks, routes, admin }).

Contrairement aux plugins en sandbox, les deux parties peuvent vivre 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 processus. L’export "." du package pointe vers un fichier qui exporte à la fois l’usine de descripteur et une fonction createPlugin (ou default) :

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

Configurer le package

{
	"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 afin que le site hôte fournisse les versions réelles et vous n’expédiez pas de doublons.

Écrire le descripteur et le 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 de suivi" },
				enabled: { type: "boolean", label: "Activé", default: options.enabled ?? true },
			},
			pages: [{ path: "/dashboard", label: "Tableau de bord", icon: "chart" }],
			widgets: [{ id: "events-today", title: "Événements aujourd'hui", size: "third" }],
		},

		hooks: {
			"plugin:install": async (_event, ctx) => {
				ctx.log.info("Plugin d'analyse installé", { 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;

Quelques détails à connaître :

  • format: "native" est requis. La valeur par défaut serait également "native" — mais être explicite sur chaque descripteur facilite l’identification du format avec lequel vous travaillez.
  • 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 passe 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 en sandbox 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 compilation ; 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 habituelles. C’est l’inverse 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_-]*$/ — commencer par une lettre minuscule, puis des lettres, des chiffres, des traits d’union ou des underscores. L’id est utilisé comme un seul segment de chemin dans les URL de route 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 modèle échoue au runtime.

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

Format de version

Utilisez le versionnement sémantique :

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 l’usine de descripteur et passez-la dans le tableau plugins: [] — les plugins natifs s’exécutent toujours dans le 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 }),
			],
		}),
	],
});

Interface de paramètres

Les plugins natifs peuvent utiliser admin.settingsSchema pour un formulaire de paramètres auto-généré, ce qui est le chemin le plus simple :

admin: {
	settingsSchema: {
		apiKey: { type: "secret", label: "Clé API" },
		enabled: { type: "boolean", label: "Activé", default: true },
		maxItems: { type: "number", label: "Nombre max d'éléments", 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 en sandbox utilisent — lisez-les avec ctx.kv.get<T>("settings:<key>") de n’importe où.

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

Exemple complet : plugin de journal d’audit

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: "Rétention (jours)",
					description: "Jours pour conserver les entrées. 0 = pour toujours.",
					default: 90,
					min: 0,
					max: 365,
				},
			},
			pages: [{ path: "/history", label: "Historique d'audit", icon: "history" }],
			widgets: [{ id: "recent-activity", title: "Activité récente", 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

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 les hooks en créant, mettant à jour ou supprimant du contenu.
  4. Vérifiez la console pour la sortie 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.

Prochaines étapes