훅을 사용하면 플러그인이 이벤트에 응답해 코드를 실행할 수 있습니다. 모든 훅은 이벤트 객체와 플러그인 컨텍스트를 받으며, 플러그인 정의 시점에 선언합니다. 런타임에 동적으로 등록하는 기능은 없습니다.
이 페이지는 sandboxed 플러그인을 다룹니다. 네이티브 플러그인도 같은 훅 이름과 이벤트 타입을 사용하지만, 인프로세스 훅 파이프라인을 사용하며 page:fragments를 추가로 등록할 수 있습니다. sandbox에서의 저장 거부 동작과 격리된 러너의 실패 동작은 아래에서 설명합니다.
훅 시그니처
모든 훅 핸들러는 두 개의 인수를 받습니다.
async (event, ctx) => ReturnType;
event— 방금 일어난 일(저장 중인 콘텐츠, 업로드된 미디어, 수명 주기 전환 등)에 대한 데이터ctx— 스토리지, KV, 로깅, capability로 제어되는 API를 갖춘PluginContext
정의를 SandboxedPlugin 타입의 상수에 할당하면 훅 이름으로부터 event(전체 표준 이벤트 타입)가 추론되고 ctx는 PluginContext로 추론되므로, 핸들러에 매개변수 타입 주석이 필요하지 않습니다. 그 상수를 기본 내보내기(default export)로 내보내세요. 헬퍼에서 이벤트 타입을 이름으로 참조하려면 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
일부 훅은 보호된 데이터를 노출하거나 작업을 변경할 수 있습니다. EmDash는 매니페스트가 일치하는 capability를 선언한 경우에만 이 훅을 등록합니다.
| 훅 | 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를 제어하며, 플러그인이 인프로세스로 실행될 때 필수입니다. 런타임 효과는 Capability와 보안에서 설명합니다.
수명 주기 훅
플러그인 설치, 활성화, 비활성화, 제거 중에 실행됩니다.
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
콘텐츠가 저장되기 전에 실행됩니다. 수정된 콘텐츠나 sandbox 훅 오류 결과를 반환하거나, 변경하지 않으려면 void를 반환하세요.
sandbox에서 저장을 거부하려면 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 } — 반환: 수정된 콘텐츠, sandbox 훅 오류 결과, 또는 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에, 준비된 slug를 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가 필요합니다.
이벤트: { 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)를 추가합니다. sandboxed 플러그인과 네이티브 플러그인 모두에서 사용할 수 있습니다. 코어가 추가 내용을 검증하고 중복을 제거하며 렌더링합니다. 플러그인은 구조화된 데이터를 반환하며, 원시 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, 스크립트, 스타일시트를 추가합니다. 네이티브 플러그인 전용입니다.
sandboxed 플러그인은 이 훅을 사용할 수 없습니다. 출력이 퍼스트파티 코드로 방문자의 브라우저에서 실행되어 sandbox 경계 밖에 있기 때문입니다. sandbox에서 안전하게 페이지에 내용을 추가하려면 page:metadata를 사용하세요. 이 기능이 필요하다면 네이티브 플러그인: 페이지 프래그먼트를 참고하세요.
훅 실행 순서
sandboxed 형식의 플러그인이 인프로세스로 실행될 때 훅은 공유 훅 파이프라인을 사용합니다.
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 () => {},
}
격리된 sandbox 러너는 활성 sandboxed 플러그인을 로드 순서대로 호출합니다. 훅은 서로 독립적으로 유지하세요. 한 sandboxed 플러그인이 다른 플러그인보다 먼저 실행되어야 한다고 요구하지 마세요.
오류 처리
sandboxed 훅의 실패 동작은 훅이 실행되는 시점에 따라 다릅니다.
content:beforeSave에서 던진 오류는CONTENT_HOOK_ERROR로 저장을 실패시킵니다. 편집자에게 구체적인 검증 이유를 보여 주려면 문서화된SAVE_REJECTED봉투(envelope)를 반환하세요.content:beforeDelete에서false를 반환하면 휴지통으로의 이동이 중단됩니다. 이 훅이 예외를 던지면 EmDash는 오류를 기록하고 삭제를 계속합니다.- 콘텐츠 after 훅은 작업이 성공한 후에 실행됩니다. 이 훅의 오류는 기록되며 작업을 롤백할 수 없습니다.
- 수명 주기, 미디어, 이메일, 댓글 훅은 해당 훅을 발생시킨 작업의 계약을 따릅니다. 실패 동작에 의존하기 전에 훅 레퍼런스에서 특정 반환값을 확인하세요.
인프로세스 플러그인은 전체 구성 형태에서 errorPolicy: "abort" 또는 "continue"를 사용할 수 있습니다. 이 설정은 격리된 sandboxed 플러그인에 대한 이식 가능한 복구 수단이 아닙니다.
타임아웃
인프로세스 훅 파이프라인의 기본값은 5,000ms이며, 전체 구성 형태에서 더 긴 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 | 아니요 |
전체 이벤트 타입과 핸들러 시그니처는 훅 레퍼런스를 참고하세요.