建立外掛

本頁內容

本指南將引導你建置一個完整的 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"; // 無效(缺少補丁)

Package 匯出

設定 package.json 匯出,以便 EmDash 可以載入每個入口點。描述器和定義是單獨的匯出,因為它們在不同的環境中執行:

{
	"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 匯出

./astro 匯出新增到 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" }
	}
}

下一步