你的第一个原生插件

本页内容

本指南将逐步介绍如何从零开始构建原生插件。原生插件与你的 Astro 站点在同一进程中运行——没有沙箱边界,可以完全访问运行时,并且能够访问沙箱无法提供的功能(React 管理页面、Portable Text 组件、页面片段)。

如果你还没有决定是使用原生插件还是沙箱插件,请先阅读选择插件格式。原生路径适用于沙箱确实无法满足需求的情况。

两个部分,在一个或两个文件中

与沙箱插件类似,原生插件包含两个部分:

  1. 描述符工厂 — 返回一个带有 format: "native" 和管理相关入口点的 PluginDescriptor。在构建时由 astro.config.mjs 导入。
  2. 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

设置包

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

emdashreact 保留为对等依赖项,以便宿主站点提供实际版本,避免重复打包。

编写描述符和运行时

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: "跟踪 ID" },
				enabled: { type: "boolean", label: "已启用", default: options.enabled ?? true },
			},
			pages: [{ path: "/dashboard", label: "仪表板", icon: "chart" }],
			widgets: [{ id: "events-today", title: "今日事件", size: "third" }],
		},

		hooks: {
			"plugin:install": async (_event, ctx) => {
				ctx.log.info("分析插件已安装", { 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 读取设置。
  • idversioncapabilities 出现两次。 一次在描述符上,一次在 definePlugin() 中。它们应该匹配。描述符的副本是 astro.config.mjs 在构建时看到的;definePlugin() 的副本是在请求时运行的。
  • 原生路由处理器接受单个参数(ctx: RouteContext),其中 ctx.inputctx.requestctx.requestMeta 与常规 PluginContext 属性合并。这与标准格式的双参数形式相反。有关完整接口,请参阅 API 路由(其他所有内容都相同)。

插件 id 规则

id 字段必须匹配 /^[a-z][a-z0-9_-]*$/ — 以小写字母开头,然后是字母、数字、连字符或下划线。id 用作插件路由 URL 中的单个路径段,以及插件存储索引生成的 SQL 标识符的一部分,因此超出该模式的任何内容都会在运行时失败。

// 有效
"seo";
"audit-log";
"audit_log";
"plugin-forms";

// 无效
"@my-org/plugin-forms";  // 运行时不允许使用作用域形式
"MyPlugin";              // 不允许大写
"42-plugin";             // 不能以数字开头
"my.plugin";             // 不允许点号

将无作用域的 identrypoint 中的作用域 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 }),
			],
		}),
	],
});

设置界面

原生插件可以使用 admin.settingsSchema 来自动生成设置表单,这是最简单的方式:

admin: {
	settingsSchema: {
		apiKey: { type: "secret", label: "API 密钥" },
		enabled: { type: "boolean", label: "已启用", default: true },
		maxItems: { type: "number", label: "最大项数", min: 1, max: 1000, default: 100 },
	},
},

字段类型:stringnumberbooleanselectsecreturlemail。每种类型都接受 labeldescriptiondefault,以及特定于类型的额外属性,如 min/max/options。设置保存到沙箱插件使用的同一个每插件 KV 存储中——从任何地方使用 ctx.kv.get<T>("settings:<key>") 读取它们。

对于比 settingsSchema 提供的更丰富的设置界面,可以提供自定义 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: "保留期限(天)",
					description: "保留条目的天数。0 = 永久。",
					default: 90,
					min: 0,
					max: 365,
				},
			},
			pages: [{ path: "/history", label: "审计历史", icon: "history" }],
			widgets: [{ id: "recent-activity", title: "最近活动", 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 站点来测试原生插件:

  1. 创建一个安装了 EmDash 的测试站点。
  2. astro.config.mjs 中注册你的插件,直接从本地源路径导入。
  3. 运行开发服务器,通过创建、更新或删除内容来触发钩子。
  4. 检查控制台中的 ctx.log 输出,并通过 API 路由验证存储。

对于单元测试,模拟 PluginContext 接口并直接调用钩子处理器。

下一步