훅

이 페이지

훅을 사용하면 플러그인이 이벤트에 응답해 코드를 실행할 수 있습니다. 모든 훅은 이벤트 객체와 플러그인 컨텍스트를 받으며, 플러그인 정의 시점에 선언됩니다. 런타임 동적 등록은 없습니다.

이 페이지는 샌드박스 플러그인을 다룹니다. 네이티브 플러그인은 동일한 훅 이름과 이벤트 타입을 사용하지만 인프로세스 훅 파이프라인을 쓰며 추가로 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

여러 훅은 보호된 데이터를 노출하거나 작업을 변경할 수 있습니다. EmDash는 매니페스트가 일치하는 capability를 선언할 때만 등록합니다:

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에서 내보냄)를 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:

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

격리 샌드박스 러너는 활성 샌드박스 플러그인을 로드 순으로 호출합니다. 훅을 독립적으로 유지하세요. 한 샌드박스 플러그인이 다른 것보다 먼저 실행되기를 요구하지 마세요.

오류 처리

샌드박스 훅 실패는 훅이 언제 실행되는지에 따라 달라집니다:

  • 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
	},
},

훅 참조

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

전체 이벤트 타입과 핸들러 시그니처는 훅 참조를 참조하세요.