Creating Plugins
This guide walks through building a complete EmDash plugin. You’ll learn how to structure the code, define hooks and storage, and export admin UI components.
Plugin Structure
Section titled “Plugin Structure”Every plugin has two parts that run in different contexts:
- Plugin descriptor (
PluginDescriptor) — returned by the factory function, tells EmDash how to load the plugin. Runs at build time in Vite (imported inastro.config.mjs). Must be side-effect-free and cannot use runtime APIs. - Plugin definition (
definePlugin()) — contains the runtime logic (hooks, routes, storage). Runs at request time on the deployed server. Has access to the full plugin context (ctx).
These must be in separate entrypoints because they execute in completely different environments:
my-plugin/├── src/│ ├── descriptor.ts # Plugin descriptor (runs in Vite at build time)│ ├── index.ts # Plugin definition with definePlugin() (runs at deploy time)│ ├── admin.tsx # Admin UI exports (React components) — optional│ └── astro/ # Optional: Astro components for site-side rendering│ └── index.ts # Must export `blockComponents`├── package.json└── tsconfig.jsonCreating the Plugin
Section titled “Creating the Plugin”Descriptor (build time)
Section titled “Descriptor (build time)”The descriptor tells EmDash where to find the plugin and what admin UI it provides. This file is imported in astro.config.mjs and runs in Vite.
import type { PluginDescriptor } from "emdash";
// Options your plugin accepts at registration timeexport interface MyPluginOptions { enabled?: boolean; maxItems?: number;}
export function myPlugin(options: MyPluginOptions = {}): PluginDescriptor { return { id: "my-plugin", version: "1.0.0", entrypoint: "@my-org/plugin-example", options, adminEntry: "@my-org/plugin-example/admin", componentsEntry: "@my-org/plugin-example/astro", adminPages: [{ path: "/settings", label: "Settings", icon: "settings" }], adminWidgets: [{ id: "status", title: "Status", size: "half" }], };}Definition (runtime)
Section titled “Definition (runtime)”The definition contains the runtime logic — hooks, routes, storage, and admin configuration. This file is loaded at request time on the deployed server.
import { definePlugin } from "emdash";import type { MyPluginOptions } from "./descriptor.js";
export function createPlugin(options: MyPluginOptions = {}) { const maxItems = options.maxItems ?? 100;
return definePlugin({ id: "my-plugin", version: "1.0.0",
// Declare required capabilities capabilities: ["read:content"],
// Plugin storage (document collections) storage: { items: { indexes: ["status", "createdAt", ["status", "createdAt"]], }, },
// Admin UI configuration admin: { entry: "@my-org/plugin-example/admin", settingsSchema: { maxItems: { type: "number", label: "Maximum Items", description: "Limit stored items", default: maxItems, min: 1, max: 1000, }, enabled: { type: "boolean", label: "Enabled", default: options.enabled ?? true, }, }, pages: [{ path: "/settings", label: "Settings", icon: "settings" }], widgets: [{ id: "status", title: "Status", size: "half" }], },
// Hook handlers hooks: { "plugin:install": async (_event, ctx) => { ctx.log.info("Plugin installed"); },
"content:afterSave": async (event, ctx) => { const enabled = await ctx.kv.get<boolean>("settings:enabled"); if (enabled === false) return;
ctx.log.info("Content saved", { collection: event.collection, id: event.content.id, }); }, },
// API routes (trusted only — not available in sandboxed plugins) routes: { status: { handler: async (ctx) => { const count = await ctx.storage.items!.count(); return { count, maxItems }; }, }, }, });}
export default createPlugin;Plugin ID Rules
Section titled “Plugin ID Rules”The id field must follow these rules:
- Lowercase alphanumeric characters and hyphens only
- Either simple (
my-plugin) or scoped (@my-org/my-plugin) - Unique across all installed plugins
// Valid IDs"seo";"audit-log";"@emdash-cms/plugin-forms";
// Invalid IDs"MyPlugin"; // No uppercase"my_plugin"; // No underscores"my.plugin"; // No dotsVersion Format
Section titled “Version Format”Use semantic versioning:
version: "1.0.0"; // Validversion: "1.2.3-beta"; // Valid (prerelease)version: "1.0"; // Invalid (missing patch)Package Exports
Section titled “Package Exports”Configure package.json exports so EmDash can load each entrypoint. The descriptor and definition are separate exports because they run in different environments:
{ "name": "@my-org/plugin-example", "version": "1.0.0", "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, "./descriptor": { "types": "./dist/descriptor.d.ts", "import": "./dist/descriptor.js" }, "./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" }, "./astro": { "types": "./dist/astro/index.d.ts", "import": "./dist/astro/index.js" } }, "files": ["dist"], "peerDependencies": { "emdash": "^0.1.0", "react": "^18.0.0" }}| Export | Context | Purpose |
|---|---|---|
"." | Server (runtime) | createPlugin() / definePlugin() — loaded by entrypoint at request time |
"./descriptor" | Vite (build time) | PluginDescriptor factory — imported in astro.config.mjs |
"./admin" | Browser | React components for admin pages/widgets |
"./astro" | Server (SSR) | Astro components for site-side block rendering |
Only include ./admin and ./astro exports if the plugin uses them.
Complete Example: Audit Log Plugin
Section titled “Complete Example: Audit Log Plugin”This example demonstrates storage, lifecycle hooks, content hooks, and API routes:
import { definePlugin } from "emdash";
interface AuditEntry { timestamp: string; action: "create" | "update" | "delete"; collection: string; resourceId: string; userId?: string;}
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: { "plugin:install": async (_event, ctx) => { ctx.log.info("Audit log plugin installed"); },
"content:afterSave": { priority: 200, // Run after other plugins timeout: 2000, handler: async (event, ctx) => { const { content, collection, isNew } = event;
const entry: AuditEntry = { timestamp: new Date().toISOString(), action: isNew ? "create" : "update", collection, resourceId: content.id as string, };
const entryId = `${Date.now()}-${content.id}`; await ctx.storage.entries!.put(entryId, entry);
ctx.log.info(`Logged ${entry.action} on ${collection}/${content.id}`); }, },
"content:afterDelete": { priority: 200, timeout: 1000, handler: async (event, ctx) => { const { id, collection } = event;
const entry: AuditEntry = { timestamp: new Date().toISOString(), action: "delete", collection, resourceId: id, };
const entryId = `${Date.now()}-${id}`; await ctx.storage.entries!.put(entryId, entry);
ctx.log.info(`Logged delete on ${collection}/${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), })), }; }, },
history: { handler: async (ctx) => { const url = new URL(ctx.request.url); const limit = parseInt(url.searchParams.get("limit") || "50", 10); const cursor = url.searchParams.get("cursor") || undefined;
const result = await ctx.storage.entries!.query({ orderBy: { timestamp: "desc" }, limit, cursor, });
return { entries: result.items.map((item) => ({ id: item.id, ...(item.data as AuditEntry), })), cursor: result.cursor, hasMore: result.hasMore, }; }, }, }, });}
export default createPlugin;Testing Plugins
Section titled “Testing Plugins”Test plugins by creating a minimal Astro site with the plugin registered:
-
Create a test site with EmDash installed.
-
Register your plugin in
astro.config.mjs:import myPlugin from "../path/to/my-plugin/src";export default defineConfig({integrations: [emdash({plugins: [myPlugin()],}),],}); -
Run the dev server and trigger hooks by creating/updating content.
-
Check the console for
ctx.logoutput and verify storage via API routes.
For unit tests, mock the PluginContext interface and call hook handlers directly.
Portable Text Block Types
Section titled “Portable Text Block Types”Plugins can add custom block types to the Portable Text editor. These appear in the editor’s slash command menu and can be inserted into any portableText field.
Declaring block types
Section titled “Declaring block types”In createPlugin(), declare blocks under admin.portableTextBlocks:
admin: { portableTextBlocks: [ { type: "youtube", label: "YouTube Video", icon: "video", // Named icon: video, code, link, link-external placeholder: "Paste YouTube URL...", fields: [ // Block Kit fields for the editing UI { type: "text_input", action_id: "id", label: "YouTube URL" }, { type: "text_input", action_id: "title", label: "Title" }, { type: "text_input", action_id: "poster", label: "Poster Image URL" }, ], }, ],}Each block type defines:
type— Block type name (used in Portable Text_type)label— Display name in the slash command menuicon— Icon key (video,code,link,link-external). Falls back to a generic cube.placeholder— Input placeholder textfields— Block Kit form fields for editing. If omitted, a simple URL input is shown.
Site-side rendering
Section titled “Site-side rendering”To render your block types on the site, export Astro components from a componentsEntry:
import YouTube from "./YouTube.astro";import CodePen from "./CodePen.astro";
// This export name is required — the virtual module imports itexport const blockComponents = { youtube: YouTube, codepen: CodePen,};Set componentsEntry in your plugin descriptor:
export function myPlugin(options = {}): PluginDescriptor { return { id: "my-plugin", entrypoint: "@my-org/my-plugin", componentsEntry: "@my-org/my-plugin/astro", // ... };}Plugin block components are automatically merged into <PortableText> — site authors don’t need to import anything. User-provided components take precedence over plugin defaults.
Package exports
Section titled “Package exports”Add the ./astro export to package.json:
{ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, "./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" }, "./astro": { "types": "./dist/astro/index.d.ts", "import": "./dist/astro/index.js" } }}Next Steps
Section titled “Next Steps”- Hooks Reference — All available hooks with signatures
- Storage API — Document collections and queries
- Settings — Settings schema and KV store
- Admin UI — Pages and widgets
- API Routes — REST endpoints