フック
このページで分かること
- フックの書き方(イベントとプラグインのコンテキストを受け取る)と設定項目(
priority、timeout、exclusive) - フックごとに必要な権限(Capability)と、ライフサイクル・コンテンツ・メディア・公開ページのフックの動き
- 実行順、エラー時の扱い、タイムアウトと、すべてのフックの一覧
このページの目次
フック(hook)を使うと、プラグインはイベントに反応してコードを実行できます。すべてのフックは、イベントのオブジェクトとプラグインのコンテキストを受け取ります。フックはプラグインを定義するときに宣言します。実行時に動的に登録する方法はありません。
このページでは、サンドボックス型プラグインについて説明します。ネイティブ型プラグインも同じフック名とイベントの型を使いますが、プロセス内のフックのパイプラインを使い、さらに page:fragments も登録できます。サンドボックス型での保存の拒否と、分離されたランナーでの失敗時の動きは、このあとで説明します。
フックのシグネチャー
すべてのフックのハンドラーは、2つの引数を受け取ります。
async (event, ctx) => ReturnType;
event:直前に起きたことのデータ(保存されようとしているコンテンツ、アップロードされたメディア、ライフサイクルの移行など)ctx:ストレージ、KV、ログ出力、権限(Capability)で制限されるAPIを持つPluginContext
定義を SandboxedPlugin 型の定数に代入すると、event の型はフック名から(正式なイベントの型全体として)、ctx の型は PluginContext として推論されます。そのため、ハンドラーの引数に型注釈は必要ありません。その定数を既定のエクスポートとして書き出します。補助関数の中でイベントの型を名前で参照するには、emdash/plugin からインポートします。
やさしい解説
EmDashでは、プラグインの定義の hooks に、フック名とハンドラーをまとめて書きます。実行時にあとから登録する方法はありません。ハンドラーは、何が起きたかを表す event と、ストレージやログなどを使うための ctx の2つを受け取ります。SandboxedPlugin 型の定数に代入しておけば、この2つの型は自動で決まります。
フックの設定
フックは、ハンドラーだけの形で宣言することも、設定オブジェクトで包んで宣言することもできます。プラグインが意図的なプロセス内での実行にも対応していて、以下で説明するメタデータが必要な場合を除き、ハンドラーだけの形を使います。
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");
},
},
},
設定項目
| 項目 | 型 | 既定値 | 説明 |
|---|---|---|---|
priority |
number |
100 |
実行順です。小さい数値が先に実行されます。 |
timeout |
number |
5000 |
最大の実行時間(ミリ秒)です。 |
exclusive |
boolean |
false |
有効な提供者を1つのプラグインだけに限ります。email:deliver と comment:moderate で使います。 |
handler |
function |
— | フックのハンドラー関数です。必須です。 |
必要な権限
いくつかのフックは、保護されたデータを渡したり、処理を変更したりできます。EmDashは、マニフェストに対応する権限が宣言されている場合にだけ、これらのフックを登録します。
| フック | 権限 | 理由 |
|---|---|---|
content:beforeSave |
content:write |
送信されたコンテンツを置き換えられるためです。 |
その他の 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 には、登録のための権限はありません。フックがイベントを読むだけで、対応する ctx のAPIを呼び出さない場合でも、表に記載した権限を宣言します。この宣言によって、運営者に正確な同意の確認を表示でき、ctx のAPIが制限されます。また、プラグインをプロセス内で動かす場合には、この宣言が必要です。実行時の効果は、権限(Capabilities)とセキュリティで説明しています。
やさしい解説
コンテンツを書き換えられるフックや、コンテンツ・メディア・コメントのデータを受け取るフックを使うには、マニフェストで対応する権限を宣言する必要があります。宣言がないと、そのフックは登録されません。たとえば、保存前にコンテンツを書き換えられる content:beforeSave には content:write が、保存後に実行される content:afterSave には content:read が必要です。
ライフサイクルのフック
プラグインのインストール、有効化、無効化、削除のときに実行されます。
plugin:install
プラグインが初めてサイトに追加されたときに、1回だけ実行されます。
この例では、マニフェストで items というストレージのコレクションを宣言していることを前提にしています。
"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" });
},
イベント: {} 戻り値: 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.",
},
};
}
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 のレスポンスとして保存を失敗させます。
イベント: { content, collection, isNew, id, actor } 戻り値: 変更したコンテンツ、サンドボックスのフックのエラー結果、または 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 }),
});
}
},
イベント: { 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 です。
content:afterPublish
コンテンツが下書きから公開中の状態に移ったあとに実行されます。content:read の権限が必要です。
イベント: { content, collection } 戻り値: Promise<void>
content:afterUnpublish
コンテンツが公開中の状態から下書きに戻ったあとに実行されます。content:read の権限が必要です。
イベント: { content, collection } 戻り値: Promise<void>
content:afterRestore
ゴミ箱のコンテンツが元に戻されたあとに実行されます。content:read の権限が必要です。
イベント: { content, collection } 戻り値: Promise<void>
content:afterSchedule
コンテンツが将来の公開のために予約公開されたあとに実行されます。content:read の権限が必要です。
イベント: { content, collection } 戻り値: Promise<void>
content:afterUnschedule
予約公開したコンテンツの予約が解除されたあとに実行されます。content:read の権限が必要です。
イベント: { content, collection } 戻り値: Promise<void>
やさしい解説
保存前に実行される content:beforeSave は、コンテンツを書き換えたり、理由を示して保存を拒否したりできます。保存後に実行される content:afterSave は、通知や外部との同期に使い、保存そのものは取り消せません。削除は、ゴミ箱に移される時点で content:beforeDelete が実行され、ゴミ箱から完全に削除するときには再び実行されません。
メディアのフック
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
型付きのメタデータ(metaタグ、OpenGraphのプロパティ、許可リストにある <link> のrel、JSON-LD)を <head> に追加します。サンドボックス型とネイティブ型のどちらのプラグインでも使えます。 コアが追加された内容を検証し、重複を除き、描画します。プラグインは構造化されたデータを返し、生の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:1つだけ。alternate:key または hreflang |
jsonld |
<script type="application/ld+json"> |
id(ある場合) |
重複を判定するキーが同じ場合は、最初に追加されたものが採用されます。<EmDashHead> は、プラグイン → サイトの設定 → テンプレートが提供する基本のメタデータ、の順に内容を組み立てます。そのため、プラグインが追加した内容は、それより後ろのものすべてより優先されます。コンテンツのページでは、基本のメタデータが生成される前に、エントリーのSEOパネルの値がページのコンテキストに取り込まれます。これらの値は、テンプレートが提供するフィールドを置き換えます(フックがページのコンテキストで受け取るのも、これらの値です)。一方で、プラグインが追加した内容は、最初のものを採用する重複の除去によって、引き続き優先されます。linkの 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系のフックは、処理が成功したあとに実行されます。そのエラーはログに出力され、処理を巻き戻すことはできません。
- ライフサイクル、メディア、メール、コメントのフックは、それぞれのもとになる処理の取り決めに従います。失敗時の動きに頼る前に、フックリファレンスで個々の戻り値を確認してください。
プロセス内のプラグインは、完全な設定の形で errorPolicy: "abort" または "continue" を使えます。この設定は、分離されたサンドボックス型プラグインでは、どの環境でも同じように働く回復の手段にはなりません。
タイムアウト
プロセス内のフックのパイプラインの既定値は5,000 msで、完全な設定の形でより長い 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 |
いいえ |
page:metadata |
ページの描画 | 追加する内容、または null |
いいえ |
page:fragments |
ページの描画(ネイティブ型のみ) | 追加する内容、または null |
いいえ |
イベントの型とハンドラーのシグネチャーの全体は、フックリファレンスを参照します。