本指南將引導你建置一個完整的 EmDash 外掛。你將學習如何組織程式碼、定義鉤子和儲存,以及匯出後台 UI 元件。
本頁面涵蓋具有完整描述器 + 定義結構的原生格式外掛。有關可以發佈到市場的標準格式外掛,請參閱發佈外掛。
外掛結構
每個原生外掛都有兩個在不同上下文中執行的部分:
- 外掛描述器 (
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"; // 無效(缺少補丁)
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 網站來測試外掛:
-
建立一個安裝了 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 匯出
將 ./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" }
}
}