콘텐츠로 이동

플러그인 생성

이 가이드는 완전한 EmDash 플러그인을 구축하는 과정을 안내합니다. 코드 구조화 방법, 훅과 스토리지 정의 방법, 관리자 UI 컴포넌트를 내보내는 방법을 배우게 됩니다.

모든 플러그인은 서로 다른 컨텍스트에서 실행되는 두 부분으로 구성됩니다:

  1. 플러그인 디스크립터 (PluginDescriptor) — 팩토리 함수에서 반환되며, EmDash에 플러그인을 로드하는 방법을 알려줍니다. 빌드 타임에 Vite에서 실행됩니다 (astro.config.mjs에서 임포트됨). 사이드 이펙트가 없어야 하며 런타임 API를 사용할 수 없습니다.
  2. 플러그인 정의 (definePlugin()) — 런타임 로직(훅, 라우트, 스토리지)을 포함합니다. 배포된 서버에서 요청 시 실행됩니다. 전체 플러그인 컨텍스트(ctx)에 접근할 수 있습니다.

이들은 완전히 다른 환경에서 실행되므로 별도의 진입점에 있어야 합니다:

my-plugin/
├── src/
│ ├── descriptor.ts # Plugin descriptor (runs in Vite at build time)
│ ├── index.ts # Plugin definition with definePlugin() (runs at deploy time)
│ ├── admin.tsx # Admin UI exports (React components) — optional
│ └── astro/ # Optional: Astro components for site-side rendering
│ └── index.ts # Must export `blockComponents`
├── package.json
└── tsconfig.json

디스크립터는 EmDash에게 플러그인을 찾을 위치와 제공하는 관리자 UI를 알려줍니다. 이 파일은 astro.config.mjs에서 임포트되며 Vite에서 실행됩니다.

typescript title="src/descriptor.ts"
import type { PluginDescriptor } from "emdash";
// Options your plugin accepts at registration time
export interface MyPluginOptions {
enabled?: boolean;
maxItems?: number;
}
export function myPlugin(options: MyPluginOptions = {}): PluginDescriptor {
return {
id: "my-plugin",
version: "1.0.0",
entrypoint: "@my-org/plugin-example",
options,
adminEntry: "@my-org/plugin-example/admin",
componentsEntry: "@my-org/plugin-example/astro",
adminPages: [{ path: "/settings", label: "Settings", icon: "settings" }],
adminWidgets: [{ id: "status", title: "Status", size: "half" }],
};
}

정의는 런타임 로직 — 훅, 라우트, 스토리지 및 관리자 구성을 포함합니다. 이 파일은 배포된 서버에서 요청 시 로드됩니다.

typescript title="src/index.ts"
import { definePlugin } from "emdash";
import type { MyPluginOptions } from "../../plugins/descriptor.js";
export function createPlugin(options: MyPluginOptions = {}) {
const maxItems = options.maxItems ?? 100;
return definePlugin({
id: "my-plugin",
version: "1.0.0",
// 필요한 기능 선언
capabilities: ["read:content"],
// 플러그인 스토리지 (문서 컬렉션)
storage: {
items: {
indexes: ["status", "createdAt", ["status", "createdAt"]],
},
},
// 관리자 UI 구성
admin: {
entry: "@my-org/plugin-example/admin",
settingsSchema: {
maxItems: {
type: "number",
label: "Maximum Items",
description: "Limit stored items",
default: maxItems,
min: 1,
max: 1000,
},
enabled: {
type: "boolean",
label: "Enabled",
default: options.enabled ?? true,
},
},
pages: [{ path: "/settings", label: "Settings", icon: "settings" }],
widgets: [{ id: "status", title: "Status", size: "half" }],
},
// 훅 핸들러
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Plugin installed");
},
"content:afterSave": async (event, ctx) => {
const enabled = await ctx.kv.get<boolean>("settings:enabled");
if (enabled === false) return;
ctx.log.info("Content saved", {
collection: event.collection,
id: event.content.id,
});
},
},
// API 라우트 (신뢰된 플러그인 전용 — 샌드박스 플러그인에서는 사용 불가)
routes: {
status: {
handler: async (ctx) => {
const count = await ctx.storage.items!.count();
return { count, maxItems };
},
},
},
});
}
export default createPlugin;

id 필드는 다음 규칙을 따라야 합니다:

  • 소문자 알파벳, 숫자 및 하이픈만 사용 가능
  • 단순 형식(my-plugin) 또는 스코프 형식(@my-org/my-plugin)
  • 설치된 모든 플러그인에서 고유해야 함
// Valid IDs
"seo";
"audit-log";
"@emdash-cms/plugin-forms";
// 잘못된 ID 예시
"MyPlugin"; // 대문자 불가
"my_plugin"; // 밑줄 불가
"my.plugin"; // 점 불가

시맨틱 버저닝을 사용하세요:

version: "1.0.0"; // Valid
version: "1.2.3-beta"; // Valid (prerelease)
version: "1.0"; // Invalid (missing patch)

EmDash가 각 진입점을 로드할 수 있도록 package.json 내보내기를 구성하세요. 디스크립터와 정의는 서로 다른 환경에서 실행되므로 별도의 내보내기입니다:

json title="package.json"
{
"name": "@my-org/plugin-example",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./descriptor": {
"types": "./dist/descriptor.d.ts",
"import": "./dist/descriptor.js"
},
"./admin": {
"types": "./dist/admin.d.ts",
"import": "./dist/admin.js"
},
"./astro": {
"types": "./dist/astro/index.d.ts",
"import": "./dist/astro/index.js"
}
},
"files": ["dist"],
"peerDependencies": {
"emdash": "^0.1.0",
"react": "^18.0.0"
}
}
내보내기컨텍스트목적
"."서버 (런타임)createPlugin() / definePlugin() — entrypoint에서 요청 시 로드됨
"./descriptor"Vite (빌드 타임)PluginDescriptor 팩토리 — astro.config.mjs에서 임포트됨
"./admin"브라우저관리자 페이지/위젯용 React 컴포넌트
"./astro"서버 (SSR)사이트 측 블록 렌더링용 Astro 컴포넌트

플러그인이 사용하는 경우에만 ./admin 및 ./astro 내보내기를 포함하세요.

완전한 예시: 감사 로그 플러그인

섹션 제목: “완전한 예시: 감사 로그 플러그인”

이 예시는 스토리지, 라이프사이클 훅, 콘텐츠 훅 및 API 라우트를 보여줍니다:

typescript title="src/index.ts"
import { definePlugin } from "emdash";
interface AuditEntry {
timestamp: string;
action: "create" | "update" | "delete";
collection: string;
resourceId: string;
userId?: string;
}
export function createPlugin() {
return definePlugin({
id: "audit-log",
version: "0.1.0",
storage: {
entries: {
indexes: [
"timestamp",
"action",
"collection",
["collection", "timestamp"],
["action", "timestamp"],
],
},
},
admin: {
settingsSchema: {
retentionDays: {
type: "number",
label: "Retention (days)",
description: "Days to keep entries. 0 = forever.",
default: 90,
min: 0,
max: 365,
},
},
pages: [{ path: "/history", label: "감사 기록", icon: "history" }],
widgets: [{ id: "recent-activity", title: "최근 활동", size: "half" }],
},
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Audit log plugin installed");
},
"content:afterSave": {
priority: 200, // 다른 플러그인 이후 실행
timeout: 2000,
handler: async (event, ctx) => {
const { content, collection, isNew } = event;
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: isNew ? "create" : "update",
collection,
resourceId: content.id as string,
};
const entryId = `${Date.now()}-${content.id}`;
await ctx.storage.entries!.put(entryId, entry);
ctx.log.info(`Logged ${entry.action} on ${collection}/${content.id}`);
},
},
"content:afterDelete": {
priority: 200,
timeout: 1000,
handler: async (event, ctx) => {
const { id, collection } = event;
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: "delete",
collection,
resourceId: id,
};
const entryId = `${Date.now()}-${id}`;
await ctx.storage.entries!.put(entryId, entry);
ctx.log.info(`Logged delete on ${collection}/${id}`);
},
},
},
routes: {
recent: {
handler: async (ctx) => {
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit: 10,
});
return {
entries: result.items.map((item) => ({
id: item.id,
...(item.data as AuditEntry),
})),
};
},
},
history: {
handler: async (ctx) => {
const url = new URL(ctx.request.url);
const limit = parseInt(url.searchParams.get("limit") || "50", 10);
const cursor = url.searchParams.get("cursor") || undefined;
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit,
cursor,
});
return {
entries: result.items.map((item) => ({
id: item.id,
...(item.data as AuditEntry),
})),
cursor: result.cursor,
hasMore: result.hasMore,
};
},
},
},
});
}
export default createPlugin;

플러그인을 등록한 최소한의 Astro 사이트를 생성하여 플러그인을 테스트하세요:

  1. EmDash가 설치된 테스트 사이트를 생성하세요.

  2. astro.config.mjs에서 플러그인을 등록하세요:

    import myPlugin from "../path/to/my-plugin/src";
    export default defineConfig({
    integrations: [
    emdash({
    plugins: [myPlugin()],
    }),
    ],
    });
  3. 개발 서버를 실행하고 콘텐츠 생성/업데이트로 훅을 트리거하세요.

  4. 콘솔에서 ctx.log 출력을 확인하고 API 라우트를 통해 스토리지를 검증하세요.

단위 테스트의 경우 PluginContext 인터페이스를 모킹하고 훅 핸들러를 직접 호출하세요.

플러그인은 Portable Text 편집기에 사용자 정의 블록 유형을 추가할 수 있습니다. 이들은 편집기의 슬래시 명령 메뉴에 나타나며 모든 portableText 필드에 삽입될 수 있습니다.

createPlugin()에서 admin.portableTextBlocks 아래에 블록을 선언하세요:

typescript title="src/index.ts"
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video", // Named icon: video, code, link, link-external
placeholder: "Paste YouTube URL...",
fields: [ // Block Kit fields for the editing UI
{ type: "text_input", action_id: "id", label: "YouTube URL" },
{ type: "text_input", action_id: "title", label: "Title" },
{ type: "text_input", action_id: "poster", label: "Poster Image URL" },
],
},
],
}

각 블록 유형은 다음을 정의합니다:

  • type — 블록 타입 이름 (Portable Text _type에서 사용됨)
  • label — 슬래시 명령어 메뉴에 표시될 이름
  • icon — 아이콘 키 (video, code, link, link-external). 기본값은 일반 큐브입니다.
  • placeholder — 입력 필드 플레이스홀더 텍스트
  • fields — 편집용 Block Kit 폼 필드. 생략 시 간단한 URL 입력 필드가 표시됩니다.

사이트에서 블록 타입을 렌더링하려면, componentsEntry에서 Astro 컴포넌트를 내보내세요:

typescript title="src/astro/index.ts"
import YouTube from "../../plugins/YouTube.astro";
import CodePen from "../../plugins/CodePen.astro";
// This export name is required — the virtual module imports it
export const blockComponents = {
youtube: YouTube,
codepen: CodePen,
};

플러그인 디스크립터에서 componentsEntry를 설정하세요:

export function myPlugin(options = {}): PluginDescriptor {
return {
id: "my-plugin",
entrypoint: "@my-org/my-plugin",
componentsEntry: "@my-org/my-plugin/astro",
// ...
};
}

플러그인 블록 컴포넌트는 자동으로 <PortableText>에 병합됩니다 — 사이트 작성자는 아무것도 임포트할 필요가 없습니다. 사용자가 제공한 컴포넌트는 플러그인 기본값보다 우선순위를 가집니다.

package.json에 ./astro 내보내기를 추가하세요:

json title="package.json"
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" },
"./astro": { "types": "./dist/astro/index.d.ts", "import": "./dist/astro/index.js" }
}
}