훅을 사용하면 플러그인이 콘텐츠, 미디어, 이메일, 댓글, 페이지 수명 주기의 특정 지점에서 EmDash 동작을 가로채고 수정할 수 있습니다.
훅 개요
다음 표는 각 훅, 트리거, 수정 가능 항목, 독점 여부를 나열합니다.
| Hook | 트리거 | 수정 가능 | 독점 |
|---|---|---|---|
content:beforeSave | 콘텐츠 저장 전 | 콘텐츠 데이터 | 아니오 |
content:afterSave | 콘텐츠 저장 후 | 없음 | 아니오 |
content:beforeDelete | 콘텐츠 삭제 전 | 취소 가능 | 아니오 |
content:afterDelete | 콘텐츠 삭제 후 | 없음 | 아니오 |
content:beforePublish | 콘텐츠 게시 전 | 취소 가능 | 아니오 |
content:beforeSchedule | 콘텐츠 예약 전 | 취소 가능 | 아니오 |
content:beforeUnpublish | 콘텐츠 게시 취소 전 | 취소 가능 | 아니오 |
content:afterPublish | 콘텐츠 게시 후 | 없음 | 아니오 |
content:afterUnpublish | 콘텐츠 게시 취소 후 | 없음 | 아니오 |
content:afterRestore | 콘텐츠 복원 후 | 없음 | 아니오 |
content:afterSchedule | 콘텐츠 예약 후 | 없음 | 아니오 |
content:afterUnschedule | 콘텐츠 예약 취소 후 | 없음 | 아니오 |
media:beforeUpload | 파일 업로드 전 | 파일 메타데이터 | 아니오 |
media:afterUpload | 파일 업로드 후 | 없음 | 아니오 |
cron | 예약 작업 실행 | 없음 | 아니오 |
email:beforeSend | 이메일 전달 전 | 메시지, 취소 가능 | 아니오 |
email:deliver | 전송 계층으로 이메일 전달 | 없음 | 예 |
email:afterSend | 이메일 전달 성공 후 | 없음 | 아니오 |
comment:beforeCreate | 댓글 저장 전 | 댓글, 취소 가능 | 아니오 |
comment:moderate | 댓글 승인 상태 결정 | 상태 | 예 |
comment:afterCreate | 댓글 저장 후 | 없음 | 아니오 |
comment:afterModerate | 관리자가 댓글 상태 변경 후 | 없음 | 아니오 |
byline:afterSave | byline 생성 또는 업데이트 후 | 없음 | 아니오 |
byline:afterDelete | byline 삭제 후 | 없음 | 아니오 |
page:metadata | 공개 페이지 head 렌더링 | 태그 기여 | 아니오 |
page:fragments | 공개 페이지 body 렌더링 | 스크립트 주입 | 아니오 |
plugin:install | 플러그인 최초 설치 시 | 없음 | 아니오 |
plugin:activate | 플러그인 활성화 시 | 없음 | 아니오 |
plugin:deactivate | 플러그인 비활성화 시 | 없음 | 아니오 |
plugin:uninstall | 플러그인 제거 시 | 없음 | 아니오 |
콘텐츠 훅
content:beforeSave
Capability: content:write
콘텐츠가 데이터베이스에 저장되기 전에 실행됩니다. 검증, 변환, 보강에 사용하세요. 샌드박스 훅은 버전 1 훅 결과와 SAVE_REJECTED 오류, 1–500자의 일반 텍스트 reason을 반환하여 저장을 거부할 수 있습니다. API는 SAVE_REJECTED로 응답하고, 관리자 UI는 플러그인을 식별해 사유를 텍스트로 표시합니다. 비어 있거나, 너무 길거나, 형식이 잘못되었거나, 알 수 없는 오류 결과는 일반적인 CONTENT_HOOK_ERROR 응답으로 저장에 실패합니다.
호스트 프로세스에서는 emdash에서 export된 ContentSaveRejectedError를 throw하여 저장을 거부할 수 있습니다. 두 실행 모드 중 어느 쪽에서든 그 밖의 예외가 발생하면 예외 메시지를 노출하지 않는 일반 응답으로 저장에 실패합니다.
import { definePlugin } from "emdash";
export default definePlugin({
id: "my-plugin",
version: "1.0.0",
hooks: {
"content:beforeSave": async (event, ctx) => {
const { content, collection, isNew } = event;
// Add timestamps
if (isNew) {
content.createdBy = "system";
}
content.modifiedAt = new Date().toISOString();
// Return modified content
return content;
},
},
});
이벤트
interface ActorInfo {
readonly id: string;
readonly role: number;
}
interface ContentHookEvent {
content: Record<string, unknown>; // Content data
collection: string; // Collection slug
isNew: boolean; // True for creates, false for updates
id?: string; // ID of the existing item on updates; absent on creates
actor?: ActorInfo; // Authenticated user that initiated the save
locale?: string; // Locale of the entry being saved
translationOf?: string; // Source entry ID when a create adds a translation
}
업데이트 시 content에는 제출된 필드 값만 포함됩니다. 훅이 저장된 항목과 비교해야 하면 ctx.content.get(event.collection, event.id)로 불러오세요. 인증된 REST, 비주얼 편집, MCP 저장에는 actor가 포함됩니다. 인증된 사용자 없이 수행되는 내부 쓰기는 actor를 생략합니다.
locale은 항목이 저장되는 로캘입니다. 생성 시에는 요청된 로캘을 구성된 로캘에 맞춰 확인한 값(요청에 로캘이 지정되지 않으면 기본 로캘)이고, 업데이트 시에는 저장된 항목의 로캘입니다. translationOf는 생성이 번역 흐름에서 이루어질 때만 설정되며, 번역 대상 항목의 ID를 담습니다. 이 둘을 함께 사용하면 새 항목과 기존 항목의 새 번역을 구분할 수 있습니다. 예를 들어 컬렉션에서 로캘당 항목 하나만 허용하려면 다음과 같이 합니다.
"content:beforeSave": async (event, ctx) => {
if (event.collection !== "home" || !event.isNew || event.translationOf) return;
const existing = await ctx.content.list("home", {
limit: 1,
where: { locale: event.locale },
});
if (existing.items.length > 0) {
throw new ContentSaveRejectedError("Home already exists in this locale. Add a translation instead.");
}
},
반환값
- 변경을 적용하려면 수정된 콘텐츠 객체를 반환
- 제한된 일반 텍스트 사유로 저장을 거부하려면 샌드박스 훅 오류 봉투를 반환
- 변경 없이 통과하려면
void반환
샌드박스 훅이 저장을 거부할 때는 다음 전체 봉투를 반환합니다.
return {
__emdashSandboxHookResult: true,
version: 1,
error: {
code: "SAVE_REJECTED",
reason: "Add a summary before saving.",
},
};
호스트는 reason을 trim하고 1~500자를 허용합니다.
content:afterSave
Capability: content:read
콘텐츠 저장 후에 실행됩니다. 알림, 캐시 무효화, 외부 동기화 같은 부수 효과에 사용하세요.
hooks: {
"content:afterSave": async (event, ctx) => {
const { content, collection, isNew } = event;
if (collection === "posts" && content.status === "published") {
// Notify external service
await ctx.http?.fetch("https://api.example.com/notify", {
method: "POST",
body: JSON.stringify({ postId: content.id }),
});
}
},
}
이벤트
content:afterSave는 content, collection, isNew, 선택적 인증 actor, 그리고 content:beforeSave와 동일한 locale, translationOf를 받습니다. content는 저장된 전체 항목이며, 데이터베이스 ID는 content.id에, 컬렉션 필드는 content.data 아래에 있습니다. content:beforeSave 업데이트에 쓰이던 별도의 선택적 id는 저장 후에는 없습니다.
반환값
반환값은 없습니다.
content:beforeDelete
Capability: content:read
콘텐츠 삭제 전에 실행됩니다. 삭제를 검증하거나 막는 데 사용하세요.
hooks: {
"content:beforeDelete": async (event, ctx) => {
const { id, collection } = event;
// Prevent deletion of protected content
const item = await ctx.content?.get(collection, id);
if (item?.data.protected) {
return false; // Cancel deletion
}
// Allow deletion
return true;
},
}
이벤트
interface ContentDeleteEvent {
id: string; // Entry ID
collection: string; // Collection slug
permanent?: false; // Present for native plugins; omitted in the sandbox runtime
}
content:beforeDelete는 항목이 휴지통으로 이동할 때만 실행됩니다. 네이티브 플러그인은 permanent: false를 받고, 샌드박스 런타임은 id와 collection만 보냅니다. 샌드박스 훅 내부에서 이 필드로 분기하지 마세요. 영구 삭제는 content:beforeDelete를 거치지 않으므로, 훅은 관리자가 이미 휴지통에 있는 항목을 영구 삭제하는 것을 막을 수 없습니다.
반환값
- 삭제를 취소하려면
false반환 - 허용하려면
true또는void반환
content:afterDelete
Capability: content:read
콘텐츠 삭제 후에 실행됩니다. 정리 작업에 사용하세요.
hooks: {
"content:afterDelete": async (event, ctx) => {
const { id, collection, permanent } = event;
if (permanent) {
await ctx.storage.relatedItems.delete(`${collection}:${id}`);
}
},
}
이벤트에는 id, collection, permanent가 있습니다. 항목이 휴지통으로 이동하면 permanent는 false이고, 영구 삭제되면 true입니다. 복원된 휴지통 항목이 여전히 필요로 하는 데이터를 제거하기 전에 확인하세요. 이 훅에는 반환값이 없습니다.
content:beforePublish
Capability: hooks.content-policy:register
콘텐츠 게시 전에 실행됩니다. 게시를 허용하려면 void를, 거부하려면 { cancel: true, reason }을 반환하세요. EmDash는 reason에 1–500자의 일반 텍스트를 허용하며, 명시적 취소 시 PUBLISH_REJECTED를 반환합니다.
이벤트에는 현재 content, collection, origin이 있습니다. 인증된 API, MCP, 비주얼 편집기 동작에는 일치하는 source와 함께 actor도 포함됩니다. visual-editor origin은 인증된 툴바 렌더에서 서명된 단기 action 토큰이 필요합니다. 예약된 항목은 예정 시각이 되면 이 훅이 다시 실행됩니다. 스케줄러 거부는 항목 예약을 해제하고 대시보드에 영향받은 항목과 사유를 표시합니다. 예약, 게시, 삭제가 성공하면 기록이 지워지며, 관리자는 오래된 기록을 닫을 수 있습니다.
content:beforeSchedule
Capability: hooks.content-policy:register
항목에 게시 시각이 지정되기 전에 실행됩니다. content:beforePublish와 동일한 결정 봉투와 origin 필드를 사용하고, 이벤트에 scheduledAt을 추가하며, 명시적 취소 시 SCHEDULE_REJECTED를 반환합니다.
content:beforeUnschedule 훅은 없습니다. 관리자는 항상 미래 게시를 취소할 수 있습니다.
content:beforeUnpublish
Capability: hooks.content-policy:register
게시 중인 콘텐츠가 내리기 전에 실행됩니다. content:beforePublish와 동일한 결정 봉투와 origin 필드를 사용하며, 명시적 취소 시 UNPUBLISH_REJECTED를 반환합니다.
hooks.content-policy:register는 content:read, content:write, 게시 동작을 암시하지 않습니다. 잘못된 결정과 예상치 못한 abort 정책 오류는 예외 메시지를 노출하지 않고 fail-closed 방식으로 처리합니다.
content:afterPublish
항목이 성공적으로 게시된 후 실행되며, EmDash가 예약 시각에 자동 게시한 항목도 포함됩니다. 게시된 항목에 의존하는 작업(다른 서비스 알림, 외부 검색 인덱스 갱신 등)에 사용하세요.
hooks: {
"content:afterPublish": async (event, ctx) => {
ctx.log.info(`Published ${event.collection}/${event.content.id}`);
},
}
이 훅에는 content:read capability가 필요합니다. EmDash는 게시 응답 이후에 실행하므로 반환값으로 항목을 바꾸거나 게시를 되돌릴 수 없습니다. 오류는 로그에 기록되며, errorPolicy: "abort"이면 이후 게시 훅은 실행되지 않습니다.
content:afterUnpublish
항목의 게시가 성공적으로 취소된 후 실행됩니다. 외부 시스템에 보관된 콘텐츠 사본을 제거하거나 갱신하는 데 사용하세요. 게시 취소는 대기 중인 예약도 취소하며, content:afterUnschedule은 실행하지 않습니다.
hooks: {
"content:afterUnpublish": async (event, ctx) => {
ctx.log.info(`Unpublished ${event.collection}/${event.content.id}`);
},
}
이 훅은 content:afterPublish와 동일하게 content:read capability, 지연 실행, 이벤트 형태, 반환값 없음 계약을 따릅니다.
content:afterRestore
휴지통 콘텐츠가 복원된 후 실행됩니다. content:read capability가 필요합니다. 복원된 항목은 휴지통에 넣을 때의 상태와 관계없이 예약 없는 초안입니다. 예약 해제만으로는 content:afterUnschedule이 실행되지 않습니다.
hooks: {
"content:afterRestore": async (event, ctx) => {
ctx.log.info(`Restored ${event.collection}/${event.content.id}`);
},
}
content:afterSchedule
콘텐츠가 미래 게시로 예약된 후 실행됩니다. content:read capability가 필요합니다.
hooks: {
"content:afterSchedule": async (event, ctx) => {
ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`);
},
}
content:afterUnschedule
예약된 콘텐츠의 예약이 취소된 후 실행됩니다. content:read capability가 필요합니다.
hooks: {
"content:afterUnschedule": async (event, ctx) => {
ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`);
},
}
이벤트
interface ContentStateChangeEvent {
content: Record<string, unknown>;
collection: string;
}
이 이벤트 형태는 content:afterPublish, content:afterUnpublish, content:afterRestore, content:afterSchedule, content:afterUnschedule에서 공유합니다. content는 상태 변경 후의 전체 항목이며 id, slug, 상태를 포함하고, 컬렉션 필드는 content.data 아래에 있습니다.
반환값
반환값은 없습니다.
미디어 훅
media:beforeUpload
Capability: media:write
파일 업로드 전에 실행됩니다. 파일 검증, 이름 변경, 거부에 사용하세요.
hooks: {
"media:beforeUpload": async (event, ctx) => {
const { file } = event;
// Reject files over 10MB
if (file.size > 10 * 1024 * 1024) {
throw new Error("File too large");
}
// Rename file
return {
name: `${Date.now()}-${file.name}`,
type: file.type,
size: file.size,
};
},
}
이벤트
interface MediaUploadEvent {
file: {
name: string; // Original filename
type: string; // MIME type
size: number; // Size in bytes
};
}
반환값
- 변경을 적용하려면 수정된 파일 메타데이터 반환
- 변경 없이 통과하려면
void반환 - 업로드를 거부하려면 throw
media:afterUpload
Capability: media:read
파일 업로드 후에 실행됩니다. 처리, 썸네일, 메타데이터 추출에 사용하세요.
hooks: {
"media:afterUpload": async (event, ctx) => {
const { media } = event;
if (media.mimeType.startsWith("image/")) {
// Store image metadata
await ctx.kv.set(`media:${media.id}:analyzed`, {
processedAt: new Date().toISOString(),
});
}
},
}
이벤트
interface MediaAfterUploadEvent {
media: {
id: string;
filename: string;
mimeType: string;
size: number | null;
url: string;
createdAt: string;
};
}
반환값
반환값은 없습니다.
라이프사이클 훅
라이프사이클 훅에는 등록 capability가 필요하지 않습니다.
plugin:install
플러그인이 처음 설치될 때 실행됩니다. 초기 설정, 스토리지 컬렉션 생성, 시드 데이터에 사용하세요.
hooks: {
"plugin:install": async (event, ctx) => {
// Initialize default settings
await ctx.settings.set("enabled", true);
await ctx.settings.set("threshold", 100);
ctx.log.info("Plugin installed successfully");
},
}
plugin:activate
플러그인이 활성화될 때 실행됩니다(설치 후 또는 재활성화 시).
hooks: {
"plugin:activate": async (event, ctx) => {
ctx.log.info("Plugin activated");
},
}
plugin:deactivate
플러그인이 비활성화될 때 실행됩니다.
hooks: {
"plugin:deactivate": async (event, ctx) => {
ctx.log.info("Plugin deactivated");
},
}
plugin:install, plugin:activate, plugin:deactivate는 빈 이벤트 객체를 받으며 반환값이 없습니다.
plugin:uninstall
플러그인이 제거될 때 실행됩니다. 정리에 사용하세요.
hooks: {
"plugin:uninstall": async (event, ctx) => {
const { deleteData } = event;
if (deleteData) {
// Clean up all plugin data
const items = await ctx.settings.list();
for (const { key } of items) {
await ctx.kv.delete(key);
}
}
ctx.log.info("Plugin uninstalled");
},
}
이벤트
interface UninstallEvent {
deleteData: boolean; // User chose to delete data
}
제거 훅에는 반환값이 없습니다.
Cron 훅
cron
Capability: 필요 없음
예약 작업이 실행될 때 발생합니다. ctx.cron.schedule()로 작업을 예약하세요. Cron 표현식은 모든 호스트에서 UTC로 평가됩니다.
hooks: {
"cron": async (event, ctx) => {
if (event.name === "daily-sync") {
const data = await ctx.http?.fetch("https://api.example.com/data");
ctx.log.info("Sync complete");
}
},
}
이벤트
interface CronEvent {
name: string;
data?: Record<string, unknown>;
scheduledAt: string;
}
Cron 훅에는 반환값이 없습니다.
이메일 훅
플러그인이 보내는 메시지에서는 이메일 훅이 순서대로 실행됩니다: email:beforeSend, email:deliver, email:afterSend. 시스템 인증 메시지는 email:deliver로 바로 가며 email:beforeSend와 email:afterSend를 거치지 않습니다.
email:beforeSend
Capability: hooks.email-events:register
전달 전에 실행되는 미들웨어 훅입니다. 메시지를 변환하거나 전달을 취소할 수 있습니다.
hooks: {
"email:beforeSend": async (event, ctx) => {
// Add footer to all emails
return {
...event.message,
text: event.message.text + "\n\n—Sent from My Site",
};
// Or return false to cancel delivery
},
}
이벤트
interface EmailBeforeSendEvent {
message: {
to: string;
cc?: string[];
replyTo?: string;
subject: string;
text: string;
html?: string;
};
source: string;
}
반환값
- 변환하려면 수정된 메시지 반환
- 전달을 취소하려면
false반환
email:deliver
Capability: hooks.email-transport:register | Exclusive: 예
전송 제공자입니다. 이메일을 전달할 수 있는 플러그인은 하나뿐입니다. 이메일 서비스를 통해 실제로 메시지를 보내는 역할을 합니다.
hooks: {
"email:deliver": {
exclusive: true,
handler: async (event, ctx) => {
await sendViaSES(event.message);
},
},
}
이벤트 및 반환값
interface EmailDeliverEvent {
message: {
to: string;
cc?: string[];
replyTo?: string;
subject: string;
text: string;
html?: string;
};
source: string;
}
훅에는 반환값이 없습니다. source는 EmDash 인증 메시지일 때 "system", 플러그인이 보낸 메시지일 때는 해당 플러그인 ID입니다.
플러그인은 메시지에 cc(추가 수신자)와 replyTo를 설정할 수 있습니다. 제공자는 둘 다 있으면 전달해야 하며, cc를 조용히 누락하면 해당 수신자는 메시지를 받지 못합니다.
email:afterSend
Capability: hooks.email-events:register
전달 성공 후 실행되는 fire-and-forget 훅입니다. 오류는 로그에만 남고 전파되지 않습니다.
hooks: {
"email:afterSend": async (event, ctx) => {
await ctx.kv.set(`email:log:${Date.now()}`, {
to: event.message.to,
subject: event.message.subject,
});
},
}
이벤트 및 반환값
email:afterSend는 email:deliver와 동일한 message, source 필드를 받으며 반환값이 없습니다.
댓글 훅
댓글 훅은 순서대로 실행됩니다: comment:beforeCreate, comment:moderate, comment:afterCreate. comment:afterModerate 훅은 관리자 또는 권한 있는 플러그인이 댓글 상태를 변경할 때 별도로 실행됩니다.
댓글이 저장된 후 훅은 다음 레코드 형태를 받습니다.
interface StoredComment {
id: string;
collection: string;
contentId: string;
parentId: string | null;
authorName: string;
authorEmail: string;
authorUserId: string | null;
body: string;
status: string;
moderationMetadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
comment:beforeCreate
Capability: users:read
댓글이 저장되기 전의 미들웨어 훅입니다. 댓글을 보강, 검증, 거부할 수 있습니다.
hooks: {
"comment:beforeCreate": async (event, ctx) => {
// Reject comments with links
if (event.comment.body.includes("http")) {
return false;
}
},
}
이벤트
interface CommentBeforeCreateEvent {
comment: {
collection: string;
contentId: string;
parentId: string | null;
authorName: string;
authorEmail: string;
authorUserId: string | null;
body: string;
ipHash: string | null;
userAgent: string | null;
};
metadata: Record<string, unknown>;
}
반환값
- 변환하려면 수정된 이벤트 반환
- 거부하려면
false반환 - 변경 없이 통과하려면
void반환
comment:moderate
Capability: users:read | Exclusive: 예
댓글이 승인, 대기, 스팸 중 어느 상태인지 결정합니다. 활성 검토 제공자는 하나뿐입니다.
hooks: {
"comment:moderate": {
exclusive: true,
handler: async (event, ctx) => {
const score = await checkSpam(event.comment);
return {
status: score > 0.8 ? "spam" : score > 0.5 ? "pending" : "approved",
reason: `Spam score: ${score}`,
};
},
},
}
이벤트
interface CommentModerateEvent {
comment: { /* same as beforeCreate */ };
metadata: Record<string, unknown>;
collectionSettings: {
commentsEnabled: boolean;
commentsModeration: "all" | "first_time" | "none";
commentsClosedAfterDays: number;
commentsAutoApproveUsers: boolean;
};
priorApprovedCount: number;
}
반환값
{ status: "approved" | "pending" | "spam"; reason?: string }
활성 검토자
EmDash에는 컬렉션 댓글 설정을 적용하는 내장 검토자가 있습니다. comment:moderate를 제공하는 플러그인이 정확히 하나면 EmDash는 내장 검토자 대신 그 플러그인을 선택해 저장합니다. 여러 플러그인이 제공하고 저장된 선택이 없으면 EmDash는 어느 것도 선택하지 않고 새 댓글은 검토 대기 상태가 됩니다. 내장 검토자를 포함해 저장된 선택은 선택된 검토자가 활성화된 동안 유지됩니다.
comment:afterCreate
Capability: users:read
댓글 저장 후 실행되는 fire-and-forget 훅입니다. 알림에 사용하세요. 이메일 전송에는 email:send capability와 구성된 email:deliver 제공자가 모두 필요하며, 둘 다 없으면 ctx.email은 undefined입니다.
hooks: {
"comment:afterCreate": async (event, ctx) => {
const recipient = event.contentAuthor?.email;
if (event.comment.status === "approved" && recipient && ctx.email) {
await ctx.email.send({
to: recipient,
subject: `New comment on "${event.content.title}"`,
text: `${event.comment.authorName} commented: ${event.comment.body}`,
});
}
},
}
이벤트 및 반환값
interface CommentAfterCreateEvent {
comment: StoredComment;
metadata: Record<string, unknown>;
content: { id: string; collection: string; slug: string; title?: string };
contentAuthor?: { id: string; name: string | null; email: string };
}
훅에는 반환값이 없습니다.
comment:afterModerate
Capability: users:read
관리자 또는 플러그인이 댓글 상태를 변경한 후 실행됩니다. 성공적인 전환마다 훅이 한 번 실행됩니다. 훅 오류는 로그에 기록되며 상태 변경은 되돌리지 않습니다.
이벤트
interface CommentAfterModerateEvent {
comment: StoredComment;
previousStatus: string;
newStatus: string;
moderator: { id: string; name: string | null };
origin?:
| { source: "admin"; userId: string }
| { source: "plugin"; pluginId: string };
}
훅에는 반환값이 없습니다.
Byline 훅
Byline 훅은 관리자 API 또는 MCP 도구가 byline 프로필을 변경한 후 실행됩니다. 시드나 가져오기(import) 시에는 실행되지 않습니다. 검색 인덱스처럼 작성자 데이터의 외부 사본을 최신 상태로 유지하는 데 사용하세요. 훅 오류는 로그에 기록되며 변경을 되돌리지 않습니다.
byline을 다른 사용자에게 다시 연결하면 해당 사용자의 항목에서 유추되는 크레디트가 달라질 수 있습니다. 이 변경에 대해서는 항목별 훅이 실행되지 않습니다.
byline:afterSave
Capability: bylines:read
byline 또는 byline 번역이 생성되거나 업데이트된 후 실행됩니다.
hooks: {
"byline:afterSave": async (event, ctx) => {
await ctx.storage.authors.put(event.byline.id, {
name: event.byline.displayName,
slug: event.byline.slug,
});
},
}
이벤트
interface BylineAfterSaveEvent {
byline: BylineInfo;
/** `true` when the byline was just created. */
isNew: boolean;
}
BylineInfo에는 ctx.bylines.get()이 반환하는 것과 동일한 공개 필드가 있습니다. 연결된 사용자 계정, 게스트 플래그, 사용자 정의 필드 값은 포함되지 않습니다.
훅에는 반환값이 없습니다.
byline:afterDelete
Capability: bylines:read
byline이 삭제된 후 실행됩니다. 이벤트에는 삭제 전 상태의 byline이 담깁니다.
이벤트
interface BylineAfterDeleteEvent {
byline: BylineInfo;
}
훅에는 반환값이 없습니다.
페이지 훅
페이지 훅은 공개 페이지를 렌더링할 때 실행됩니다. 플러그인이 메타데이터와 스크립트를 주입할 수 있게 합니다.
두 페이지 훅 모두 현재 공개 페이지 컨텍스트를 받습니다.
interface PublicPageContext {
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;
}
interface PageMetadataEvent { page: PublicPageContext }
interface PageFragmentEvent { page: PublicPageContext }
page:metadata
Capability: 필요 없음
페이지 head에 meta 태그, Open Graph 속성, JSON-LD 구조화 데이터, link 태그를 기여합니다.
hooks: {
"page:metadata": async (event, ctx) => {
return [
{ kind: "meta", name: "generator", content: "EmDash" },
{ kind: "property", property: "og:site_name", content: event.page.siteName ?? "My Site" },
{ kind: "jsonld", graph: { "@type": "WebSite", name: event.page.siteName } },
];
},
}
기여 유형
type PageMetadataContribution =
| { kind: "meta"; name: string; content: string; key?: string }
| { kind: "property"; property: string; content: string; key?: string }
| {
kind: "link";
rel: "canonical" | "alternate" | "author" | "license" | "nlweb" | "site.standard.document";
href: string;
hreflang?: string;
key?: string;
}
| {
kind: "jsonld";
id?: string;
graph: Record<string, unknown> | Array<Record<string, unknown>>;
};
key 필드는 기여를 중복 제거합니다 — 동일한 key를 가진 마지막 기여만 사용됩니다.
기여 하나, 기여 배열, 또는 추가할 것이 없으면 null을 반환하세요.
page:fragments
Capability: hooks.page-fragments:register
페이지에 스크립트 또는 HTML을 주입합니다. 네이티브 플러그인에서만 사용할 수 있습니다.
hooks: {
"page:fragments": async (event, ctx) => {
return [
{
kind: "external-script",
placement: "body:end",
src: "https://analytics.example.com/script.js",
async: true,
},
{
kind: "inline-script",
placement: "head",
code: `window.siteId = "abc123";`,
},
];
},
}
기여 유형
type PageFragmentContribution =
| {
kind: "external-script";
placement: "head" | "body:start" | "body:end";
src: string;
async?: boolean;
defer?: boolean;
attributes?: Record<string, string>;
key?: string;
}
| {
kind: "inline-script";
placement: "head" | "body:start" | "body:end";
code: string;
attributes?: Record<string, string>;
key?: string;
}
| {
kind: "html";
placement: "head" | "body:start" | "body:end";
html: string;
key?: string;
};
fragment 기여 하나, 기여 배열, 또는 추가할 것이 없으면 null을 반환하세요.
훅 구성
훅은 핸들러 함수 또는 구성 객체를 받을 수 있습니다.
hooks: {
// Simple handler
"content:afterSave": async (event, ctx) => { ... },
// With configuration
"content:beforeSave": {
priority: 50, // Lower runs first (default: 100)
timeout: 10000, // Max execution time in ms (default: 5000)
dependencies: [], // Run after these plugins
errorPolicy: "abort", // "continue" or "abort" (default)
handler: async (event, ctx) => { ... },
},
}
구성 옵션
| 옵션 | 타입 | 기본값 | 설명 |
|---|---|---|---|
priority | number | 100 | 실행 순서(낮을수록 먼저) |
timeout | number | 5000 | 최대 실행 시간(밀리초) |
dependencies | string[] | [] | 먼저 실행해야 하는 플러그인 ID |
errorPolicy | string | "abort" | 오류를 무시하려면 "continue" |
exclusive | boolean | false | 활성 제공자는 하나만(email:deliver, comment:moderate 같은 제공자 패턴 훅) |
플러그인 컨텍스트
모든 훅은 플러그인 API에 접근할 수 있는 컨텍스트 객체를 받습니다.
interface PluginContext {
plugin: { id: string; version: string };
storage: PluginStorage;
kv: KVAccess;
content?: ContentAccess;
media?: MediaAccess;
http?: HttpAccess;
log: LogAccess;
site: { name: string; url: string; locale: string };
url(path: string): string;
users?: UserAccess;
cron?: CronAccess;
email?: EmailAccess;
}
각 컨텍스트 API에 필요한 capability는 플러그인 capability를 참고하세요.
site.url과 url()은 이메일 속 링크와 같은 주소를 사용합니다. 먼저 구성된 siteUrl, 그다음 Settings > General의 Site URL, 마지막으로 사이트가 설정된 주소 순입니다. EmDash는 런타임이 시작될 때 이를 읽으므로, Site URL을 변경하면 서버를 다시 시작한 후에, 또는 Cloudflare Workers에서는 새 아이솔레이트가 시작될 때 플러그인에 반영됩니다.
오류 처리
훅의 오류는 로그에 기록되며 errorPolicy에 따라 처리됩니다.
"abort"(기본값) — 실행 중단, 해당 시 트랜잭션 롤백"continue"— 오류를 로그하고 다음 훅으로 진행
hooks: {
"content:beforeSave": {
errorPolicy: "continue", // Don't block save if this fails
handler: async (event, ctx) => {
try {
await ctx.http?.fetch("https://api.example.com/validate");
} catch (error) {
ctx.log.warn("Validation service unavailable", error);
}
},
},
}
실행 순서
훅은 다음 순서로 실행됩니다.
priority기준 정렬(오름차순)dependencies가 있는 플러그인은 의존 플러그인 이후 실행- 동일 priority 내 순서는 결정적이지만 명시되지 않음
// This runs first (priority 10)
{ priority: 10, handler: ... }
// This runs second (priority 50)
{ priority: 50, handler: ... }
// This runs last (default priority 100)
{ handler: ... }