훅을 사용하면 플러그인이 이벤트에 응답해 코드를 실행할 수 있습니다. 모든 훅은 이벤트 객체와 플러그인 컨텍스트를 받으며, 플러그인 정의 시점에 선언됩니다. 런타임 동적 등록은 없습니다.
이 페이지는 샌드박스 플러그인을 다룹니다. 네이티브 플러그인은 동일한 훅 이름과 이벤트 타입을 사용하지만 인프로세스 훅 파이프라인을 쓰며 추가로 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");
},
},
}, 구성 옵션
| Option | Type | Default | Description |
|---|---|---|---|
priority | number | 100 | 실행 순서. 숫자가 작을수록 먼저 실행. |
timeout | number | 5000 | 최대 실행 시간(밀리초). |
exclusive | boolean | false | 활성 프로바이더는 플러그인 하나만. email:deliver와 comment:moderate에 사용. |
handler | function | — | 훅 핸들러 함수. 필수. |
필수 capability
여러 훅은 보호된 데이터를 노출하거나 작업을 변경할 수 있습니다. EmDash는 매니페스트가 일치하는 capability를 선언할 때만 등록합니다:
| Hooks | Capability | Reason |
|---|---|---|
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" });
},
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에서 내보냄)를 throw하세요. 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입니다. 플러그인 origin에는 pluginId도 포함됩니다. 인증된 인간 액션에는 actor.id, 숫자 actor.role, 일치하는 actor.source가 포함됩니다. EmDash는 인증된 툴바 렌더에 임베드된 서명된 단기 액션 토큰에서만 visual-editor origin을 수락합니다. 일반 API 요청은 origin을 선택할 수 없습니다.
게시 및 예약 이벤트는 유효 초안을 content.data에, 스테이징된 슬러그를 content.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
파일이 업로드되기 전에 실행됩니다. 수정된 파일 메타데이터를 반환하거나 취소하려면 throw하세요.
"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>에 기여합니다 — 메타 태그, 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:
| Kind | Renders | Dedupe 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를 사용하세요. 이 표면이 필요하면 네이티브 플러그인: 페이지 프래그먼트를 참조하세요.
훅 실행 순서
샌드박스 형식 플러그인이 인프로세스로 실행될 때 훅은 공유 훅 파이프라인을 사용합니다:
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 () => {},
}
격리 샌드박스 러너는 활성 샌드박스 플러그인을 로드 순으로 호출합니다. 훅을 독립적으로 유지하세요. 한 샌드박스 플러그인이 다른 것보다 먼저 실행되기를 요구하지 마세요.
오류 처리
샌드박스 훅 실패는 훅이 언제 실행되는지에 따라 달라집니다:
- throw된
content:beforeSave오류는CONTENT_HOOK_ERROR로 저장을 실패시킵니다. 편집자가 특정 검증 이유를 봐야 할 때는 문서화된SAVE_REJECTED엔벨로프를 반환하세요. content:beforeDelete에서false를 반환하면 휴지통 이동이 중단됩니다. 그 훅이 throw하면 EmDash는 오류를 기록하고 삭제를 계속합니다.- 콘텐츠 after 훅은 작업 성공 후 실행됩니다. 그 오류는 기록되며 작업을 롤백할 수 없습니다.
- 수명 주기, 미디어, 이메일, 댓글 훅은 원본 작업의 계약을 따릅니다. 실패 동작에 의존하기 전에 훅 참조에서 특정 반환 값을 확인하세요.
인프로세스 플러그인은 전체 구성 형식에서 errorPolicy: "abort" 또는 "continue"를 사용할 수 있습니다. 그 설정은 격리된 샌드박스 플러그인을 위한 이식 가능한 복구 제어가 아닙니다.
타임아웃
인프로세스 훅 파이프라인의 기본값은 5,000ms이며 전체 구성 형식에서 더 긴 timeout을 받습니다:
"content:afterSave": {
timeout: 30000,
handler: async (event, ctx) => {
// Long-running operation
},
},
훅 참조
| Hook | Trigger | Return | Exclusive |
|---|---|---|---|
plugin:install | 첫 플러그인 설치 | void | No |
plugin:activate | 플러그인 활성화 | void | No |
plugin:deactivate | 플러그인 비활성화 | void | No |
plugin:uninstall | 플러그인 제거 | void | No |
content:beforeSave | 콘텐츠 저장 전 | 수정된 콘텐츠, 거부 엔벨로프, 또는 void | No |
content:afterSave | 콘텐츠 저장 후 | void | No |
content:beforeDelete | 휴지통 이동 전 | 취소는 false, 아니면 허용 | No |
content:afterDelete | 휴지통 또는 영구 삭제 후 | void | No |
content:afterPublish | 콘텐츠 게시 후 | void | No |
content:afterUnpublish | 콘텐츠 게시 취소 후 | void | No |
content:afterRestore | 콘텐츠 복원 후 | void | No |
content:afterSchedule | 콘텐츠 예약 후 | void | No |
content:afterUnschedule | 콘텐츠 예약 취소 후 | void | No |
media:beforeUpload | 파일 업로드 전 | 수정된 파일 정보 또는 void | No |
media:afterUpload | 파일 업로드 후 | void | No |
cron | 예약 작업 실행 | void | No |
email:beforeSend | 이메일 전달 전 | 수정된 메시지, false, 또는 void | No |
email:deliver | 전송을 통해 이메일 전달 | void | Yes |
email:afterSend | 이메일 전달 후 | void | No |
comment:beforeCreate | 댓글 저장 전 | 수정된 이벤트, false, 또는 void | No |
comment:moderate | 댓글 상태 결정 | { status, reason? } | Yes |
comment:afterCreate | 댓글 저장 후 | void | No |
comment:afterModerate | 관리자가 댓글 상태 변경 | void | No |
page:metadata | 페이지 렌더 | 기여 또는 null | No |
page:fragments | 페이지 렌더(네이티브만) | 기여 또는 null | No |
전체 이벤트 타입과 핸들러 시그니처는 훅 참조를 참조하세요.