플러그인 생성
이 가이드는 완전한 EmDash 플러그인을 구축하는 과정을 안내합니다. 코드 구조화 방법, 훅과 스토리지 정의 방법, 관리자 UI 컴포넌트를 내보내는 방법을 배우게 됩니다.
플러그인 구조
섹션 제목: “플러그인 구조”모든 플러그인은 서로 다른 컨텍스트에서 실행되는 두 부분으로 구성됩니다:
- 플러그인 디스크립터 (
PluginDescriptor) — 팩토리 함수에서 반환되며, EmDash에 플러그인을 로드하는 방법을 알려줍니다. 빌드 타임에 Vite에서 실행됩니다 (astro.config.mjs에서 임포트됨). 사이드 이펙트가 없어야 하며 런타임 API를 사용할 수 없습니다. - 플러그인 정의 (
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 timeexport 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 규칙
섹션 제목: “플러그인 ID 규칙”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"; // Validversion: "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 사이트를 생성하여 플러그인을 테스트하세요:
-
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 블록 유형”플러그인은 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 itexport 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" } }}