Hooks 讓外掛在事件發生時執行程式碼。所有 hooks 都會收到事件物件與外掛上下文。Hooks 在外掛定義時宣告,而非在 runtime 動態註冊。
Hook 簽名
每個 hook 處理器接收兩個引數:
async (event: EventType, ctx: PluginContext) => ReturnType;
event— 與事件相關的資料(正在儲存的內容、已上傳的媒體等)ctx— 包含 storage、KV、logging 與受 capability 約束的 API 的外掛上下文
Hook 設定
Hooks 可宣告為簡單 handler,或帶完整設定:
簡單
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved");
}
} 完整設定
hooks: {
"content:afterSave": {
priority: 100,
timeout: 5000,
dependencies: ["audit-log"],
errorPolicy: "continue",
handler: async (event, ctx) => {
ctx.log.info("Content saved");
}
}
} 設定選項
| 選項 | 類型 | 預設值 | 說明 |
|---|---|---|---|
priority | number | 100 | 執行順序。數值越小越先執行。 |
timeout | number | 5000 | 最長執行時間(毫秒)。 |
dependencies | string[] | [] | 必須先執行完畢的外掛 ID。 |
errorPolicy | "abort" | "continue" | "abort" | 出錯時是否停止整條管線。 |
exclusive | boolean | false | 僅一個外掛可為活躍提供者。用於 email:deliver 與 comment:moderate。 |
handler | function | — | Hook 處理函式。必填。 |
生命週期 Hooks
生命週期 Hooks 在外掛安裝、啟用與停用時執行。
plugin:install
在外掛首次加入網站時執行一次。
"plugin:install": async (_event, ctx) => {
ctx.log.info("Installing plugin...");
// Seed default data
await ctx.kv.set("settings: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");
// Release resources, pause background work
}
事件: {}
回傳值: Promise<void>
plugin:uninstall
在外掛從網站移除時執行。
"plugin:uninstall": async (event, ctx) => {
ctx.log.info("Uninstalling plugin...");
if (event.deleteData) {
// User opted to delete plugin data
const result = await ctx.storage.items!.query({ limit: 1000 });
await ctx.storage.items!.deleteMany(result.items.map(i => i.id));
}
}
事件: { deleteData: boolean }
回傳值: Promise<void>
內容 Hooks
內容 Hooks 在建立、更新與刪除操作時執行。
content:beforeSave
在內容儲存前執行。回傳修改後的內容,或 void 表示不變。拋出錯誤可取消儲存。
"content:beforeSave": async (event, ctx) => {
const { content, collection, isNew } = event;
// Validate
if (collection === "posts" && !content.title) {
throw new Error("Posts require a title");
}
// Transform
if (content.slug) {
content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
}
return content;
}
事件:
{
content: Record<string, unknown>; // Content data being saved
collection: string; // Collection name
isNew: boolean; // True if creating, false if updating
}
回傳值: Promise<Record<string, unknown> | void>
content:afterSave
在內容成功儲存後執行。用於副作用,如通知、logging 或與外部系統同步。
"content:afterSave": async (event, ctx) => {
const { content, collection, isNew } = event;
ctx.log.info(`${isNew ? "Created" : "Updated"} ${collection}/${content.id}`);
// Trigger external sync
if (ctx.http) {
await ctx.http.fetch("https://api.example.com/webhook", {
method: "POST",
body: JSON.stringify({ event: "content:save", id: content.id })
});
}
}
事件:
{
content: Record<string, unknown>; // Saved content (includes id, timestamps)
collection: string;
isNew: boolean;
}
回傳值: Promise<void>
content:beforeDelete
在內容刪除前執行。回傳 false 取消刪除,回傳 true 或 void 允許刪除。
"content:beforeDelete": async (event, ctx) => {
const { id, collection } = event;
// Prevent deletion of protected content
if (collection === "pages" && id === "home") {
ctx.log.warn("Cannot delete home page");
return false;
}
return true;
}
事件:
{
id: string; // Content ID being deleted
collection: string;
}
回傳值: Promise<boolean | void>
content:afterDelete
在內容成功刪除後執行。
"content:afterDelete": async (event, ctx) => {
const { id, collection } = event;
ctx.log.info(`Deleted ${collection}/${id}`);
// Clean up related plugin data
await ctx.storage.cache!.delete(`${collection}:${id}`);
}
事件:
{
id: string;
collection: string;
}
回傳值: Promise<void>
content:afterPublish
在內容發布(從草稿提升為上線)後執行。用於快取失效、通知或與外部系統同步等副作用。
需要 read:content capability。
"content:afterPublish": async (event, ctx) => {
const { content, collection } = event;
ctx.log.info(`Published ${collection}/${content.id}`);
// Notify external system
if (ctx.http) {
await ctx.http.fetch("https://api.example.com/webhook", {
method: "POST",
body: JSON.stringify({ event: "content:publish", id: content.id })
});
}
}
事件:
{
content: Record<string, unknown>; // Published content (includes id, timestamps)
collection: string;
}
回傳值: Promise<void>
content:afterUnpublish
在內容取消發布(從上線恢復為草稿)後執行。用於快取失效或通知外部系統等副作用。
需要 read:content capability。
"content:afterUnpublish": async (event, ctx) => {
const { content, collection } = event;
ctx.log.info(`Unpublished ${collection}/${content.id}`);
}
事件:
{
content: Record<string, unknown>; // Unpublished content
collection: string;
}
回傳值: Promise<void>
媒體 Hooks
媒體 Hooks 在檔案上傳時執行。
media:beforeUpload
在檔案上傳前執行。回傳修改後的檔案資訊,或 void 表示不變。拋出錯誤可取消上傳。
"media:beforeUpload": async (event, ctx) => {
const { file } = event;
// Validate file type
if (!file.type.startsWith("image/")) {
throw new Error("Only images are allowed");
}
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
throw new Error("File too large");
}
// Rename file
return {
...file,
name: `${Date.now()}-${file.name}`
};
}
事件:
{
file: {
name: string; // Original filename
type: string; // MIME type
size: number; // Size in bytes
}
}
回傳值: Promise<{ name: string; type: string; size: number } | void>
media:afterUpload
在檔案成功上傳後執行。
"media:afterUpload": async (event, ctx) => {
const { media } = event;
ctx.log.info(`Uploaded ${media.filename}`, {
id: media.id,
size: media.size,
mimeType: media.mimeType
});
}
事件:
{
media: {
id: string;
filename: string;
mimeType: string;
size: number | null;
url: string;
createdAt: string;
}
}
回傳值: Promise<void>
Hook 執行順序
Hooks 依下列順序執行:
priority數值較小的先執行- 優先順序相同時,依外掛註冊順序執行
- 帶有
dependencies的 hooks 會等待所列外掛先完成
// Plugin A
"content:afterSave": {
priority: 50, // Runs first
handler: async () => {}
}
// Plugin B
"content:afterSave": {
priority: 100, // Runs second (default priority)
handler: async () => {}
}
// Plugin C
"content:afterSave": {
priority: 200,
dependencies: ["plugin-a"], // Runs after A, even if priority was lower
handler: async () => {}
}
錯誤處理
當 hook 拋出錯誤或逾時時:
errorPolicy: "abort"— 整條管線停止。原始操作可能失敗。errorPolicy: "continue"— 記錄錯誤,其餘 hooks 仍會繼續執行。
"content:afterSave": {
timeout: 5000,
errorPolicy: "continue", // Don't fail the save if this hook fails
handler: async (event, ctx) => {
// External API call that might fail
await ctx.http!.fetch("https://unreliable-api.com/notify");
}
}
逾時
Hooks 預設逾時為 5000ms(5 秒)。對可能更耗時的操作可提高該值:
"content:afterSave": {
timeout: 30000, // 30 seconds
handler: async (event, ctx) => {
// Long-running operation
}
}
公開頁面 Hooks
公開頁面 Hooks 讓外掛向渲染頁面的 <head> 與 <body> 貢獻內容。範本透過 emdash/ui 的 <EmDashHead>、<EmDashBodyStart> 與 <EmDashBodyEnd> 元件選擇接入。
page:metadata
向 <head> 貢獻型別化 metadata — meta 標籤、OpenGraph 屬性、canonical/alternate 連結與 JSON-LD 結構化資料。在可信模式與 sandbox 模式下皆可用。
Core 會驗證、去重並渲染這些貢獻。外掛回傳結構化資料,從不回傳原始 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 };
}
}
回傳值: PageMetadataContribution | PageMetadataContribution[] | null
貢獻類型:
| Kind | 渲染為 | 去重鍵 |
|---|---|---|
meta | <meta name="..." content="..."> | key 或 name |
property | <meta property="..." content="..."> | key 或 property |
link | <link rel="canonical|alternate" href="..."> | canonical:單例;alternate:key 或 hreflang |
jsonld | <script type="application/ld+json"> | id(若存在) |
同一去重鍵以首次貢獻為準。Link 的 href 須為 HTTP 或 HTTPS。
page:fragments
向頁面插入點貢獻原始 HTML、指令碼或 markup。僅限可信外掛 — sandbox 外掛不能使用此 hook。
"page:fragments": async (event, ctx) => {
return {
kind: "external-script",
placement: "head",
src: "https://www.googletagmanager.com/gtm.js?id=GTM-XXXXX",
async: true,
};
}
回傳值: PageFragmentContribution | PageFragmentContribution[] | null
Placements:"head"、"body:start"、"body:end"。若範本未包含某 placement 的元件,則針對該 placement 的貢獻會被靜默忽略。
Hooks 速查
| 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 | 寄送郵件前 | 修改後的訊息、false 或 void | 否 |
email:deliver | 透過 transport 遞送郵件 | void | 是 |
email:afterSend | 寄送郵件後 | void | 否 |
comment:beforeCreate | 儲存留言前 | 修改後的事件、false 或 void | 否 |
comment:moderate | 決定留言狀態 | { status, reason? } | 是 |
comment:afterCreate | 儲存留言後 | void | 否 |
comment:afterModerate | 管理員變更留言狀態 | void | 否 |
page:metadata | 頁面渲染 | 貢獻或 null | 否 |
page:fragments | 頁面渲染(可信) | 貢獻或 null | 否 |
完整事件類型與 handler 簽名見 Hook 參考。