勾點

本頁內容

勾點讓外掛能夠回應事件執行程式碼。所有勾點都會收到事件物件與外掛情境,並在外掛定義時宣告——執行階段沒有動態註冊。

本頁涵蓋沙箱外掛。原生外掛使用相同的勾點名稱與事件類型,但使用處理程序內勾點管線,並且還可以註冊 page:fragments。沙箱儲存拒絕與隔離執行器失敗行為見下文。

勾點簽章

每個勾點處理函式接受兩個參數:

async (event, ctx) => ReturnType;
  • event — 關於剛剛發生之事的資料(正在儲存的內容、已上傳的媒體、生命週期轉換等)
  • ctx — 帶有儲存、KV、記錄與受 capability 閘控的 API 的 PluginContext

將定義指派給 SandboxedPlugin 類型的常數會從勾點名稱推斷 event(完整的正規事件類型),並將 ctx 推斷為 PluginContext,因此處理函式無需參數註解。將該常數作為 default 匯出。要在輔助函式中按名稱引用事件類型,請從 emdash/plugin 匯入。

勾點設定

勾點可以宣告為裸處理函式,或包裝在設定物件中。除非外掛還支援刻意的處理程序內執行並需要下文所述的中繼資料,否則優先使用裸形式。

Simple

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

Full config

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

設定選項

OptionTypeDefaultDescription
prioritynumber100執行順序。數字越小越先執行。
timeoutnumber5000最大執行時間(毫秒)。
exclusivebooleanfalse只能有一個外掛作為作用中提供方。用於 email:deliver 與 comment:moderate。
handlerfunction—勾點處理函式。必要。

所需 capability

若干勾點會公開受保護資料或可變更操作。僅當資訊清單宣告符合的 capability 時,EmDash 才會註冊它們:

HooksCapabilityReason
content:beforeSavecontent:write勾點可以取代提交的內容。
content:beforePublish, content:beforeSchedule, content:beforeUnpublishhooks.content-policy:register勾點可以拒絕發佈狀態變更。
其他 content:* 勾點content:read其事件會公開內容或識別項目。
media:beforeUploadmedia:write勾點可以取代上傳中繼資料或停止上傳。
media:afterUploadmedia:read其事件會公開已儲存的媒體項目。
email:beforeSend, email:afterSendhooks.email-events:register勾點檢查郵件生命週期事件。
email:deliverhooks.email-transport:register勾點成為郵件傳輸提供方。
所有 comment:* 勾點users:read留言事件可能包含作者聯絡資訊與請求中繼資料。
page:fragmentshooks.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" });
},

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) {
		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));
		}
	}
},

Event: { deleteData: boolean } — Returns: 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.",
			},
		};
	}

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

	return content;
},

不要在 reason 中放入 HTML。管理端會將值轉譯為文字。

在主機處理程序中,改為擲出 ContentSaveRejectedError(從 emdash 匯出)。API 會傳回帶有您訊息的 SAVE_REJECTED。任一執行模式中的其他例外都會以通用的 CONTENT_HOOK_ERROR 回應使儲存失敗。

Event: { content, collection, isNew, id, actor } — Returns: 修改後的內容、沙箱勾點錯誤結果或 void。更新時,id 是現有項目的 ID,content 僅包含提交的欄位值;用 ctx.content.get(event.collection, event.id) 載入已儲存項目。經驗證的 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 }),
		});
	}
},

Event: { content, collection, isNew, actor } — Returns: 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;
},

Event: { id, collection, permanent: false } — Returns: boolean | void

此勾點在項目移入回收站之前執行。從回收站永久刪除項目不會再次執行 content:beforeDelete。

content:afterDelete

在內容成功刪除後執行。

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

Event: { id, collection, permanent } — Returns: 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;外掛來源還包含 pluginId。經驗證的人類操作包含 actor.id、數字 actor.role 以及符合的 actor.source。EmDash 僅接受來自嵌入在經驗證工具列轉譯中的已簽署短期操作權杖的 visual-editor 來源;普通 API 請求無法選擇其來源。

發佈與排程事件在 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。

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

content:afterUnpublish

在內容從線上恢復為草稿之後執行。需要 content:read capability。

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

content:afterRestore

在從回收站恢復內容之後執行。需要 content:read capability。

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

content:afterSchedule

在內容被安排為未來發佈之後執行。需要 content:read capability。

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

content:afterUnschedule

在取消已排程內容的計畫之後執行。需要 content:read capability。

Event: { content, collection } — Returns: 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}` };
},

Event: { file: { name, type, size } } — Returns: 修改後的檔案或 void

media:afterUpload

在檔案成功上傳之後執行。

Event: { media: { id, filename, mimeType, size, url, createdAt } } — Returns: 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,
		},
	};
},

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 };
		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;
	}
}

Returns: PageMetadataContribution | PageMetadataContribution[] | null

Contribution kinds:

KindRendersDedupe key
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 面板值會在產生基礎中繼資料之前摺疊到頁面情境中——它們會取代範本提供的欄位(也是勾點在頁面情境中看到的內容),而外掛貢獻仍透過 first-wins 去重獲勝。連結 rel 限制為安全性鎖定的允許清單(canonical、alternate、author、license、nlweb、site.standard.document);href 必須是 HTTP 或 HTTPS。

page:fragments

向頁面插入點貢獻原始 HTML、指令碼或樣式表。僅限原生外掛。

沙箱外掛不能使用此勾點,因為其輸出會作為第一方程式碼在造訪者瀏覽器中執行,超出任何沙箱邊界。對於沙箱安全的頁面貢獻,請使用 page:metadata。若需要此表面,請參見原生外掛:頁面片段。

勾點執行順序

當沙箱格式外掛在處理程序內執行時,勾點使用共用勾點管線:

  1. priority 值較低的勾點先執行。
  2. 優先順序相同時,按外掛註冊順序執行。
  3. 帶有 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 勾點在操作成功後執行。其錯誤會被記錄,且無法復原操作。
  • 生命週期、媒體、郵件與留言勾點遵循其發起操作的契約。在依賴失敗行為之前,請用勾點參考檢查特定傳回值。

處理程序內外掛可以在完整設定形式中使用 errorPolicy: "abort" 或 "continue"。該設定不是隔離沙箱外掛的可攜復原控制。

逾時

處理程序內勾點管線預設為 5,000 ms,並在完整設定形式中接受更長的 timeout:

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

勾點參考

HookTriggerReturnExclusive
plugin:install首次安裝外掛voidNo
plugin:activate外掛啟用voidNo
plugin:deactivate外掛停用voidNo
plugin:uninstall外掛移除voidNo
content:beforeSave內容儲存前修改後的內容、拒絕信封或 voidNo
content:afterSave內容儲存後voidNo
content:beforeDelete內容移入回收站前false 取消,否則允許No
content:afterDelete回收站或永久刪除後voidNo
content:afterPublish內容發佈後voidNo
content:afterUnpublish內容取消發佈後voidNo
content:afterRestore內容恢復後voidNo
content:afterSchedule內容排程後voidNo
content:afterUnschedule內容取消排程後voidNo
media:beforeUpload檔案上傳前修改後的檔案資訊或 voidNo
media:afterUpload檔案上傳後voidNo
cron排程工作觸發voidNo
email:beforeSend郵件投遞前修改後的訊息、false 或 voidNo
email:deliver透過傳輸投遞郵件voidYes
email:afterSend郵件投遞後voidNo
comment:beforeCreate留言儲存前修改後的事件、false 或 voidNo
comment:moderate決定留言狀態{ status, reason? }Yes
comment:afterCreate留言儲存後voidNo
comment:afterModerate管理員變更留言狀態voidNo
page:metadata頁面轉譯貢獻或 nullNo
page:fragments頁面轉譯(僅原生)貢獻或 nullNo

完整事件類型與處理函式簽章請參見勾點參考。