钩子

本页内容

钩子让插件能够响应事件运行代码。所有钩子都会接收一个事件对象和插件上下文,并且在插件定义时声明——运行时不支持动态注册。

本页介绍沙箱插件。原生插件使用相同的钩子名称和事件类型,但使用进程内钩子管线,并且还可以额外注册 page:fragments。沙箱中的保存拒绝行为以及隔离运行器的失败行为见下文。

钩子签名

每个钩子处理函数都接收两个参数:

async (event, ctx) => ReturnType;
  • event — 关于刚刚发生之事的数据(正在保存的内容、上传的媒体、生命周期转换等)
  • ctx — PluginContext,包含存储、KV、日志,以及受 capability 门控的 API

将定义赋值给类型为 SandboxedPlugin 的常量后,event 会根据钩子名称推断(完整的规范事件类型),ctx 则推断为 PluginContext,因此处理函数无需参数注解。请将该常量作为默认导出。如果要在辅助函数中按名称引用事件类型,请从 emdash/plugin 导入。

钩子配置

钩子可以声明为单独的处理函数,也可以包装在配置对象中。除非插件还支持有意的进程内执行并需要下面描述的元数据,否则优先使用单独处理函数的形式。

简单

hooks: {
	"content:afterSave": async (event, ctx) => {
		ctx.log.info("Content saved");
	},
},

完整配置

hooks: {
	"content:afterSave": {
		priority: 100,
		timeout: 5000,
		handler: async (event, ctx) => {
			ctx.log.info("Content saved");
		},
	},
},

配置选项

选项类型默认值说明
prioritynumber100执行顺序。数字越小越先执行。
timeoutnumber5000最长执行时间,单位为毫秒。
exclusivebooleanfalse只能有一个插件作为活动提供者。用于 email:deliver 和 comment:moderate。
handlerfunction—钩子处理函数。必填。

所需 capability

有几个钩子会暴露受保护的数据,或者可以改变某项操作。只有当清单声明了对应的 capability 时,EmDash 才会注册它们:

钩子Capability原因
content:beforeSavecontent:write该钩子可以替换提交的内容。
content:beforePublish、content:beforeSchedule、content:beforeUnpublishhooks.content-policy:register这些钩子可以拒绝发布状态的变更。
其他 content:* 钩子content:read它们的事件会暴露内容或标识某个条目。
media:beforeUploadmedia:write该钩子可以替换上传元数据或中止上传。
media:afterUploadmedia:read它的事件会暴露已存储的媒体项。
email:beforeSend、email:afterSendhooks.email-events:register这些钩子检查邮件生命周期事件。
email:deliverhooks.email-transport:register该钩子成为邮件传输提供者。
所有 comment:* 钩子users:read评论事件可能包含作者联系信息和请求元数据。
page:fragmentshooks.page-fragments:register该钩子注入第一方页面内容,且仅限原生。

生命周期钩子、cron 和 page:metadata 没有注册 capability。即使钩子只读取其事件、不调用对应的 ctx API,也要声明所列的 capability。该声明能给运营者准确的同意提示,对 ctx API 进行门控,并且在插件进程内运行时是必需的。Capabilities 与安全解释了运行时的效果。

生命周期钩子

在插件安装、激活、停用和移除期间运行。

plugin:install

插件首次添加到站点时运行一次。

此示例假设清单声明了一个 items 存储 collection:

"plugin:install": async (_event, ctx) => {
	ctx.log.info("Installing plugin...");
	await ctx.settings.set("enabled", true);
	await ctx.storage.items.put("default", { name: "Default Item" });
},

事件: {} — 返回值: Promise<void>

plugin:activate

插件启用时运行(安装后或重新启用时)。

"plugin:activate": async (_event, ctx) => {
	ctx.log.info("Plugin activated");
},

事件: {} — 返回值: Promise<void>

plugin:deactivate

插件被停用(但未移除)时运行。

"plugin:deactivate": async (_event, ctx) => {
	ctx.log.info("Plugin deactivated");
},

事件: {} — 返回值: Promise<void>

plugin:uninstall

插件从站点中移除时运行。

"plugin:uninstall": async (event, ctx) => {
	ctx.log.info("Uninstalling plugin...");
	if (event.deleteData) {
		while (true) {
			const result = await ctx.storage.items.query({ limit: 100 });
			if (result.items.length === 0) break;
			await ctx.storage.items.deleteMany(result.items.map((item) => item.id));
		}
	}
},

事件: { deleteData: boolean } — 返回值: Promise<void>

内容钩子

在站点内容的创建、更新和删除操作期间运行。

content:beforeSave

在内容保存之前运行。返回修改后的内容、沙箱钩子错误结果,或返回 void 以保持内容不变。

要从沙箱中拒绝保存,请返回带有 SAVE_REJECTED 错误的带版本钩子结果。将 reason 设置为 1 到 500 个字符的纯文本。EmDash 会标识出该插件,并向编辑者显示原因。空的、过长的、格式错误的以及未知的错误结果,都会以通用的钩子错误使保存失败。

"content:beforeSave": async (event, ctx) => {
	const { content } = event;
	if (typeof content.title !== "string" || content.title.trim() === "") {
		return {
			__emdashSandboxHookResult: true,
			version: 1,
			error: {
				code: "SAVE_REJECTED",
				reason: "Add a title before saving.",
			},
		};
	}

	content.title = content.title.trim();

	return content;
},

不要在 reason 中放入 HTML。管理后台会把该值作为文本渲染。

在宿主进程中,则应抛出 ContentSaveRejectedError(从 emdash 导出)。API 会返回带有你的消息的 SAVE_REJECTED。两种执行模式中的任何其他异常,都会以通用的 CONTENT_HOOK_ERROR 响应使保存失败。

事件: { content, collection, isNew, id, actor } — 返回值: 修改后的内容、沙箱钩子错误结果或 void。更新时,id 是现有项的 ID,content 仅包含提交的字段值;用 ctx.content.get(event.collection, event.id) 加载已存储项。条目的 slug 不属于 content,钩子返回的 slug 键会因为是未知字段而校验失败。经认证的 REST、可视化编辑和 MCP 保存包含 actor.id 和数字 actor.role。没有认证用户的内部写入会省略 actor。

content:afterSave

在内容成功保存之后运行。适用于通知、日志记录或外部同步等副作用。

"content:afterSave": async (event, ctx) => {
	const contentId = String(event.content.id);
	ctx.log.info(`${event.isNew ? "Created" : "Updated"} ${event.collection}/${contentId}`, {
		actorId: event.actor?.id,
	});

	if (ctx.http) {
		await ctx.http.fetch("https://api.example.com/webhook", {
			method: "POST",
			body: JSON.stringify({ event: "content:save", id: contentId }),
		});
	}
},

事件: { content, collection, isNew, actor } — 返回值: Promise<void>。经认证的保存包含与 content:beforeSave 相同的可选 actor 快照。

content:beforeDelete

在内容删除之前运行。返回 false 取消删除;返回 true 或 void 则允许删除。

"content:beforeDelete": async (event, ctx) => {
	if (event.collection === "pages" && event.id === "home") {
		ctx.log.warn("Cannot delete home page");
		return false;
	}
	return true;
},

事件: { id, collection, permanent: false } — 返回值: boolean | void

该钩子在条目被移入回收站之前运行。从回收站中永久移除条目时,不会再次运行 content:beforeDelete。

content:afterDelete

在内容成功删除之后运行。

"content:afterDelete": async (event, ctx) => {
	await ctx.storage.cache.delete(`${event.collection}:${event.id}`);
},

事件: { id, collection, permanent } — 返回值: Promise<void>。当条目移入回收站时 permanent 为 false,永久删除时为 true。

声明 hooks.content-policy:register,即可在不获得内容读取、写入或发布操作权限的情况下,检查并拒绝发布、定时发布或取消发布。

返回 void 表示允许该操作,返回 { cancel: true, reason } 表示拒绝。原因必须是 1–500 个纯文本字符。无效的决定和意外错误默认会中止,且不会暴露异常。明确的拒绝会返回 PUBLISH_REJECTED、SCHEDULE_REJECTED 或 UNPUBLISH_REJECTED。

这三个事件都包含 { content, collection, origin, actor? }。origin.source 为 api、mcp、visual-editor、plugin、scheduler 或 system;来自插件的 origin 还包含 pluginId。经认证的人工操作包含 actor.id、数字 actor.role 和对应的 actor.source。EmDash 仅接受来自已认证工具栏渲染中嵌入的、经签名且有效期很短的操作令牌的 visual-editor origin;普通的 API 请求无法自行选择其 origin。

发布和定时发布事件会在 content.data 中暴露有效的草稿,并在 content.slug 中暴露暂存的 slug。取消发布事件会暴露该操作将要移除的当前线上内容。

content:beforePublish

下面的钩子要求内容在上线之前带有审批标记:

"content:beforePublish": async (event) => {
	const data = event.content.data;
	const approvalStatus =
		typeof data === "object" && data !== null && "approval_status" in data
			? data.approval_status
			: undefined;
	if (approvalStatus !== "approved") {
		return { cancel: true, reason: "Approve this entry before publishing." };
	}
},

该钩子会在手动、MCP、插件、系统和定时发布之前运行。定时内容在发布时间到来时会再次检查。调度器的拒绝会取消该条目的定时发布,保存可公开展示的原因,并在仪表板上列出受影响的条目,而不是在每个调度周期都重复同一个永久性拒绝。一次成功的定时发布、发布或删除会清除该记录。当条目或策略插件不再可用时,管理员可以关闭过期的记录。

content:beforeSchedule

在条目获得发布时间之前运行。事件还包含 scheduledAt。

没有 content:beforeUnschedule 钩子。管理员始终可以取消未来的发布。

content:beforeUnpublish

在线上内容被移除之前运行。

content:afterPublish

在内容从草稿提升为线上之后运行。需要 content:read capability。

事件: { content, collection } — 返回值: Promise<void>

content:afterUnpublish

在内容从线上恢复为草稿之后运行。需要 content:read capability。

事件: { content, collection } — 返回值: Promise<void>

content:afterRestore

在回收站中的内容被恢复之后运行。需要 content:read capability。

事件: { content, collection } — 返回值: Promise<void>

content:afterSchedule

在内容被安排定时发布之后运行。需要 content:read capability。

事件: { content, collection } — 返回值: Promise<void>

content:afterUnschedule

在定时内容被取消定时之后运行。需要 content:read capability。

事件: { content, collection } — 返回值: Promise<void>

媒体钩子

media:beforeUpload

在文件上传之前运行。返回修改后的文件元数据,或抛出异常以取消上传。

"media:beforeUpload": async (event, ctx) => {
	if (!event.file.type.startsWith("image/")) {
		throw new Error("Only images are allowed");
	}
	if (event.file.size > 10 * 1024 * 1024) {
		throw new Error("File too large");
	}
	return { ...event.file, name: `${Date.now()}-${event.file.name}` };
},

事件: { file: { name, type, size } } — 返回值: 修改后的文件或 void

media:afterUpload

在文件成功上传之后运行。

事件: { media: { id, filename, mimeType, size, url, createdAt } } — 返回值: Promise<void>

公开页面钩子

这些钩子让插件能够为渲染出的公开页面贡献内容。模板通过引入 emdash/ui 中的 <EmDashHead>、<EmDashBodyStart> 和 <EmDashBodyEnd> 组件来选择启用。

page:metadata

向 <head> 贡献带类型的元数据——meta 标签、OpenGraph 属性、允许列表中的 <link> rel 以及 JSON-LD。**沙箱插件和原生插件均可使用。**核心会校验、去重并渲染这些贡献;插件返回结构化数据,而绝不返回原始 HTML。

"page:metadata": async (event, ctx) => {
	if (event.page.kind !== "content") return null;

	return {
		kind: "jsonld",
		id: `schema:${event.page.content?.collection}:${event.page.content?.id}`,
		graph: {
			"@context": "https://schema.org",
			"@type": "BlogPosting",
			headline: event.page.pageTitle ?? event.page.title,
			description: event.page.description,
		},
	};
},

事件:

{
	page: {
		url: string;
		path: string;
		locale: string | null;
		kind: "content" | "custom";
		pageType: string;
		title: string | null;
		pageTitle?: string | null;
		description: string | null;
		canonical: string | null;
		image: string | null;
		content?: { collection: string; id: string; slug: string | null };
		seo?: {
			ogTitle?: string | null;
			ogDescription?: string | null;
			ogImage?: string | null;
			robots?: string | null;
		};
		articleMeta?: {
			publishedTime?: string | null;
			modifiedTime?: string | null;
			author?: string | null;
		};
		siteName?: string;
		breadcrumbs?: Array<{ name: string; url: string }>;
		siteUrl?: string;
	}
}

返回值: PageMetadataContribution | PageMetadataContribution[] | null

贡献类型:

类型渲染结果去重键
meta<meta name="..." content="...">key 或 name
property<meta property="..." content="...">key 或 property
link<link rel="<allowed value>" href="...">canonical:单例;alternate:key 或 hreflang
jsonld<script type="application/ld+json">id(若存在)

对于任何去重键,先到者优先。<EmDashHead> 按“插件 → 站点设置 → 模板提供的基础元数据”的顺序组合各项贡献,因此插件的贡献会覆盖其下的所有内容。在内容页面上,条目的 SEO 面板取值会在生成基础元数据之前并入页面上下文——它们会取代模板提供的字段(也是你的钩子在页面上下文中看到的值),而插件的贡献仍然通过先到者优先的去重规则胜出。链接的 rel 限制在一个受安全锁定的允许列表中(canonical、alternate、author、license、nlweb、site.standard.document);href 必须是 HTTP 或 HTTPS。

page:fragments

向页面插入点贡献原始 HTML、脚本或样式表。仅限原生插件。

沙箱插件不能使用此钩子,因为其输出会作为第一方代码在访问者浏览器中运行,超出任何沙箱边界。对于沙箱安全的页面贡献,请使用 page:metadata。若需要此表面,请参见原生插件:页面片段。

钩子执行顺序

当沙箱格式的插件在进程内运行时,钩子使用共享的钩子管线:

  1. priority 值较低的钩子先运行。
  2. 优先级相同时,钩子按插件注册顺序运行。
  3. 带有 dependencies 的钩子会等待这些插件完成。
// Plugin A
"content:afterSave": { priority: 50, handler: async () => {} }

// Plugin B
"content:afterSave": { priority: 100, handler: async () => {} }

// Plugin C
"content:afterSave": {
	priority: 200,
	dependencies: ["plugin-a"],   // waits for A even if its priority would normally be later
	handler: async () => {},
}

隔离的沙箱运行器按加载顺序调用处于活动状态的沙箱插件。请让钩子保持相互独立:不要要求某个沙箱插件必须先于另一个运行。

错误处理

沙箱钩子的失败取决于钩子运行的时机:

  • content:beforeSave 抛出的错误会以 CONTENT_HOOK_ERROR 使保存失败。如果希望编辑者看到具体的校验原因,请返回有文档记录的 SAVE_REJECTED 封装。
  • 从 content:beforeDelete 返回 false 会阻止移入回收站。如果该钩子抛出异常,EmDash 会记录错误并继续删除。
  • 内容的 after 钩子在操作成功之后运行。它们的错误会被记录,且无法回滚该操作。
  • 生命周期、媒体、邮件和评论钩子遵循其发起操作的契约。在依赖失败行为之前,请用 Hook 参考检查特定的返回值。

进程内插件可以在完整配置形式中使用 errorPolicy: "abort" 或 "continue"。该设置并不是适用于隔离沙箱插件的可移植恢复控制手段。

超时

进程内钩子管线默认为 5,000 毫秒,并且在完整配置形式中接受更长的 timeout:

"content:afterSave": {
	timeout: 30000,
	handler: async (event, ctx) => {
		// Long-running operation
	},
},

钩子参考

钩子触发时机返回值独占
plugin:install插件首次安装void否
plugin:activate插件启用void否
plugin:deactivate插件停用void否
plugin:uninstall插件移除void否
content:beforeSave内容保存之前修改后的内容、拒绝封装或 void否
content:afterSave内容保存之后void否
content:beforeDelete内容移入回收站之前false 表示取消,否则允许否
content:afterDelete移入回收站或永久删除之后void否
content:afterPublish内容发布之后void否
content:afterUnpublish内容取消发布之后void否
content:afterRestore内容恢复之后void否
content:afterSchedule内容定时发布之后void否
content:afterUnschedule内容取消定时之后void否
media:beforeUpload文件上传之前修改后的文件信息或 void否
media:afterUpload文件上传之后void否
cron定时任务触发void否
email:beforeSend邮件投递之前修改后的邮件、false 或 void否
email:deliver通过传输通道投递邮件void是
email:afterSend邮件投递之后void否
comment:beforeCreate评论存储之前修改后的事件、false 或 void否
comment:moderate决定评论状态{ status, reason? }是
comment:afterCreate评论存储之后void否
comment:afterModerate管理员更改评论状态void否
byline:afterSave署名保存之后void否
byline:afterDelete署名删除之后void否
page:metadata页面渲染贡献内容或 null否
page:fragments页面渲染(仅限原生)贡献内容或 null否

完整的事件类型和处理函数签名,请参见 Hook 参考。