原生外掛可以將受信任的 React 元件載入 EmDash 管理端。主機保留對導覽、頁面路由、儀表板卡片、編輯器版面、內容表格、驗證與錯誤邊界的控制。外掛提供元件以及放置它們所需的中繼資料。
若外掛只需要設定表單,請從 admin.settingsSchema 開始。它使用主機的表單元件,且不需要 React 進入點。沙箱外掛也可以使用此產生表單;本頁的自訂 React 擴充需要原生外掛。
產生的設定表單
在執行階段定義中宣告 settingsSchema。以下 schema 會產生多行文字欄位、選取框、數字輸入、開關以及僅寫入的密鑰輸入:
return definePlugin({
id: "plugin-activity",
version: "0.1.0",
admin: {
settingsSchema: {
projectName: {
type: "string",
label: "Project name",
description: "Name shown in activity exports",
},
notes: {
type: "string",
label: "Internal notes",
multiline: true,
},
mode: {
type: "select",
label: "Recording mode",
options: [
{ value: "creates", label: "New entries only" },
{ value: "all", label: "New and updated entries" },
],
default: "all",
},
retentionDays: {
type: "number",
label: "Retention in days",
min: 1,
max: 365,
default: 30,
},
enabled: {
type: "boolean",
label: "Record activity",
default: true,
},
exportToken: {
type: "secret",
label: "Export token",
},
},
},
});
可用欄位具有以下選項。每種類型都要求 label,description 可選。
type | Value | Additional fields |
|---|---|---|
string | string | default, multiline |
number | number | default, min, max |
boolean | boolean | default |
select | string | required options: Array<{ value, label }> and optional default |
secret | string | no additional fields; the stored value is never returned to the browser |
url | string | default, placeholder |
email | string | default, placeholder |
該表單可從 Plugins 中外掛卡片上的設定控制項進入。讀取或變更需要 plugins:manage。
設定使用外掛的命名空間設定儲存。名為 retentionDays 的欄位對外掛可用作 retentionDays:
const retentionDays =
(await ctx.settings.get<number>("retentionDays")) ?? 30;
當沒有儲存值時,schema 預設值會填入產生的表單,但 EmDash 不會將這些預設值寫入設定儲存。執行階段讀取設定時請套用相同的後援。清除非密鑰欄位會刪除其儲存值並將表單恢復為預設值。密鑰值在持久化前會加密,且從不回傳給瀏覽器;表單僅報告密鑰是否已設定,並允許管理員取代或清除它。
設定標籤與描述依宣告原樣轉譯。若這些字串必須隨管理端地區設定變化,請改為建置自訂 React 設定頁。
React 進入點
受信任的 React 擴充有三個相互關聯的宣告:
- 描述子的
adminEntry告訴 Astro 要將哪個模組打包進管理端。 - 執行階段的
admin.entry、admin.pages與admin.widgets描述可見的管理面。 - 管理模組匯出元件對應,其鍵與宣告的頁面路徑和小工具 ID 相符。
描述子只需要模組指定子。頁面與小工具中繼資料屬於執行階段定義:
export function activityPlugin(): PluginDescriptor {
return {
id: "plugin-activity",
version: "0.1.0",
format: "native",
entrypoint: "@example/plugin-activity",
adminEntry: "@example/plugin-activity/admin",
};
}
export function createPlugin() {
return definePlugin({
id: "plugin-activity",
version: "0.1.0",
storage: {
events: { indexes: ["createdAt"] },
},
admin: {
entry: "@example/plugin-activity/admin",
pages: [{ path: "/activity", label: "Activity", icon: "clock" }],
widgets: [{ id: "recent-activity", title: "Recent activity" }],
},
});
}
保持 adminEntry 與 admin.entry 相同。前者是建置時匯入;後者告訴執行階段此外掛使用受信任的 React 管理元件。
管理頁面
每個頁面宣告具有以下欄位:
| Field | Required | Behavior |
|---|---|---|
path | Yes | Mounts the page at /_emdash/admin/plugins/<plugin-id><path>. Use a leading slash. |
label | Yes | Supplies the sidebar and command-palette label. |
icon | No | Names a Phosphor icon in kebab, snake, space-separated, or PascalCase form. Unknown names fall back to the plugin icon. |
管理模組將每個宣告的路徑對應到 React 元件。尾隨斜線視為等價,且當不存在 / 頁面時,外掛根會開啟第一個匯出的頁面。
以下頁面載入私有外掛路由。控制項使用 Kumo,外掛 API 請求使用 apiFetch();apiFetch() 會新增 cookie 驗證的私有路由所需的 X-EmDash-Request: 1 標頭。
import { Button, Loader } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
import * as React from "react";
interface ActivitySummary {
count: number;
}
export function ActivityPage() {
const { i18n } = useLingui();
const [summary, setSummary] = React.useState<ActivitySummary>();
const [error, setError] = React.useState<string>();
const load = React.useCallback(async () => {
setError(undefined);
try {
const response = await apiFetch(
"/_emdash/api/plugins/plugin-activity/summary",
);
setSummary(
await parseApiResponse<ActivitySummary>(
response,
i18n._({ id: "activity.load-error", message: "Could not load activity" }),
),
);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
}
}, [i18n]);
React.useEffect(() => {
void load();
}, [load]);
return (
<section className="space-y-4">
<h1 className="text-2xl font-semibold">
{i18n._({ id: "activity.title", message: "Activity" })}
</h1>
{summary ? (
<p>
{i18n._({ id: "activity.count", message: "Event count" })}: {summary.count}
</p>
) : error ? (
<p role="alert" className="text-kumo-danger">{error}</p>
) : (
<Loader />
)}
<Button type="button" onClick={() => void load()}>
{i18n._({ id: "activity.refresh", message: "Refresh" })}
</Button>
</section>
);
}
在原生執行階段中定義對應路由。原生處理函式接收一個情境參數:
routes: {
summary: {
permission: "plugins:read",
handler: async (ctx) => ({
count: await ctx.storage.events.count(),
}),
},
},
私有路由預設使用僅管理員的 plugins:manage 權限。宣告與操作相符的最窄現有權限。僅對面向未驗證網際網路流量的端點使用 public: true。
從管理進入點匯出頁面:
import type { PluginAdminExports } from "emdash";
import { ActivityPage } from "./ActivityPage.js";
export const pages: PluginAdminExports["pages"] = {
"/activity": ActivityPage,
};
頁面標籤會經過管理端的共用 Lingui 實例。諸如 Settings 的標籤在存在翻譯時使用管理端翻譯。外掛可以將自己的訊息目錄載入共用實例,用於外掛特定的標籤與元件訊息;否則宣告的英文訊息為後援。
在匯入管理進入點時載入外掛目錄,並在管理員變更地區設定後再次載入。以下小型德文目錄使用與頁面和小工具範例相同的 ID:
import { i18n } from "@lingui/core";
const catalogs: Record<string, Record<string, string>> = {
de: {
Activity: "Aktivität",
"activity.title": "Aktivität",
"activity.count": "Ereignisanzahl",
"activity.refresh": "Aktualisieren",
"activity.load-error": "Aktivität konnte nicht geladen werden",
"activity.unavailable": "Nicht verfügbar",
"activity.default-locale": "Standardsprache",
},
};
function loadPluginCatalog() {
const messages = catalogs[i18n.locale];
if (!messages || "activity.title" in i18n.messages) return;
i18n.load(i18n.locale, messages);
}
loadPluginCatalog();
i18n.on("change", loadPluginCatalog);
在匯出元件之前,為了註冊副作用而匯入載入器:
import "./i18n.js";
// Page, widget, panel, and column exports follow.
地區設定變更時,管理端會取代其作用中目錄。change 監聽器還原外掛訊息,訊息 ID 檢查可防止 i18n.load() 觸發迴圈。對於更多地區設定,用外掛的 Lingui 建置產生訊息物件,而不是手工維護。將 @lingui/core 與 @lingui/react 保持為 peer dependency,以便外掛使用主機的共用實例。
儀表板小工具
小工具宣告有必要的 id 以及選擇性的 title 與 size:
admin: {
entry: "@example/plugin-activity/admin",
widgets: [
{ id: "recent-activity", title: "Recent activity", size: "half" },
],
},
在相同 ID 下匯出元件。此小工具讀取與頁面相同的摘要路由,並僅提供卡片內容;EmDash 提供周圍的儀表板卡片與標題。
import { useLingui } from "@lingui/react";
import { useQuery } from "@tanstack/react-query";
import type { PluginAdminExports } from "emdash";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
interface ActivitySummary {
count: number;
}
async function loadSummary(fallbackMessage: string) {
const response = await apiFetch(
"/_emdash/api/plugins/plugin-activity/summary",
);
return parseApiResponse<ActivitySummary>(
response,
fallbackMessage,
);
}
function RecentActivityWidget() {
const { i18n } = useLingui();
const { data, isLoading, isError } = useQuery({
queryKey: ["plugin-activity", "summary"],
queryFn: () =>
loadSummary(
i18n._({ id: "activity.load-error", message: "Could not load activity" }),
),
});
return (
<p>
{i18n._({ id: "activity.count", message: "Event count" })}:{" "}
{isLoading
? "…"
: isError
? i18n._({ id: "activity.unavailable", message: "Unavailable" })
: (data?.count ?? 0)}
</p>
);
}
export const widgets: PluginAdminExports["widgets"] = {
"recent-activity": RecentActivityWidget,
};
主機將元件放在儀表板卡片內,並將 title 轉譯為標題。保持元件精簡,不要新增第二個卡片外殼。size 接受 full、half 或 third;它作為版面提示儲存,但目前儀表板在其回應式兩欄網格中轉譯外掛小工具,且不套用該提示。
內容編輯器面板
編輯器面板會向已儲存項目的設定側欄新增由主機框定的區塊。它不會為新項目掛載,因為尚不存在已儲存的 entry。
面板與內容清單欄直接從受信任的管理模組發現。它們需要 adminEntry 與 admin.entry 以便模組載入,但不需要 admin.pages 或 admin.widgets 中的項目。
import type {
ContentEditorPanelContext,
ContentEditorPanelExtension,
} from "@emdash-cms/admin";
import { useLingui } from "@lingui/react";
function ActivityPanel({ entry, collection, locale }: ContentEditorPanelContext) {
const { i18n } = useLingui();
const displayLocale =
locale ??
i18n._({ id: "activity.default-locale", message: "Default locale" });
return (
<p className="text-sm text-kumo-subtle">
{collection}/{entry.slug} ({displayLocale})
</p>
);
}
export const contentEditorPanels = [
{
id: "activity-summary",
title: "Activity summary",
component: ActivityPanel,
collections: ["posts", "pages"],
order: 10,
},
] satisfies readonly ContentEditorPanelExtension[];
面板欄位具有以下行為:
id、title與component為必要。ID 在此外掛的面板中必須唯一。collections是集合名稱陣列或述詞。省略則對每個集合顯示面板。minRole是數字可見性閾值。它不授權 API 呼叫。order將較小值排在前面。並列時使用外掛 ID 與面板 ID。
元件接收已儲存的 entry、其 collection 以及解析後的 locale。保持版面適配窄側欄。EmDash 隔離元件與集合述詞失敗,使一個面板無法卸載編輯器。
內容清單欄
內容清單欄為作用中集合清單新增唯讀儲存格。主機仍擁有分頁、列操作、載入與空狀態以及表格本身。欄不會在回收站中顯示。
以下欄使用 visibleItems 取得一頁狀態。每個儲存格使用相同的 React Query 鍵,因此請求共用一個結果,而不是每列發出一個請求。
import { useQuery } from "@tanstack/react-query";
import type {
ContentListColumnCellContext,
ContentListColumnExtension,
} from "@emdash-cms/admin";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
async function loadStatuses(
collection: string,
locale: string | undefined,
ids: readonly string[],
) {
const response = await apiFetch(
"/_emdash/api/plugins/plugin-activity/statuses",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ collection, locale, ids }),
},
);
return parseApiResponse<Record<string, string>>(
response,
"Could not load activity statuses",
);
}
function ActivityCell({
item,
visibleItems,
collection,
locale,
}: ContentListColumnCellContext) {
const ids = visibleItems.map((visibleItem) => visibleItem.id);
const { data } = useQuery({
queryKey: ["plugin-activity", "statuses", collection, locale ?? null, ids],
queryFn: () => loadStatuses(collection, locale, ids),
});
return <span>{data?.[item.id] ?? "-"}</span>;
}
export const contentListColumns = [
{
id: "activity",
label: "Activity",
cell: ActivityCell,
collections: ["posts", "pages"],
align: "end",
order: 10,
},
] satisfies readonly ContentListColumnExtension[];
欄欄位具有以下行為:
id、label與cell為必要。ID 在此外掛的欄中必須唯一。header用元件取代標題內容;label仍為主機後援。collections、minRole與order的行為與面板對應項相同。align接受start或end,並對由左至右與由右至左的地區設定使用邏輯對齊。
欄不能新增僅瀏覽器的排序或篩選。這些控制項只會影響已載入的游標頁,而不是整個伺服器端集合。
已停用的外掛
當管理員停用外掛時,EmDash 會從其管理端移除其頁面、小工具、面板與欄。其私有路由回傳找不到,其勾點停止執行。重新啟用外掛會重建勾點管線,並再次使其受信任的管理匯出可用。
打包進入點
將管理模組與伺服器執行階段分開匯出,以便 Astro 可以用主機的 React、Kumo 與 Lingui 實例將其打包到瀏覽器。分發原生外掛 提供完整的套件版面、匯出、peer dependency 與建置命令。