플러그인 만들기

이 페이지

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

이 페이지는 전체 디스크립터 + 정의 구조를 가진 네이티브 형식 플러그인을 다룹니다. 마켓플레이스에 게시할 수 있는 표준 형식 플러그인에 대해서는 플러그인 게시를 참조하세요.

플러그인 구조

모든 네이티브 플러그인에는 다른 컨텍스트에서 실행되는 두 부분이 있습니다:

  1. 플러그인 디스크립터 (PluginDescriptor) — 팩토리 함수에 의해 반환되며, EmDash에 플러그인을 로드하는 방법을 알려줍니다. Vite에서 빌드 시 실행됩니다(astro.config.mjs에서 가져옴). 부작용이 없어야 하며 런타임 API를 사용할 수 없습니다.
  2. 플러그인 정의 (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 사이트를 만들어 플러그인을 테스트합니다:

  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 블록 유형

플러그인은 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" }
	}
}

다음 단계