本指南将引导你构建一个完整的 EmDash 插件。你将学习如何组织代码、定义钩子和存储,以及导出后台 UI 组件。
本页面涵盖具有完整描述器 + 定义结构的原生格式插件。有关可以发布到市场的标准格式插件,请参阅发布插件。
插件结构
每个原生插件都有两个在不同上下文中运行的部分:
- 插件描述器 (
PluginDescriptor) — 由工厂函数返回,告诉 EmDash 如何加载插件。在 Vite 中的构建时运行(在astro.config.mjs中导入)。必须没有副作用,不能使用运行时 API。 - 插件定义 (
definePlugin()) — 包含运行时逻辑(钩子、路由、存储)。在部署的服务器上的请求时运行。 可以访问完整的插件上下文 (ctx)。
这些必须在单独的入口点中,因为它们在完全不同的环境中执行:
my-plugin/
├── src/
│ ├── descriptor.ts # 插件描述器(在构建时在 Vite 中运行)
│ ├── index.ts # 使用 definePlugin() 的插件定义(在部署时运行)
│ ├── admin.tsx # 后台 UI 导出(React 组件)— 可选
│ └── astro/ # 可选:用于站点侧渲染的 Astro 组件
│ └── index.ts # 必须导出 `blockComponents`
├── package.json
└── tsconfig.json
创建插件
描述器(构建时)
描述器告诉 EmDash 在哪里找到插件以及它提供什么后台 UI。此文件在 astro.config.mjs 中导入并在 Vite 中运行。
import type { PluginDescriptor } from "emdash";
// 你的插件在注册时接受的选项
export 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" }],
};
}
定义(运行时)
定义包含运行时逻辑 — 钩子、路由、存储和后台配置。此文件在部署的服务器上的请求时加载。
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",
// 声明所需的能力
capabilities: ["read:content"],
// 插件存储(文档集合)
storage: {
items: {
indexes: ["status", "createdAt", ["status", "createdAt"]],
},
},
// 后台 UI 配置
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" }],
},
// 钩子处理器
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: {
status: {
handler: async (ctx) => {
const count = await ctx.storage.items!.count();
return { count, maxItems };
},
},
},
});
}
export default createPlugin;
插件 ID 规则
id 字段必须遵循以下规则:
- 仅小写字母数字字符和连字符
- 简单 (
my-plugin) 或作用域 (@my-org/my-plugin) - 在所有已安装的插件中唯一
// 有效的 ID
"seo";
"audit-log";
"@emdash-cms/plugin-forms";
// 无效的 ID
"MyPlugin"; // 不允许大写
"my_plugin"; // 不允许下划线
"my.plugin"; // 不允许点
版本格式
使用语义版本控制:
version: "1.0.0"; // 有效
version: "1.2.3-beta"; // 有效(预发布)
version: "1.0"; // 无效(缺少补丁)
Package 导出
配置 package.json 导出,以便 EmDash 可以加载每个入口点。描述器和定义是单独的导出,因为它们在不同的环境中运行:
{
"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"
}
}
| 导出 | 上下文 | 目的 |
|---|---|---|
"." | 服务器(运行时) | createPlugin() / definePlugin() — 在请求时由 entrypoint 加载 |
"./descriptor" | Vite(构建时) | PluginDescriptor 工厂 — 在 astro.config.mjs 中导入 |
"./admin" | 浏览器 | 用于后台页面/小部件的 React 组件 |
"./astro" | 服务器(SSR) | 用于站点侧块渲染的 Astro 组件 |
仅在插件使用它们时包含 ./admin 和 ./astro 导出。
完整示例:审计日志插件
此示例演示存储、生命周期钩子、内容钩子和 API 路由:
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, // 在其他插件之后运行
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;
测试插件
通过创建一个注册了插件的最小 Astro 站点来测试插件:
-
创建一个安装了 EmDash 的测试站点。
-
在
astro.config.mjs中注册你的插件:import myPlugin from "../path/to/my-plugin/src"; export default defineConfig({ integrations: [ emdash({ plugins: [myPlugin()], }), ], }); -
运行开发服务器并通过创建/更新内容来触发钩子。
-
检查控制台以获取
ctx.log输出,并通过 API 路由验证存储。
对于单元测试,模拟 PluginContext 接口并直接调用钩子处理器。
Portable Text 块类型
插件可以向 Portable Text 编辑器添加自定义块类型。这些出现在编辑器的斜杠命令菜单中,可以插入到任何 portableText 字段中。
声明块类型
在 createPlugin() 中,在 admin.portableTextBlocks 下声明块:
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video", // 命名图标: video, code, link, link-external
placeholder: "Paste YouTube URL...",
fields: [ // 用于编辑 UI 的 Block Kit 字段
{ 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" },
],
},
],
}
每个块类型定义:
type— 块类型名称(在 Portable Text_type中使用)label— 斜杠命令菜单中的显示名称icon— 图标键(video、code、link、link-external)。回退到通用立方体。placeholder— 输入占位符文本fields— 用于编辑的 Block Kit 表单字段。如果省略,将显示简单的 URL 输入。
站点侧渲染
要在站点上渲染你的块类型,从 componentsEntry 导出 Astro 组件:
import YouTube from "./YouTube.astro";
import CodePen from "./CodePen.astro";
// 此导出名称是必需的 — 虚拟模块导入它
export const blockComponents = {
youtube: YouTube,
codepen: CodePen,
};
在你的插件描述器中设置 componentsEntry:
export function myPlugin(options = {}): PluginDescriptor {
return {
id: "my-plugin",
entrypoint: "@my-org/my-plugin",
componentsEntry: "@my-org/my-plugin/astro",
// ...
};
}
插件块组件会自动合并到 <PortableText> 中 — 站点作者不需要导入任何内容。用户提供的组件优先于插件默认值。
Package 导出
将 ./astro 导出添加到 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" }
}
}