このガイドでは、完全な EmDash プラグインの構築について説明します。コードの構造化、フックとストレージの定義、管理 UI コンポーネントのエクスポート方法を学習します。
このページでは、完全なディスクリプタ + 定義構造を持つネイティブ形式のプラグインについて説明します。マーケットプレイスに公開できる標準形式のプラグインについては、プラグインの公開を参照してください。
プラグイン構造
すべてのネイティブプラグインには、異なるコンテキストで実行される 2 つの部分があります:
- プラグインディスクリプタ (
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" }
}
}