훅을 사용하면 플러그인이 이벤트에 반응해 코드를 실행할 수 있습니다. 모든 훅은 이벤트 객체와 플러그인 컨텍스트를 받습니다. 훅은 플러그인 정의 시점에 선언되며 런타임에 동적으로 등록되지 않습니다.
훅 시그니처
모든 훅 핸들러는 두 인자를 받습니다.
async (event: EventType, ctx: PluginContext) => ReturnType;
event— 이벤트에 대한 데이터(저장되는 콘텐츠, 업로드된 미디어 등)ctx— 스토리지, KV, 로깅, capability로 제어되는 API가 있는 플러그인 컨텍스트
훅 구성
훅은 단순 핸들러로 선언하거나 전체 구성과 함께 선언할 수 있습니다.
간단함
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 | — | 훅 핸들러 함수. 필수. |
라이프사이클 훅
라이프사이클 훅은 플러그인 설치, 활성화, 비활성화 중에 실행됩니다.
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>
콘텐츠 훅
콘텐츠 훅은 생성, 업데이트, 삭제 작업 중에 실행됩니다.
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
콘텐츠가 성공적으로 저장된 후에 실행됩니다. 알림, 로깅, 외부 시스템 동기화 같은 부수 효과에 사용합니다.
"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>
미디어 훅
미디어 훅은 파일 업로드 중에 실행됩니다.
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>
훅 실행 순서
훅은 다음 순서로 실행됩니다.
priority값이 더 낮은 훅이 먼저 실행됩니다- 우선순위가 같으면 플러그인 등록 순서로 실행됩니다
dependencies가 있는 훅은 해당 플러그인이 완료될 때까지 기다립니다
// 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 () => {}
}
오류 처리
훅이 예외를 던지거나 타임아웃되면:
errorPolicy: "abort"— 전체 파이프라인이 중지됩니다. 원래 작업이 실패할 수 있습니다.errorPolicy: "continue"— 오류가 기록되고 나머지 훅은 계속 실행됩니다.
"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");
}
}
타임아웃
훅의 기본 타임아웃은 5000ms(5초)입니다. 더 오래 걸릴 수 있는 작업은 늘리세요.
"content:afterSave": {
timeout: 30000, // 30 seconds
handler: async (event, ctx) => {
// Long-running operation
}
}
공개 페이지 훅
공개 페이지 훅을 사용하면 플러그인이 렌더링된 페이지의 <head>와 <body>에 기여할 수 있습니다. 템플릿은 emdash/ui의 <EmDashHead>, <EmDashBodyStart>, <EmDashBodyEnd> 컴포넌트로 옵트인합니다.
page:metadata
<head>에 타입이 지정된 메타데이터를 기여합니다 — meta 태그, OpenGraph 속성, canonical/alternate 링크, JSON-LD 구조화 데이터. trusted 모드와 sandbox 모드 모두에서 동작합니다.
코어가 기여를 검증·중복 제거·렌더링합니다. 플러그인은 원시 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 or name |
property | <meta property="..." content="..."> | key or property |
link | <link rel="canonical|alternate" href="..."> | canonical: singleton; alternate: key or hreflang |
jsonld | <script type="application/ld+json"> | id (if present) |
중복 제거 키마다 첫 기여가 적용됩니다. 링크 href는 HTTP 또는 HTTPS여야 합니다.
page:fragments
페이지 삽입 지점에 원시 HTML, 스크립트 또는 마크업을 기여합니다. trusted 플러그인만 — sandbox된 플러그인은 이 훅을 사용할 수 없습니다.
"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
배치: "head", "body:start", "body:end". 배치용 컴포넌트를 생략한 템플릿은 해당 배치를 대상으로 하는 기여를 조용히 무시합니다.
훅 참고
| 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 | 페이지 렌더링(trusted) | 기여 또는 null | 아니오 |
전체 이벤트 타입과 핸들러 시그니처는 훅 참고를 보세요.