勾點讓外掛程式能夠回應事件並執行程式碼。所有勾點都會接收一個事件物件與外掛程式內容(context),並且在外掛程式定義時宣告——執行階段不支援動態註冊。
本頁涵蓋沙箱外掛程式。原生外掛程式使用相同的勾點名稱與事件類型,但使用行程內(in-process)勾點管線,且還可以額外註冊 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 儲存集合:
"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 參考。