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 :
- Une factory de descripteur — retourne un
PluginDescriptoravecformat: "native"plus des points d’entrée liés à l’admin. Importée parastro.config.mjsau moment du build. - Une fonction
createPlugin(options)— le côté runtime. Retourne un résultatdefinePlugin({ 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.entrypointest l’export principal du package. EmDash l’importe au runtime et appelle l’export par défaut pour construire le plugin résolu.optionspassent 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,versionetcapabilitiesapparaissent deux fois. Une fois sur le descripteur, une fois surdefinePlugin(). Ils doivent correspondre. La copie du descripteur est ce queastro.config.mjsvoit au moment du build ; la copie dedefinePlugin()est ce qui s’exécute au moment de la requête.- Les gestionnaires de route natifs prennent un seul argument —
(ctx: RouteContext)oùctx.input,ctx.requestetctx.requestMetasont fusionnés avec les propriétés régulières dePluginContext. 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é :
- Créez un site de test avec EmDash installé.
- Enregistrez votre plugin dans
astro.config.mjs, en l’important directement depuis votre chemin source local. - Lancez le serveur de développement et déclenchez les hooks en créant, mettant à jour ou supprimant du contenu.
- Vérifiez la console pour la sortie de
ctx.loget 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
- Pages admin et widgets React — fournissez une UI React personnalisée pour le panneau admin.
- Composants de rendu Portable Text — fournissez des composants Astro qui rendent les types de blocs définis par les plugins.
- Fragments de page — injectez des scripts, feuilles de style ou du HTML dans les pages publiques.
- Distribuer des plugins natifs — packaging npm et versionnage.