原生插件可以将受信任的 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 与构建命令。