첫 번째 네이티브 플러그인

이 페이지

이 가이드는 네이티브 플러그인을 처음부터 만드는 과정을 안내합니다. 네이티브 플러그인은 React 관리 페이지, Portable Text 컴포넌트, 페이지 프래그먼트를 포함한 런타임에 대한 전체 접근 권한을 가진 Astro 사이트와 동일한 프로세스에서 실행됩니다.

네이티브 플러그인 대신 샌드박스 플러그인을 원하는지 아직 결정하지 않았다면, 먼저 플러그인 형식 선택을 읽어보세요. 네이티브는 React 관리 페이지, Portable Text 렌더링 컴포넌트 또는 페이지 프래그먼트가 필요한 플러그인을 위한 형식입니다.

두 조각, 하나 또는 두 파일로

샌드박스 플러그인과 마찬가지로, 네이티브 플러그인은 두 조각을 제공합니다:

  1. 디스크립터 팩토리format: "native"와 관리 관련 엔트리 포인트가 포함된 PluginDescriptor를 반환합니다. 빌드 시 astro.config.mjs에서 임포트됩니다.
  2. createPlugin(options) 함수 — 런타임 측. definePlugin({ id, version, capabilities, hooks, routes, admin }) 결과를 반환합니다.

샌드박스 플러그인과 달리, 두 조각 모두 다른 환경에서 실행되지 않기 때문에 같은 파일에 있을 수 있습니다 — 전체 플러그인이 인프로세스로 실행됩니다. 패키지의 "." 내보내기는 디스크립터 팩토리와 createPlugin(또는 default) 함수를 모두 내보내는 파일을 가리킵니다:

my-native-plugin/
├── src/
│   ├── index.ts          # 디스크립터 팩토리 + createPlugin
│   ├── admin.tsx         # React 관리 컴포넌트 (선택사항)
│   └── astro/            # PT 블록 렌더링용 Astro 컴포넌트 (선택사항)
│       └── index.ts
├── package.json
└── tsconfig.json

패키지 설정

다음 package.json은 네이티브 플러그인에 필요한 엔트리 포인트와 피어 종속성을 선언합니다:

{
	"name": "@my-org/plugin-analytics",
	"version": "0.1.0",
	"type": "module",
	"main": "dist/index.js",
	"exports": {
		".": {
			"types": "./dist/index.d.ts",
			"import": "./dist/index.js"
		},
		"./admin": {
			"types": "./dist/admin.d.ts",
			"import": "./dist/admin.js"
		}
	},
	"files": ["dist"],
	"peerDependencies": {
		"emdash": "*",
		"react": "^18.0.0"
	}
}

호스트 사이트가 실제 버전을 제공하고 중복을 배송하지 않도록 emdashreact를 피어 종속성으로 유지하세요.

디스크립터와 런타임 작성

다음 src/index.ts는 디스크립터 팩토리와 createPlugin 런타임을 하나의 파일에 정의합니다:

import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";

export interface AnalyticsOptions {
	enabled?: boolean;
	maxEvents?: number;
}

export function analyticsPlugin(options: AnalyticsOptions = {}): PluginDescriptor {
	return {
		id: "analytics",
		version: "0.1.0",
		format: "native",
		entrypoint: "@my-org/plugin-analytics",
		options,
		adminEntry: "@my-org/plugin-analytics/admin",
		adminPages: [{ path: "/dashboard", label: "Dashboard", icon: "chart" }],
		adminWidgets: [{ id: "events-today", title: "Events Today", size: "third" }],
	};
}

export function createPlugin(options: AnalyticsOptions = {}) {
	const maxEvents = options.maxEvents ?? 100;

	return definePlugin({
		id: "analytics",
		version: "0.1.0",

		capabilities: ["network:request"],
		allowedHosts: ["api.analytics.example.com"],

		storage: {
			events: { indexes: ["type", "createdAt"] },
		},

		admin: {
			entry: "@my-org/plugin-analytics/admin",
			settingsSchema: {
				trackingId: { type: "string", label: "Tracking ID" },
				enabled: { type: "boolean", label: "Enabled", default: options.enabled ?? true },
			},
			pages: [{ path: "/dashboard", label: "Dashboard", icon: "chart" }],
			widgets: [{ id: "events-today", title: "Events Today", size: "third" }],
		},

		hooks: {
			"plugin:install": async (_event, ctx) => {
				ctx.log.info("Analytics plugin installed", { maxEvents });
			},

			"content:afterSave": async (event, ctx) => {
				const enabled = await ctx.kv.get<boolean>("settings:enabled");
				if (enabled === false) return;

				await ctx.storage.events.put(`evt_${Date.now()}`, {
					type: "content:save",
					contentId: event.content.id,
					createdAt: new Date().toISOString(),
				});
			},
		},

		routes: {
			stats: {
				handler: async (ctx) => {
					const today = new Date().toISOString().split("T")[0];
					const count = await ctx.storage.events.count({
						createdAt: { gte: today },
					});
					return { today: count };
				},
			},
		},
	});
}

export default createPlugin;

이 구성의 주요 세부사항:

  • format: "native"는 필수입니다. "native"는 기본값이기도 하지만, 모든 디스크립터에 명시적으로 기재하면 형식을 쉽게 식별할 수 있습니다.
  • entrypoint는 패키지의 메인 내보내기입니다. EmDash는 런타임에 이를 임포트하고 기본 내보내기를 호출하여 해결된 플러그인을 구성합니다.
  • options는 디스크립터 → createPlugin으로 흐릅니다. 사용자가 플러그인 등록 시 전달하는 모든 것(analyticsPlugin({ enabled: false }))은 디스크립터에 보존되어 createPlugin으로 전달됩니다. 샌드박스 플러그인에는 이 표면이 없습니다 — 대신 KV에서 설정을 읽습니다.
  • id, version, capabilities는 두 번 나타납니다. 디스크립터에 한 번, definePlugin()에 한 번. 일치해야 합니다. 디스크립터의 사본은 빌드 시 astro.config.mjs가 보는 것이고, definePlugin()의 사본은 요청 시 실행되는 것입니다.
  • 네이티브 라우트 핸들러는 단일 인수를 받습니다(ctx: RouteContext) 여기서 ctx.input, ctx.request, ctx.requestMeta가 일반 PluginContext 속성과 병합됩니다. 이는 표준 형식의 두 인수 형태와 반대입니다. 전체 표면은 API 라우트를 참조하세요(그 외 모든 것은 동일합니다).

플러그인 ID 규칙

id 필드는 /^[a-z][a-z0-9_-]*$/와 일치해야 합니다 — 소문자로 시작한 후 문자, 숫자, 하이픈 또는 언더스코어가 옵니다. ID는 플러그인 라우트 URL의 단일 경로 세그먼트와 플러그인 스토리지 인덱스에 대해 생성된 SQL 식별자의 일부로 사용되므로, 이 패턴 밖의 것은 런타임에 실패합니다. 다음 값들은 어떤 ID가 허용되는지 보여줍니다:

// 유효
"seo";
"audit-log";
"audit_log";
"plugin-forms";

// 무효
"@my-org/plugin-forms";  // 스코프 형식은 런타임에 허용되지 않음
"MyPlugin";              // 대문자 불가
"42-plugin";             // 숫자로 시작할 수 없음
"my.plugin";             // 점 불가

스코프 없는 identrypoint의 스코프 있는 npm 패키지 이름을 조합하세요 — 패키지 이름과 플러그인 ID는 별개의 관심사입니다.

버전 형식

시맨틱 버전닝을 사용하세요. 다음 값들은 어떤 버전 문자열이 허용되는지 보여줍니다:

version: "1.0.0";       // 유효
version: "1.2.3-beta";  // 유효 (프리릴리스)
version: "1.0";         // 무효 (패치 누락)

플러그인 등록

사이트의 astro.config.mjs에서 디스크립터 팩토리를 임포트하고 plugins: [] 배열에 전달합니다 — 네이티브 플러그인은 항상 인프로세스로 실행되며, sandboxed: []에는 넣지 않습니다:

import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { analyticsPlugin } from "@my-org/plugin-analytics";

export default defineConfig({
	integrations: [
		emdash({
			plugins: [
				analyticsPlugin({ enabled: true, maxEvents: 500 }),
			],
		}),
	],
});

설정 UI

네이티브 플러그인은 자동 생성 설정 폼을 위해 admin.settingsSchema를 사용할 수 있으며, 이것이 가장 간단한 방법입니다:

admin: {
	settingsSchema: {
		apiKey: { type: "secret", label: "API Key" },
		enabled: { type: "boolean", label: "Enabled", default: true },
		maxItems: { type: "number", label: "Max items", min: 1, max: 1000, default: 100 },
	},
},

필드 유형: string, number, boolean, select, secret, url, email. 각각 label, description, defaultmin/max/options 같은 유형별 추가 옵션을 받습니다. 설정은 샌드박스 플러그인이 사용하는 것과 동일한 플러그인별 KV 저장소에 지속됩니다 — 어디서든 ctx.kv.get<T>("settings:<key>")로 읽을 수 있습니다.

생성된 폼은 Plugins의 플러그인 카드에서 톱니바퀴 아이콘 뒤에 나타납니다(관리자만 — 플러그인 설정 편집에는 plugins:manage 권한이 필요합니다). 시크릿 필드는 쓰기 전용입니다: 관리자는 저장된 값을 볼 수 없고, 설정 여부만 확인할 수 있습니다.

settingsSchema가 제공하는 것보다 더 풍부한 설정 UI를 위해, 커스텀 React 페이지를 제공하세요 — React 관리 페이지와 위젯을 참조하세요.

완전한 예제 — 감사 로그 플러그인

다음 플러그인은 모든 콘텐츠 생성, 업데이트, 삭제를 인덱스된 스토리지에 기록하고 최근 활동 라우트를 노출합니다:

import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";

interface AuditEntry {
	timestamp: string;
	action: "create" | "update" | "delete";
	collection: string;
	resourceId: string;
	userId?: string;
}

export function auditLogPlugin(): PluginDescriptor {
	return {
		id: "audit-log",
		version: "0.1.0",
		format: "native",
		entrypoint: "@emdash-cms/plugin-audit-log",
	};
}

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: {
			"content:afterSave": {
				priority: 200,
				handler: async (event, ctx) => {
					const entry: AuditEntry = {
						timestamp: new Date().toISOString(),
						action: event.isNew ? "create" : "update",
						collection: event.collection,
						resourceId: event.content.id as string,
					};
					await ctx.storage.entries.put(`${Date.now()}-${event.content.id}`, entry);
				},
			},

			"content:afterDelete": {
				priority: 200,
				handler: async (event, ctx) => {
					await ctx.storage.entries.put(`${Date.now()}-${event.id}`, {
						timestamp: new Date().toISOString(),
						action: "delete",
						collection: event.collection,
						resourceId: event.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),
						})),
					};
				},
			},
		},
	});
}

export default createPlugin;

테스트

플러그인이 등록된 최소한의 Astro 사이트를 만들어 네이티브 플러그인을 테스트합니다:

  1. EmDash가 설치된 테스트 사이트를 만듭니다.
  2. 로컬 소스 경로에서 직접 임포트하여 astro.config.mjs에 플러그인을 등록합니다.
  3. 개발 서버를 실행하고 콘텐츠를 생성, 업데이트 또는 삭제하여 훅을 트리거합니다.
  4. 콘솔에서 ctx.log 출력을 확인하고 API 라우트를 통해 스토리지를 검증합니다.

유닛 테스트의 경우, PluginContext 인터페이스를 모킹하고 훅 핸들러를 직접 호출합니다.

다음 단계