你的第一個原生外掛

本頁內容

本指南將引導你從零開始建構一個原生外掛。原生外掛在與你的 Astro 網站相同的程序中執行,擁有對執行時期的完整存取權限,包括 React 管理頁面、Portable Text 元件和頁面片段。

如果你還沒有決定是要原生外掛還是沙盒外掛,請先閱讀選擇外掛格式。原生是需要 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 讀取設定。
  • idversioncapabilities 出現兩次。 描述符上一次,definePlugin() 上一次。它們應該匹配。描述符的副本是 astro.config.mjs 在建置時看到的;definePlugin() 的副本是在請求時執行的。
  • 原生路由處理器接受單一引數(ctx: RouteContext) 其中 ctx.inputctx.requestctx.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 },
	},
},

欄位類型:stringnumberbooleanselectsecreturlemail。每種類型接受 labeldescriptiondefault,以及類型特定的擴充如 min/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 介面並直接呼叫鉤子處理器。

下一步