钩子

本页内容

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

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

钩子签名

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

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

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

钩子配置

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

Simple

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

Full config

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

配置选项

OptionTypeDefaultDescription
prioritynumber100执行顺序。数字越小越先运行。
timeoutnumber5000最大执行时间(毫秒)。
exclusivebooleanfalse只能有一个插件作为活动提供方。用于 email:deliver 和 comment:moderate。
handlerfunction—钩子处理函数。必需。

所需 capability

若干钩子会暴露受保护数据或可更改操作。仅当清单声明匹配的 capability 时,EmDash 才会注册它们:

HooksCapabilityReason
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 存储集合:

"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" });
},

Event: {} — Returns: Promise<void>

plugin:activate

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

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

Event: {} — Returns: Promise<void>

plugin:deactivate

在插件禁用时运行(但未移除)。

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

Event: {} — Returns: 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));
		}
	}
},

Event: { deleteData: boolean } — Returns: 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.",
			},
		};
	}

	if (typeof content.slug === "string") {
		content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
	}

	return content;
},

不要在 reason 中放入 HTML。管理端会将值渲染为文本。

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

Event: { content, collection, isNew, id, actor } — Returns: 修改后的内容、沙箱钩子错误结果或 void。更新时,id 是现有项的 ID,content 仅包含提交的字段值;用 ctx.content.get(event.collection, event.id) 加载已存储项。经认证的 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 }),
		});
	}
},

Event: { content, collection, isNew, actor } — Returns: 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;
},

Event: { id, collection, permanent: false } — Returns: boolean | void

此钩子在条目移入回收站之前运行。从回收站永久删除条目不会再次运行 content:beforeDelete。

content:afterDelete

在内容成功删除后运行。

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

Event: { id, collection, permanent } — Returns: 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;插件来源还包含 pluginId。经认证的人类操作包含 actor.id、数字 actor.role 以及匹配的 actor.source。EmDash 仅接受来自嵌入在经认证工具栏渲染中的已签名短期操作令牌的 visual-editor 来源;普通 API 请求无法选择其来源。

发布和定时事件在 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。

Event: { content, collection } — Returns: Promise<void>

content:afterUnpublish

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

Event: { content, collection } — Returns: Promise<void>

content:afterRestore

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

Event: { content, collection } — Returns: Promise<void>

content:afterSchedule

在内容被安排为未来发布之后运行。需要 content:read capability。

Event: { content, collection } — Returns: Promise<void>

content:afterUnschedule

在取消已定时内容的计划之后运行。需要 content:read capability。

Event: { content, collection } — Returns: 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}` };
},

Event: { file: { name, type, size } } — Returns: 修改后的文件或 void

media:afterUpload

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

Event: { media: { id, filename, mimeType, size, url, createdAt } } — Returns: 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,
		},
	};
},

Event:

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

Returns: PageMetadataContribution | PageMetadataContribution[] | null

Contribution kinds:

KindRendersDedupe key
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 面板值会在生成基础元数据之前折叠到页面上下文中——它们会替换模板提供的字段(也是钩子在页面上下文中看到的内容),而插件贡献仍通过 first-wins 去重获胜。链接 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 钩子在操作成功后运行。其错误会被记录,且无法回滚操作。
  • 生命周期、媒体、邮件和评论钩子遵循其发起操作的契约。在依赖失败行为之前,请用钩子参考检查特定返回值。

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

超时

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

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

钩子参考

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

完整事件类型与处理函数签名请参见钩子参考。