Dein erstes natives Plugin

Auf dieser Seite

Diese Anleitung führt durch den Aufbau eines nativen Plugins von Grund auf. Native Plugins laufen im selben Prozess wie deine Astro-Site mit vollem Zugriff auf die Laufzeitumgebung, einschließlich React-Admin-Seiten, Portable-Text-Komponenten und Seitenfragmenten.

Wenn du dich noch nicht entschieden hast, ob du ein natives Plugin anstelle eines Sandbox-Plugins möchtest, lies zuerst Ein Plugin-Format wählen. Nativ ist das Format für Plugins, die React-Admin-Seiten, Portable-Text-Rendering-Komponenten oder Seitenfragmente benötigen.

Zwei Teile, in einer oder zwei Dateien

Wie Sandbox-Plugins liefern native Plugins zwei Teile:

  1. Eine Descriptor-Factory — gibt einen PluginDescriptor mit format: "native" plus Admin-bezogene Einstiegspunkte zurück. Wird von astro.config.mjs zur Build-Zeit importiert.
  2. Eine createPlugin(options)-Funktion — die Laufzeitseite. Gibt ein definePlugin({ id, version, capabilities, hooks, routes, admin })-Ergebnis zurück.

Im Gegensatz zu Sandbox-Plugins können beide Teile in derselben Datei leben, da sie nicht in verschiedenen Umgebungen laufen — das gesamte Plugin läuft im selben Prozess. Der "."-Export des Pakets verweist auf eine Datei, die sowohl die Descriptor-Factory als auch eine createPlugin (oder default)-Funktion exportiert:

my-native-plugin/
├── src/
│   ├── index.ts          # Descriptor-Factory + createPlugin
│   ├── admin.tsx         # React-Admin-Komponenten (optional)
│   └── astro/            # Astro-Komponenten für PT-Block-Rendering (optional)
│       └── index.ts
├── package.json
└── tsconfig.json

Paket einrichten

Die folgende package.json deklariert die Einstiegspunkte und Peer-Abhängigkeiten, die ein natives Plugin benötigt:

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

Behalte emdash und react als Peer-Abhängigkeiten, damit die Host-Site die tatsächlichen Versionen bereitstellt und du keine Duplikate auslieferst.

Descriptor und Laufzeit schreiben

Die folgende src/index.ts definiert die Descriptor-Factory und die createPlugin-Laufzeit in einer Datei:

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;

Wichtige Details dieser Konfiguration:

  • format: "native" ist erforderlich. "native" ist auch der Standard, aber die explizite Angabe auf jedem Descriptor macht das Format leicht erkennbar.
  • entrypoint ist der Hauptexport des Pakets. EmDash importiert ihn zur Laufzeit und ruft den Standardexport auf, um das aufgelöste Plugin zu konstruieren.
  • options fließen Descriptor → createPlugin. Alles, was der Benutzer bei der Registrierung des Plugins übergibt (analyticsPlugin({ enabled: false })), wird auf dem Descriptor beibehalten und an createPlugin weitergeleitet. Sandbox-Plugins haben diese Oberfläche nicht — sie lesen Einstellungen stattdessen aus KV.
  • id, version und capabilities erscheinen zweimal. Einmal auf dem Descriptor, einmal auf definePlugin(). Sie sollten übereinstimmen. Die Kopie des Descriptors ist das, was astro.config.mjs zur Build-Zeit sieht; die definePlugin()-Kopie läuft zur Anfrage-Zeit.
  • Native Route-Handler nehmen ein einzelnes Argument(ctx: RouteContext) wobei ctx.input, ctx.request und ctx.requestMeta mit den regulären PluginContext-Eigenschaften zusammengeführt werden. Dies ist das Gegenteil der Zwei-Argument-Form des Standardformats. Siehe API-Routen für die vollständige Oberfläche (alles andere ist identisch).

Plugin-ID-Regeln

Das id-Feld muss /^[a-z][a-z0-9_-]*$/ entsprechen — beginnt mit einem Kleinbuchstaben, dann Buchstaben, Ziffern, Bindestriche oder Unterstriche. Die ID wird als einzelnes Pfadsegment in Plugin-Routen-URLs und als Teil generierter SQL-Bezeichner für Plugin-Speicherindizes verwendet, daher schlägt alles außerhalb dieses Musters zur Laufzeit fehl. Die folgenden Werte zeigen, welche IDs akzeptiert werden:

// Gültig
"seo";
"audit-log";
"audit_log";
"plugin-forms";

// Ungültig
"@my-org/plugin-forms";  // Scoped-Form zur Laufzeit nicht erlaubt
"MyPlugin";              // Keine Großbuchstaben
"42-plugin";             // Darf nicht mit einer Ziffer beginnen
"my.plugin";             // Keine Punkte

Kombiniere eine ungescoped id mit einem gescoped npm-Paketnamen in entrypoint — der Paketname und die Plugin-ID sind separate Belange.

Versionsformat

Verwende semantische Versionierung. Die folgenden Werte zeigen, welche Versionsstrings akzeptiert werden:

version: "1.0.0";       // gültig
version: "1.2.3-beta";  // gültig (Vorabversion)
version: "1.0";         // ungültig (fehlendes Patch)

Plugin registrieren

Importiere in der astro.config.mjs deiner Site die Descriptor-Factory und übergib sie in das plugins: []-Array — native Plugins laufen immer im selben Prozess, niemals in 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 }),
			],
		}),
	],
});

Einstellungs-UI

Native Plugins können admin.settingsSchema für ein automatisch generiertes Einstellungsformular verwenden, was der einfachste Weg ist:

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 },
	},
},

Feldtypen: string, number, boolean, select, secret, url, email. Jeder akzeptiert label, description, default, plus typspezifische Extras wie min/max/options. Einstellungen werden im selben Plugin-eigenen KV-Store gespeichert, den Sandbox-Plugins verwenden — lies sie mit ctx.kv.get<T>("settings:<key>") von überall.

Das generierte Formular erscheint hinter dem Zahnradsymbol auf der Karte des Plugins unter Plugins (nur Admins — das Bearbeiten von Plugin-Einstellungen erfordert die Berechtigung plugins:manage). Secret-Felder sind nur beschreibbar: Der Admin sieht nie den gespeicherten Wert, nur ob einer gesetzt ist.

Für reichhaltigere Einstellungs-UI als settingsSchema bietet, liefere benutzerdefinierte React-Seiten — siehe React-Admin-Seiten und Widgets.

Vollständiges Beispiel — Audit-Log-Plugin

Das folgende Plugin zeichnet jedes Erstellen, Aktualisieren und Löschen von Inhalten in indiziertem Speicher auf und stellt eine Route für kürzliche Aktivitäten bereit:

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;

Testen

Teste ein natives Plugin, indem du eine minimale Astro-Site mit dem registrierten Plugin erstellst:

  1. Erstelle eine Testsite mit installiertem EmDash.
  2. Registriere dein Plugin in astro.config.mjs, indem du es direkt von deinem lokalen Quellpfad importierst.
  3. Starte den Dev-Server und löse Hooks aus, indem du Inhalte erstellst, aktualisierst oder löschst.
  4. Überprüfe die Konsole auf ctx.log-Ausgaben und verifiziere den Speicher über API-Routen.

Für Unit-Tests mocke das PluginContext-Interface und rufe Hook-Handler direkt auf.

Nächste Schritte