Hook 参考

本页内容

Hook 允许插件在内容、媒体、邮件、评论和页面生命周期的特定节点拦截并修改 EmDash 行为。

Hook 概览

下表列出每个 Hook、触发条件、可修改的内容以及是否为独占 Hook:

Hook触发可修改独占
content:beforeSave内容保存前内容数据否
content:afterSave内容保存后无否
content:beforeDelete内容删除前可取消否
content:afterDelete内容删除后无否
content:beforePublish内容发布前可取消否
content:beforeSchedule内容定时发布前可取消否
content:beforeUnpublish内容取消发布前可取消否
content:afterPublish内容发布后无否
content:afterUnpublish内容取消发布后无否
content:afterRestore内容从回收站恢复后无否
content:afterSchedule内容已定时发布后无否
content:afterUnschedule内容已取消定时发布后无否
media:beforeUpload文件上传前文件元数据否
media:afterUpload文件上传后无否
cron定时任务触发无否
email:beforeSend邮件投递前邮件内容,可取消否
email:deliver通过传输层投递邮件无是
email:afterSend邮件成功投递后无否
comment:beforeCreate评论入库前评论,可取消否
comment:moderate决定评论审核状态状态是
comment:afterCreate评论入库后无否
comment:afterModerate管理员更改评论状态后无否
page:metadata渲染公开页面 <head>贡献标签否
page:fragments渲染公开页面正文注入脚本否
plugin:install插件首次安装时无否
plugin:activate插件启用时无否
plugin:deactivate插件禁用时无否
plugin:uninstall插件移除时无否

内容 Hook

content:beforeSave

能力: content:write

在内容写入数据库之前运行。可用于校验、转换或丰富内容。沙箱 Hook 通过返回版本 1 的 Hook 结果,并在其中包含 SAVE_REJECTED 错误及 1–500 字符的纯文本 reason,来拒绝保存。API 会响应 SAVE_REJECTED,管理后台会标识插件并以文本展示原因。空、过长、格式错误及未知的错误结果会导致保存失败,并返回通用的 CONTENT_HOOK_ERROR 响应。

在宿主进程中,可抛出从 emdash 导出的 ContentSaveRejectedError 来拒绝保存。任一种执行模式下抛出的其他任何异常都会导致保存失败,并返回不暴露异常消息的通用响应。

import { definePlugin } from "emdash";

export default definePlugin({
	id: "my-plugin",
	version: "1.0.0",
	hooks: {
		"content:beforeSave": async (event, ctx) => {
			const { content, collection, isNew } = event;

			// 添加时间戳
			if (isNew) {
				content.createdBy = "system";
			}
			content.modifiedAt = new Date().toISOString();

			// 返回修改后的 content
			return content;
		},
	},
});

事件

interface ActorInfo {
	readonly id: string;
	readonly role: number;
}

interface ContentHookEvent {
	content: Record<string, unknown>; // 内容数据
	collection: string; // 集合 slug
	isNew: boolean; // 创建时为 true,更新时为 false
	id?: string; // 更新时现有条目的 ID;创建时不存在
	actor?: ActorInfo; // 发起保存的已认证用户
}

更新时,content 仅包含提交的字段值。若 Hook 需要与已存储条目对比,请用 ctx.content.get(event.collection, event.id) 加载该条目。经认证的 REST、可视化编辑和 MCP 保存会包含 actor。没有认证用户的内部写入会省略该字段。

返回值

  • 返回修改后的 content 对象以应用更改
  • 返回沙箱 Hook 错误信封,以带长度限制的纯文本原因拒绝保存
  • 返回 void 表示原样通过

沙箱 Hook 返回以下完整信封以拒绝保存:

return {
	__emdashSandboxHookResult: true,
	version: 1,
	error: {
		code: "SAVE_REJECTED",
		reason: "保存前请填写摘要。",
	},
};

宿主会对 reason 做 trim,并接受 1 到 500 个字符。

content:afterSave

能力: content:read

在内容保存之后运行。可用于通知、缓存失效或外部同步等副作用。

hooks: {
  "content:afterSave": async (event, ctx) => {
    const { content, collection, isNew } = event;

    if (collection === "posts" && content.status === "published") {
      // 通知外部服务
      await ctx.http?.fetch("https://api.example.com/notify", {
        method: "POST",
        body: JSON.stringify({ postId: content.id }),
      });
    }
  },
}

事件

content:afterSave 接收 content、collection、isNew 以及可选的已认证 actor。content 是完整的已保存条目,数据库 ID 在 content.id,集合字段在 content.data 下。用于 content:beforeSave 更新的可选独立 id 在保存后不再出现。

返回值

无需返回值。

content:beforeDelete

能力: content:read

在内容删除之前运行。可用于校验删除或阻止删除。

hooks: {
  "content:beforeDelete": async (event, ctx) => {
    const { id, collection } = event;

    // 阻止删除受保护的内容
    const item = await ctx.content?.get(collection, id);
    if (item?.data.protected) {
      return false; // 取消删除
    }

    // 允许删除
    return true;
  },
}

事件

interface ContentDeleteEvent {
	id: string; // 条目 ID
	collection: string; // 集合 slug
	permanent?: false; // 原生插件会提供;沙箱运行时会省略
}

content:beforeDelete 仅在条目移入回收站时运行。原生插件会收到 permanent: false;沙箱运行时只发送 id 和 collection。不要在沙箱 Hook 内根据该字段分支。永久删除会跳过 content:beforeDelete,因此 Hook 无法阻止管理员永久删除已在回收站中的条目。

返回值

  • 返回 false 取消删除
  • 返回 true 或 void 允许删除

content:afterDelete

能力: content:read

在内容删除之后运行。可用于清理任务。

hooks: {
  "content:afterDelete": async (event, ctx) => {
    const { id, collection, permanent } = event;

    if (permanent) {
      await ctx.storage.relatedItems.delete(`${collection}:${id}`);
    }
  },
}

事件包含 id、collection 和 permanent。条目移入回收站时 permanent 为 false,永久删除时为 true。在删除仍可能被恢复的回收站条目所需的数据前,请先检查该字段。此 Hook 无返回值。

content:beforePublish

能力: hooks.content-policy:register

在内容发布之前运行。返回 void 允许发布,或返回 { cancel: true, reason } 拒绝发布。EmDash 接受 reason 中 1–500 个纯文本字符;显式取消时返回 PUBLISH_REJECTED。

事件包含当前的 content、collection 和 origin。经认证的 API、MCP 和可视化编辑器操作还会包含 actor 及与之匹配的 source。visual-editor 来源需要来自已认证工具栏渲染的、带签名的短期 action token。定时条目到期时会再次运行此 Hook。调度器拒绝会取消定时并在仪表盘列出受影响的条目及原因。成功的定时、发布或删除会清除该记录;管理员可关闭过期的记录。

content:beforeSchedule

能力: hooks.content-policy:register

在条目被赋予发布时间之前运行。它与 content:beforePublish 使用相同的决策信封和 origin 字段,并在事件中增加 scheduledAt;显式取消时返回 SCHEDULE_REJECTED。

不存在 content:beforeUnschedule Hook。管理员始终可以取消未来的发布计划。

content:beforeUnpublish

能力: hooks.content-policy:register

在已发布内容下线之前运行。它与 content:beforePublish 使用相同的决策信封和 origin 字段;显式取消时返回 UNPUBLISH_REJECTED。

hooks.content-policy:register 不隐含 content:read、content:write 或发布类操作。无效决策与意外的 abort 策略错误会安全失败(fail closed),且不暴露异常消息。

content:afterPublish

在条目成功发布之后运行,包括 EmDash 在计划时间自动发布的条目。可用于依赖已发布条目的工作,例如通知其他服务或刷新外部搜索索引。

hooks: {
  "content:afterPublish": async (event, ctx) => {
    ctx.log.info(`Published ${event.collection}/${event.content.id}`);
  },
}

此 Hook 需要 content:read 能力。EmDash 在发布响应之后运行它,因此其返回值无法修改条目或撤销发布。错误会被记录;若 errorPolicy 为 "abort",后续发布 Hook 不会运行。

content:afterUnpublish

在条目成功取消发布之后运行。可用于删除或更新外部系统中保存的内容副本。取消发布也会取消任何待执行的定时,且不会运行 content:afterUnschedule。

hooks: {
  "content:afterUnpublish": async (event, ctx) => {
    ctx.log.info(`Unpublished ${event.collection}/${event.content.id}`);
  },
}

此 Hook 与 content:afterPublish 具有相同的 content:read 能力要求、延迟执行、事件形状以及无返回值约定。

content:afterRestore

在回收站内容恢复之后运行。需要 content:read 能力。恢复的条目为草稿且无定时,与其移入回收站时的状态无关。移除定时不会额外运行 content:afterUnschedule。

hooks: {
  "content:afterRestore": async (event, ctx) => {
    ctx.log.info(`Restored ${event.collection}/${event.content.id}`);
  },
}

content:afterSchedule

在内容为未来发布完成定时之后运行。需要 content:read 能力。

hooks: {
  "content:afterSchedule": async (event, ctx) => {
    ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`);
  },
}

content:afterUnschedule

在已定时内容取消定时之后运行。需要 content:read 能力。

hooks: {
  "content:afterUnschedule": async (event, ctx) => {
    ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`);
  },
}

事件

interface ContentStateChangeEvent {
	content: Record<string, unknown>;
	collection: string;
}

content:afterPublish、content:afterUnpublish、content:afterRestore、content:afterSchedule 和 content:afterUnschedule 共享此事件形状。content 是状态变更后的完整条目,包含 id、slug 和状态;集合字段在 content.data 下。

返回值

无需返回值。

媒体 Hook

media:beforeUpload

能力: media:write

在文件上传之前运行。可用于校验、重命名或拒绝文件。

hooks: {
  "media:beforeUpload": async (event, ctx) => {
    const { file } = event;

    // 拒绝超过 10MB 的文件
    if (file.size > 10 * 1024 * 1024) {
      throw new Error("File too large");
    }

    // 重命名文件
    return {
      name: `${Date.now()}-${file.name}`,
      type: file.type,
      size: file.size,
    };
  },
}

事件

interface MediaUploadEvent {
	file: {
		name: string; // 原始文件名
		type: string; // MIME 类型
		size: number; // 字节大小
	};
}

返回值

  • 返回修改后的文件元数据以应用更改
  • 返回 void 表示原样通过
  • 抛出异常以拒绝上传

media:afterUpload

能力: media:read

在文件上传之后运行。可用于处理、缩略图或元数据提取。

hooks: {
  "media:afterUpload": async (event, ctx) => {
    const { media } = event;

    if (media.mimeType.startsWith("image/")) {
      // 存储图片元数据
      await ctx.kv.set(`media:${media.id}:analyzed`, {
        processedAt: new Date().toISOString(),
      });
    }
  },
}

事件

interface MediaAfterUploadEvent {
	media: {
		id: string;
		filename: string;
		mimeType: string;
		size: number | null;
		url: string;
		createdAt: string;
	};
}

返回值

无需返回值。

生命周期 Hook

生命周期 Hook 不需要注册类能力。

plugin:install

在插件首次安装时运行。可用于初始设置、创建 storage 集合或填充种子数据。

hooks: {
  "plugin:install": async (event, ctx) => {
    // 初始化默认设置
    await ctx.settings.set("enabled", true);
    await ctx.settings.set("threshold", 100);

    ctx.log.info("Plugin installed successfully");
  },
}

plugin:activate

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

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

plugin:deactivate

在插件禁用时运行。

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

plugin:install、plugin:activate 和 plugin:deactivate 接收空事件对象,且无返回值。

plugin:uninstall

在插件移除时运行。可用于清理。

hooks: {
  "plugin:uninstall": async (event, ctx) => {
    const { deleteData } = event;

    if (deleteData) {
      // 清理所有插件数据
      const items = await ctx.settings.list();
      for (const { key } of items) {
        await ctx.kv.delete(key);
      }
    }

    ctx.log.info("Plugin uninstalled");
  },
}

事件

interface UninstallEvent {
	deleteData: boolean; // 用户选择删除数据
}

卸载 Hook 无返回值。

定时任务 Hook

cron

能力: 无需

在定时任务执行时触发。使用 ctx.cron.schedule() 安排任务。Cron 表达式在每个主机上以 UTC 评估。

hooks: {
  "cron": async (event, ctx) => {
    if (event.name === "daily-sync") {
      const data = await ctx.http?.fetch("https://api.example.com/data");
      ctx.log.info("Sync complete");
    }
  },
}

事件

interface CronEvent {
	name: string;
	data?: Record<string, unknown>;
	scheduledAt: string;
}

Cron Hook 无返回值。

邮件 Hook

对于插件发送的邮件,Hook 按顺序运行:email:beforeSend,然后 email:deliver,然后 email:afterSend。系统认证邮件直接进入 email:deliver;不会经过 email:beforeSend 或 email:afterSend。

email:beforeSend

能力: hooks.email-events:register

在投递之前运行的中间件 Hook。可转换邮件或取消投递。

hooks: {
  "email:beforeSend": async (event, ctx) => {
    // 为所有邮件添加页脚
    return {
      ...event.message,
      text: event.message.text + "\n\n—Sent from My Site",
    };

    // 或返回 false 取消投递
  },
}

事件

interface EmailBeforeSendEvent {
	message: {
		to: string;
		cc?: string[];
		replyTo?: string;
		subject: string;
		text: string;
		html?: string;
	};
	source: string;
}

返回值

  • 返回修改后的 message 以进行转换
  • 返回 false 取消投递

email:deliver

能力: hooks.email-transport:register | 独占: 是

传输提供方。仅有一个插件可以投递邮件。负责通过邮件服务实际发送消息。

hooks: {
  "email:deliver": {
    exclusive: true,
    handler: async (event, ctx) => {
      await sendViaSES(event.message);
    },
  },
}

事件与返回值

interface EmailDeliverEvent {
	message: {
		to: string;
		cc?: string[];
		replyTo?: string;
		subject: string;
		text: string;
		html?: string;
	};
	source: string;
}

此 Hook 无返回值。source 对 EmDash 认证邮件为 "system",对插件发送的邮件为插件 ID。

插件可在 message 上设置 cc(额外收件人)和 replyTo。提供方应在存在时一并投递;若提供方静默丢弃 cc,这些收件人将收不到邮件。

email:afterSend

能力: hooks.email-events:register

成功投递后的 fire-and-forget Hook。错误会被记录但不会向上传播。

hooks: {
  "email:afterSend": async (event, ctx) => {
    await ctx.kv.set(`email:log:${Date.now()}`, {
      to: event.message.to,
      subject: event.message.subject,
    });
  },
}

事件与返回值

email:afterSend 接收与 email:deliver 相同的 message 和 source 字段。无返回值。

评论 Hook

评论 Hook 按顺序运行:comment:beforeCreate,然后 comment:moderate,然后 comment:afterCreate。当管理员或授权插件更改评论状态时,会单独运行 comment:afterModerate Hook。

评论入库后,Hook 接收以下记录形状:

interface StoredComment {
	id: string;
	collection: string;
	contentId: string;
	parentId: string | null;
	authorName: string;
	authorEmail: string;
	authorUserId: string | null;
	body: string;
	status: string;
	moderationMetadata: Record<string, unknown> | null;
	createdAt: string;
	updatedAt: string;
}

comment:beforeCreate

能力: users:read

在评论入库之前的中间件 Hook。可丰富、校验或拒绝评论。

hooks: {
  "comment:beforeCreate": async (event, ctx) => {
    // 拒绝包含链接的评论
    if (event.comment.body.includes("http")) {
      return false;
    }
  },
}

事件

interface CommentBeforeCreateEvent {
	comment: {
		collection: string;
		contentId: string;
		parentId: string | null;
		authorName: string;
		authorEmail: string;
		authorUserId: string | null;
		body: string;
		ipHash: string | null;
		userAgent: string | null;
	};
	metadata: Record<string, unknown>;
}

返回值

  • 返回修改后的事件以进行转换
  • 返回 false 拒绝
  • 返回 void 原样通过

comment:moderate

能力: users:read | 独占: 是

决定评论是 approved、pending 还是 spam。仅有一个审核提供方处于活跃状态。

hooks: {
  "comment:moderate": {
    exclusive: true,
    handler: async (event, ctx) => {
      const score = await checkSpam(event.comment);
      return {
        status: score > 0.8 ? "spam" : score > 0.5 ? "pending" : "approved",
        reason: `Spam score: ${score}`,
      };
    },
  },
}

事件

interface CommentModerateEvent {
	comment: { /* 与 beforeCreate 相同 */ };
	metadata: Record<string, unknown>;
	collectionSettings: {
		commentsEnabled: boolean;
		commentsModeration: "all" | "first_time" | "none";
		commentsClosedAfterDays: number;
		commentsAutoApproveUsers: boolean;
	};
	priorApprovedCount: number;
}

返回值

{ status: "approved" | "pending" | "spam"; reason?: string }

活跃审核器

EmDash 内置审核器会应用集合的评论设置。当恰好有一个插件提供 comment:moderate 时,EmDash 会选择该插件而非内置审核器并保存选择。当多个插件提供该 Hook 且尚未保存选择时,EmDash 不会选择任何一个,新评论将等待人工审核。已保存的选择(包括内置审核器)在所选审核器保持启用期间一直有效。

comment:afterCreate

能力: users:read

评论入库后的 fire-and-forget Hook。可用于通知。发送邮件还需要 email:send 能力以及已配置的 email:deliver 提供方;二者缺一则 ctx.email 为 undefined。

hooks: {
  "comment:afterCreate": async (event, ctx) => {
    const recipient = event.contentAuthor?.email;
    if (event.comment.status === "approved" && recipient && ctx.email) {
      await ctx.email.send({
        to: recipient,
        subject: `New comment on "${event.content.title}"`,
        text: `${event.comment.authorName} commented: ${event.comment.body}`,
      });
    }
  },
}

事件与返回值

interface CommentAfterCreateEvent {
	comment: StoredComment;
	metadata: Record<string, unknown>;
	content: { id: string; collection: string; slug: string; title?: string };
	contentAuthor?: { id: string; name: string | null; email: string };
}

此 Hook 无返回值。

comment:afterModerate

能力: users:read

在管理员或插件更改评论状态之后运行。成功的状态转换会运行 Hook 一次。Hook 错误会被记录,且不会撤销状态变更。

事件

interface CommentAfterModerateEvent {
	comment: StoredComment;
	previousStatus: string;
	newStatus: string;
	moderator: { id: string; name: string | null };
	origin?:
		| { source: "admin"; userId: string }
		| { source: "plugin"; pluginId: string };
}

此 Hook 无返回值。

页面 Hook

页面 Hook 在渲染公开页面时运行,允许插件注入元数据和脚本。

两个页面 Hook 都会接收当前公开页面上下文:

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

interface PageMetadataEvent { page: PublicPageContext }
interface PageFragmentEvent { page: PublicPageContext }

page:metadata

能力: 无需

向页面 <head> 贡献 meta 标签、Open Graph 属性、JSON-LD 结构化数据或 link 标签。

hooks: {
  "page:metadata": async (event, ctx) => {
    return [
      { kind: "meta", name: "generator", content: "EmDash" },
      { kind: "property", property: "og:site_name", content: event.page.siteName ?? "My Site" },
      { kind: "jsonld", graph: { "@type": "WebSite", name: event.page.siteName } },
    ];
  },
}

贡献类型

type PageMetadataContribution =
	| { kind: "meta"; name: string; content: string; key?: string }
	| { kind: "property"; property: string; content: string; key?: string }
	| {
			kind: "link";
			rel: "canonical" | "alternate" | "author" | "license" | "nlweb" | "site.standard.document";
			href: string;
			hreflang?: string;
			key?: string;
	  }
	| {
			kind: "jsonld";
			id?: string;
			graph: Record<string, unknown> | Array<Record<string, unknown>>;
	  };

key 字段用于去重贡献——仅保留给定 key 的最后一次贡献。

返回单个贡献、贡献数组,或在插件无需添加任何内容时返回 null。

page:fragments

能力: hooks.page-fragments:register

向页面注入脚本或 HTML。仅原生插件可用。

hooks: {
  "page:fragments": async (event, ctx) => {
    return [
      {
        kind: "external-script",
        placement: "body:end",
        src: "https://analytics.example.com/script.js",
        async: true,
      },
      {
        kind: "inline-script",
        placement: "head",
        code: `window.siteId = "abc123";`,
      },
    ];
  },
}

贡献类型

type PageFragmentContribution =
	| {
			kind: "external-script";
			placement: "head" | "body:start" | "body:end";
			src: string;
			async?: boolean;
			defer?: boolean;
			attributes?: Record<string, string>;
			key?: string;
		}
	| {
			kind: "inline-script";
			placement: "head" | "body:start" | "body:end";
			code: string;
			attributes?: Record<string, string>;
			key?: string;
		}
	| {
			kind: "html";
			placement: "head" | "body:start" | "body:end";
			html: string;
			key?: string;
		};

返回单个 fragment 贡献、贡献数组,或在插件无需添加任何内容时返回 null。

Hook 配置

Hook 可接受处理函数或配置对象:

hooks: {
  // 简单处理函数
  "content:afterSave": async (event, ctx) => { ... },

  // 带配置
  "content:beforeSave": {
    priority: 50,        // 数值越小越先执行(默认 100)
    timeout: 10000,      // 最大执行时间(毫秒,默认 5000)
    dependencies: [],    // 在这些插件之后运行
    errorPolicy: "abort", // "continue" 或 "abort"(默认)
    handler: async (event, ctx) => { ... },
  },
}

配置选项

选项类型默认值说明
prioritynumber100执行顺序(数值越小越先执行)
timeoutnumber5000最大执行时间(毫秒)
dependenciesstring[][]必须先执行的插件 ID
errorPolicystring"abort""continue" 表示忽略错误
exclusivebooleanfalse仅一个插件可作为活跃提供方(用于 email:deliver、comment:moderate 等提供方模式 Hook)

插件上下文

所有 Hook 都会收到可访问插件 API 的上下文对象:

interface PluginContext {
	plugin: { id: string; version: string };
	storage: PluginStorage;
	kv: KVAccess;
	content?: ContentAccess;
	media?: MediaAccess;
	http?: HttpAccess;
	log: LogAccess;
	site: { name: string; url: string; locale: string };
	url(path: string): string;
	users?: UserAccess;
	cron?: CronAccess;
	email?: EmailAccess;
}

各上下文 API 所需能力请参阅 插件能力。

错误处理

Hook 中的错误会被记录,并根据 errorPolicy 处理:

  • "abort"(默认)— 停止执行,并在适用时回滚事务
  • "continue" — 记录错误并继续执行下一个 Hook
hooks: {
  "content:beforeSave": {
    errorPolicy: "continue", // 失败时不阻止保存
    handler: async (event, ctx) => {
      try {
        await ctx.http?.fetch("https://api.example.com/validate");
      } catch (error) {
        ctx.log.warn("Validation service unavailable", error);
      }
    },
  },
}

执行顺序

Hook 按以下顺序运行:

  1. 按 priority 升序排序
  2. 带有 dependencies 的插件在其依赖之后运行
  3. 相同 priority 内顺序确定但未指定
// 最先运行(priority 10)
{ priority: 10, handler: ... }

// 其次运行(priority 50)
{ priority: 50, handler: ... }

// 最后运行(默认 priority 100)
{ handler: ... }