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:
- Eine Descriptor-Factory — gibt einen
PluginDescriptormitformat: "native"plus Admin-bezogene Einstiegspunkte zurück. Wird vonastro.config.mjszur Build-Zeit importiert. - Eine
createPlugin(options)-Funktion — die Laufzeitseite. Gibt eindefinePlugin({ 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.entrypointist der Hauptexport des Pakets. EmDash importiert ihn zur Laufzeit und ruft den Standardexport auf, um das aufgelöste Plugin zu konstruieren.optionsfließen Descriptor →createPlugin. Alles, was der Benutzer bei der Registrierung des Plugins übergibt (analyticsPlugin({ enabled: false })), wird auf dem Descriptor beibehalten und ancreatePluginweitergeleitet. Sandbox-Plugins haben diese Oberfläche nicht — sie lesen Einstellungen stattdessen aus KV.id,versionundcapabilitieserscheinen zweimal. Einmal auf dem Descriptor, einmal aufdefinePlugin(). Sie sollten übereinstimmen. Die Kopie des Descriptors ist das, wasastro.config.mjszur Build-Zeit sieht; diedefinePlugin()-Kopie läuft zur Anfrage-Zeit.- Native Route-Handler nehmen ein einzelnes Argument —
(ctx: RouteContext)wobeictx.input,ctx.requestundctx.requestMetamit den regulärenPluginContext-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:
- Erstelle eine Testsite mit installiertem EmDash.
- Registriere dein Plugin in
astro.config.mjs, indem du es direkt von deinem lokalen Quellpfad importierst. - Starte den Dev-Server und löse Hooks aus, indem du Inhalte erstellst, aktualisierst oder löschst.
- Ü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
- React-Admin-Seiten und Widgets — liefere benutzerdefinierte React-UI für das Admin-Panel.
- Portable-Text-Rendering-Komponenten — stelle Astro-Komponenten bereit, die plugin-definierte Blocktypen rendern.
- Seitenfragmente — injiziere Skripte, Stylesheets oder HTML in öffentliche Seiten.
- Native Plugins verteilen — npm-Paketierung und Versionierung.