이 가이드는 완전한 EmDash 플러그인 구축 과정을 안내합니다. 코드 구조화, 훅 및 스토리지 정의, 관리 UI 컴포넌트 내보내기 방법을 배웁니다.
이 페이지는 전체 디스크립터 + 정의 구조를 가진 네이티브 형식 플러그인을 다룹니다. 마켓플레이스에 게시할 수 있는 표준 형식 플러그인에 대해서는 플러그인 게시를 참조하세요.
플러그인 구조
모든 네이티브 플러그인에는 다른 컨텍스트에서 실행되는 두 부분이 있습니다:
- 플러그인 디스크립터 (
PluginDescriptor) — 팩토리 함수에 의해 반환되며, EmDash에 플러그인을 로드하는 방법을 알려줍니다. Vite에서 빌드 시 실행됩니다(astro.config.mjs에서 가져옴). 부작용이 없어야 하며 런타임 API를 사용할 수 없습니다. - 플러그인 정의 (
definePlugin()) — 런타임 로직(훅, 라우트, 스토리지)을 포함합니다. 배포된 서버에서 요청 시 실행됩니다. 전체 플러그인 컨텍스트 (ctx)에 액세스할 수 있습니다.
완전히 다른 환경에서 실행되므로 별도의 진입점에 있어야 합니다:
my-plugin/
├── src/
│ ├── descriptor.ts # 플러그인 디스크립터(빌드 시 Vite에서 실행)
│ ├── index.ts # definePlugin()을 사용한 플러그인 정의(배포 시 실행)
│ ├── admin.tsx # 관리 UI 내보내기(React 컴포넌트) — 선택 사항
│ └── astro/ # 선택 사항: 사이트 측 렌더링용 Astro 컴포넌트
│ └── index.ts # `blockComponents`를 내보내야 함
├── package.json
└── tsconfig.json
플러그인 만들기
디스크립터(빌드 시)
디스크립터는 EmDash에 플러그인을 찾을 위치와 제공하는 관리 UI를 알려줍니다. 이 파일은 astro.config.mjs에서 가져오고 Vite에서 실행됩니다.
import type { PluginDescriptor } from "emdash";
// 등록 시 플러그인이 허용하는 옵션
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" }],
};
}
정의(런타임)
정의에는 런타임 로직 — 훅, 라우트, 스토리지 및 관리 구성이 포함됩니다. 이 파일은 배포된 서버에서 요청 시 로드됩니다.
import { definePlugin } from "emdash";
import type { MyPluginOptions } from "./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 규칙
id 필드는 다음 규칙을 따라야 합니다:
- 소문자 영숫자 문자 및 하이픈만
- 단순(
my-plugin) 또는 범위 지정(@my-org/my-plugin) - 설치된 모든 플러그인에서 고유
// 유효한 ID
"seo";
"audit-log";
"@emdash-cms/plugin-forms";
// 무효한 ID
"MyPlugin"; // 대문자 불가
"my_plugin"; // 밑줄 불가
"my.plugin"; // 점 불가
버전 형식
시맨틱 버전 관리 사용:
version: "1.0.0"; // 유효
version: "1.2.3-beta"; // 유효(사전 릴리스)
version: "1.0"; // 무효(패치 누락)
패키지 내보내기
EmDash가 각 진입점을 로드할 수 있도록 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 라우트를 보여줍니다:
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: "Audit History", icon: "history" }],
widgets: [{ id: "recent-activity", title: "Recent Activity", 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 사이트를 만들어 플러그인을 테스트합니다:
-
EmDash가 설치된 테스트 사이트를 만듭니다.
-
astro.config.mjs에 플러그인을 등록합니다:import myPlugin from "../path/to/my-plugin/src"; export default defineConfig({ integrations: [ emdash({ plugins: [myPlugin()], }), ], }); -
개발 서버를 실행하고 콘텐츠를 생성/업데이트하여 훅을 트리거합니다.
-
콘솔에서
ctx.log출력을 확인하고 API 라우트를 통해 스토리지를 확인합니다.
단위 테스트의 경우 PluginContext 인터페이스를 모의하고 훅 핸들러를 직접 호출합니다.
Portable Text 블록 유형
플러그인은 Portable Text 편집기에 사용자 정의 블록 유형을 추가할 수 있습니다. 이들은 편집기의 슬래시 명령 메뉴에 나타나며 모든 portableText 필드에 삽입할 수 있습니다.
블록 유형 선언
createPlugin()에서 admin.portableTextBlocks 아래에 블록을 선언합니다:
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video", // 명명된 아이콘: video, code, link, link-external
placeholder: "Paste YouTube URL...",
fields: [ // 편집 UI용 Block Kit 필드
{ 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 컴포넌트를 내보냅니다:
import YouTube from "./YouTube.astro";
import CodePen from "./CodePen.astro";
// 이 내보내기 이름은 필수 — 가상 모듈이 가져옴
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 내보내기를 추가합니다:
{
"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" }
}
}