钩子让插件能够响应事件运行代码。所有钩子都会接收一个事件对象和插件上下文,并且在插件定义时声明——运行时不支持动态注册。
本页介绍沙箱插件。原生插件使用相同的钩子名称和事件类型,但使用进程内钩子管线,并且还可以额外注册 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");
},
},
}, 配置选项
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
priority | number | 100 | 执行顺序。数字越小越先执行。 |
timeout | number | 5000 | 最长执行时间,单位为毫秒。 |
exclusive | boolean | false | 只能有一个插件作为活动提供者。用于 email:deliver 和 comment:moderate。 |
handler | function | — | 钩子处理函数。必填。 |
所需 capability
有几个钩子会暴露受保护的数据,或者可以改变某项操作。只有当清单声明了对应的 capability 时,EmDash 才会注册它们:
| 钩子 | Capability | 原因 |
|---|---|---|
content:beforeSave | content:write | 该钩子可以替换提交的内容。 |
content:beforePublish、content:beforeSchedule、content:beforeUnpublish | hooks.content-policy:register | 这些钩子可以拒绝发布状态的变更。 |
其他 content:* 钩子 | content:read | 它们的事件会暴露内容或标识某个条目。 |
media:beforeUpload | media:write | 该钩子可以替换上传元数据或中止上传。 |
media:afterUpload | media:read | 它的事件会暴露已存储的媒体项。 |
email:beforeSend、email:afterSend | hooks.email-events:register | 这些钩子检查邮件生命周期事件。 |
email:deliver | hooks.email-transport:register | 该钩子成为邮件传输提供者。 |
所有 comment:* 钩子 | users:read | 评论事件可能包含作者联系信息和请求元数据。 |
page:fragments | hooks.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。若需要此表面,请参见原生插件:页面片段。
钩子执行顺序
当沙箱格式的插件在进程内运行时,钩子使用共享的钩子管线:
priority值较低的钩子先运行。- 优先级相同时,钩子按插件注册顺序运行。
- 带有
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 参考。