プラグインの作成

このページ

このガイドでは、完全な EmDash プラグインの構築について説明します。コードの構造化、フックとストレージの定義、管理 UI コンポーネントのエクスポート方法を学習します。

このページでは、完全なディスクリプタ + 定義構造を持つネイティブ形式のプラグインについて説明します。マーケットプレイスに公開できる標準形式のプラグインについては、プラグインの公開を参照してください。

プラグイン構造

すべてのネイティブプラグインには、異なるコンテキストで実行される 2 つの部分があります:

  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 — アイコンキー(videocodelinklink-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" }
	}
}

次のステップ