本指南将引导你从零开始构建一个原生插件。原生插件在与你的 Astro 站点相同的进程中运行,拥有对运行时的完全访问权限,包括 React 管理页面、Portable Text 组件和页面片段。
如果你还没有决定是要原生插件还是沙盒插件,请先阅读选择插件格式。原生是需要 React 管理页面、Portable Text 渲染组件或页面片段的插件的格式。
两个部分,一个或两个文件
与沙盒插件一样,原生插件提供两个部分:
- 描述符工厂 — 返回一个带有
format: "native"和管理相关入口点的PluginDescriptor。在构建时由astro.config.mjs导入。 createPlugin(options)函数 — 运行时端。返回definePlugin({ id, version, capabilities, hooks, routes, admin })的结果。
与沙盒插件不同,两个部分可以放在同一个文件中,因为它们不在不同的环境中运行——整个插件在同一进程中运行。包的 "." 导出指向一个同时导出描述符工厂和 createPlugin(或 default)函数的文件:
my-native-plugin/
├── src/
│ ├── index.ts # 描述符工厂 + createPlugin
│ ├── admin.tsx # React 管理组件(可选)
│ └── astro/ # PT 块渲染用 Astro 组件(可选)
│ └── index.ts
├── package.json
└── tsconfig.json
设置包
以下 package.json 声明了原生插件需要的入口点和对等依赖:
{
"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"
}
}
将 emdash 和 react 保持为对等依赖,这样宿主站点提供实际版本,你不会发布重复的包。
编写描述符和运行时
以下 src/index.ts 在一个文件中定义了描述符工厂和 createPlugin 运行时:
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;
此配置的关键细节:
format: "native"是必需的。"native"也是默认值,但在每个描述符上显式声明可以轻松识别格式。entrypoint是包的主导出。 EmDash 在运行时导入它并调用默认导出来构建解析后的插件。options从描述符流向createPlugin。 用户在注册插件时传递的所有内容(analyticsPlugin({ enabled: false }))都保留在描述符上并转发给createPlugin。沙盒插件没有这个表面——它们改为从 KV 读取设置。id、version和capabilities出现两次。 描述符上一次,definePlugin()上一次。它们应该匹配。描述符的副本是astro.config.mjs在构建时看到的;definePlugin()的副本是在请求时运行的。- 原生路由处理器接受单个参数 —
(ctx: RouteContext)其中ctx.input、ctx.request和ctx.requestMeta与常规PluginContext属性合并。这与标准格式的双参数形式相反。完整的表面请参见 API 路由(其他所有内容都相同)。
插件 ID 规则
id 字段必须匹配 /^[a-z][a-z0-9_-]*$/ — 以小写字母开头,然后是字母、数字、连字符或下划线。ID 用作插件路由 URL 中的单个路径段,以及作为插件存储索引生成的 SQL 标识符的一部分,因此超出此模式的任何内容在运行时都会失败。以下值显示哪些 ID 被接受:
// 有效
"seo";
"audit-log";
"audit_log";
"plugin-forms";
// 无效
"@my-org/plugin-forms"; // 作用域形式在运行时不允许
"MyPlugin"; // 不能有大写字母
"42-plugin"; // 不能以数字开头
"my.plugin"; // 不能有点号
将无作用域的 id 与 entrypoint 中的有作用域的 npm 包名配对——包名和插件 ID 是不同的关注点。
版本格式
使用语义化版本控制。以下值显示哪些版本字符串被接受:
version: "1.0.0"; // 有效
version: "1.2.3-beta"; // 有效(预发布)
version: "1.0"; // 无效(缺少补丁号)
注册插件
在站点的 astro.config.mjs 中,导入描述符工厂并将其传递到 plugins: [] 数组中——原生插件始终在同一进程中运行,永远不放在 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
原生插件可以使用 admin.settingsSchema 来生成自动设置表单,这是最简单的方式:
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 },
},
},
字段类型:string、number、boolean、select、secret、url、email。每种类型接受 label、description、default,以及类型特定的扩展如 min/max/options。设置持久化到沙盒插件使用的同一个插件级 KV 存储——可以在任何地方使用 ctx.kv.get<T>("settings:<key>") 读取。
生成的表单出现在 Plugins 中插件卡片的齿轮图标后面(仅管理员——编辑插件设置需要 plugins:manage 权限)。密钥字段为只写:管理员永远看不到存储的值,只能看到是否已设置。
对于比 settingsSchema 提供的更丰富的设置 UI,请提供自定义 React 页面——参见 React 管理页面和小组件。
完整示例 — 审计日志插件
以下插件将每次内容的创建、更新和删除记录到索引存储中,并公开一个最近活动路由:
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;
测试
通过创建一个注册了插件的最小 Astro 站点来测试原生插件:
- 创建一个安装了 EmDash 的测试站点。
- 在
astro.config.mjs中注册你的插件,直接从本地源路径导入。 - 运行开发服务器,通过创建、更新或删除内容来触发钩子。
- 检查控制台中的
ctx.log输出,并通过 API 路由验证存储。
对于单元测试,模拟 PluginContext 接口并直接调用钩子处理器。
下一步
- React 管理页面和小组件 — 为管理面板提供自定义 React UI。
- Portable Text 渲染组件 — 提供渲染插件定义的块类型的 Astro 组件。
- 页面片段 — 将脚本、样式表或 HTML 注入公共页面。
- 分发原生插件 — npm 打包和版本管理。