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 :
- Une factory de descripteur — renvoie un
PluginDescriptoravecformat: "native"plus des points d’entrée liés à l’administration. Importé parastro.config.mjsau moment de la construction. - Une fonction
createPlugin(options)— le côté runtime. Renvoie un résultatdefinePlugin({ 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.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 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,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 de la construction ; 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ésPluginContextré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é :
- 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. - Exécutez le serveur de développement et déclenchez des 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, simulez l’interface PluginContext et appelez directement les gestionnaires de hooks.
Quelle est la suite
- Pages d’administration et widgets React — expédiez une UI React personnalisée pour le panneau d’administration.
- Composants de rendu Portable Text — fournissez des composants Astro qui rendent des types de blocs définis par plugin.
- Fragments de page — injectez des scripts, des feuilles de style ou du HTML dans les pages publiques.
- Distribution de plugins natifs — packaging npm et versioning.