Hooks

本页内容

Hooks 允许插件响应事件运行代码。所有 hooks 都接收一个事件对象和插件上下文,并在插件定义时声明 — 运行时不存在动态注册。

本页面介绍沙箱(标准格式)插件。Hooks 在原生插件中的工作方式完全相同;唯一的区别是原生插件还可以注册 page:fragments,而沙箱插件不能。

Hook 签名

每个 hook 处理器接收两个参数:

async (event: EventType, ctx: PluginContext) => ReturnType;
  • event — 关于刚刚发生的事情的数据(正在保存的内容、上传的媒体、生命周期转换等)
  • ctx — 带有存储、KV、日志和能力控制的 API 的 PluginContext

Hook 配置

Hook 可以声明为简单的处理器或包装在配置对象中:

Simple

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

Full config

hooks: {
	"content:afterSave": {
		priority: 100,
		timeout: 5000,
		dependencies: ["audit-log"],
		errorPolicy: "continue",
		handler: async (event, ctx) => {
			ctx.log.info("Content saved");
		},
	},
},

配置选项

选项类型默认值描述
prioritynumber100执行顺序。较小的数字先执行。
timeoutnumber5000最大执行时间(毫秒)。
dependenciesstring[][]必须在此 hook 之前运行的插件 ID。
errorPolicy"abort" | "continue""abort"是否在错误时停止管道。
exclusivebooleanfalse只有一个插件可以是活动提供者。用于 email:delivercomment:moderate
handlerfunctionhook 处理器函数。必需。

生命周期 hooks

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

plugin:install

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

"plugin:install": async (_event, ctx) => {
	ctx.log.info("Installing plugin...");
	await ctx.kv.set("settings: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) {
		const result = await ctx.storage.items.query({ limit: 1000 });
		await ctx.storage.items.deleteMany(result.items.map((i) => i.id));
	}
},

Event: { deleteData: boolean }Returns: Promise<void>

内容 hooks

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

content:beforeSave

在保存内容之前运行。返回修改后的内容,或返回 void 以保持不变。抛出错误以取消。

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

	if (collection === "posts" && !content.title) {
		throw new Error("Posts require a title");
	}

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

	return content;
},

Event: { content, collection, isNew }Returns: 修改后的内容或 void

content:afterSave

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

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

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

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

content:beforeDelete

在删除内容之前运行。返回 false 以取消;truevoid 允许。

"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 }Returns: boolean | void

content:afterDelete

在成功删除内容后运行。

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

Event: { id, collection }Returns: Promise<void>

content:afterPublish

在内容从草稿升级到已发布后运行。需要 content:read 能力。

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

content:afterUnpublish

在内容从已发布恢复到草稿后运行。需要 content:read 能力。

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

媒体 hooks

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>

公共页面 hooks

这些允许插件为渲染的公共页面做出贡献。模板通过包含来自 emdash/ui<EmDashHead><EmDashBodyStart><EmDashBodyEnd> 组件来选择加入。

page:metadata

<head> 贡献类型化的元数据 — 元标签、OpenGraph 属性、允许列表的 <link> rels 和 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 };
	}
}

Returns: PageMetadataContribution | PageMetadataContribution[] | null

贡献类型:

Kind渲染去重键
meta<meta name="..." content="...">keyname
property<meta property="..." content="...">keyproperty
link<link rel="canonical|alternate" href="...">canonical: singleton; alternate: keyhreflang
jsonld<script type="application/ld+json">id(如果存在)

对于任何去重键,第一个贡献获胜。Link rel 限制为安全锁定的允许列表(canonicalalternateauthorlicensenlwebsite.standard.document);href 必须是 HTTP 或 HTTPS。

page:fragments

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

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

Hook 执行顺序

Hooks 按以下顺序执行:

  1. 具有较低 priority 值的 hooks 先执行。
  2. 对于相同的优先级,hooks 按插件注册顺序执行。
  3. 具有 dependencies 的 hooks 等待这些插件完成。
// 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 () => {},
}

错误处理

当 hook 抛出错误或超时时:

  • errorPolicy: "abort" — 整个管道停止,原始操作可能失败。
  • errorPolicy: "continue" — 错误被记录,剩余的 hooks 仍然运行。
"content:afterSave": {
	timeout: 5000,
	errorPolicy: "continue",
	handler: async (event, ctx) => {
		await ctx.http!.fetch("https://unreliable-api.com/notify");
	},
},

超时

Hooks 默认为 5000ms。为较慢的工作增加超时:

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

Hook 参考

Hook触发器返回独占
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
media:beforeUpload文件上传前修改后的文件信息或 void
media:afterUpload文件上传后void
cron计划任务触发void
email:beforeSend电子邮件发送前修改后的消息、falsevoid
email:deliver通过传输发送电子邮件void
email:afterSend电子邮件发送后void
comment:beforeCreate评论存储前修改后的事件、falsevoid
comment:moderate决定评论状态{ status, reason? }
comment:afterCreate评论存储后void
comment:afterModerate管理员更改评论状态void
page:metadata页面渲染贡献或 null
page:fragments页面渲染(仅原生)贡献或 null

有关完整的事件类型和处理器签名,请参阅 Hook 参考