네이티브 플러그인은 신뢰할 수 있는 React 컴포넌트를 EmDash 관리자에 로드할 수 있습니다. 호스트는 탐색, 페이지 라우팅, 대시보드 카드, 편집기 레이아웃, 콘텐츠 테이블, 인증, 오류 경계를 제어합니다. 플러그인은 컴포넌트와 배치에 필요한 메타데이터를 제공합니다.
플러그인에 설정 폼만 필요하면 admin.settingsSchema로 시작하세요. 호스트의 폼 컴포넌트를 사용하며 React 진입점이 필요 없습니다. 샌드박스 플러그인도 이 생성된 폼을 사용할 수 있습니다. 이 페이지의 사용자 정의 React 확장은 네이티브 플러그인이 필요합니다.
생성된 설정 폼
런타임 정의 안에 settingsSchema를 선언합니다. 다음 스키마는 여러 줄 텍스트 필드, 선택, 숫자 입력, 스위치, 쓰기 전용 비밀 입력을 생성합니다.
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;
스키마 기본값은 저장된 값이 없을 때 생성된 폼을 채우지만, 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()는 쿠키 인증 비공개 경로에 필요한 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를 받으며 레이아웃 힌트로 저장되지만, 현재 대시보드는 반응형 2열 그리드에서 플러그인 위젯을 렌더링하며 그 힌트를 적용하지 않습니다.
콘텐츠 편집기 패널
편집기 패널은 저장된 항목의 설정 사이드바에 호스트가 프레임한 섹션을 추가합니다. 아직 저장된 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는 그 페이지, 위젯, 패널, 열을 관리자에서 제거합니다. 비공개 경로는 not found를 반환하고 훅은 실행을 멈춥니다. 플러그인을 다시 활성화하면 훅 파이프라인이 재구성되고 신뢰할 수 있는 관리자 내보내기를 다시 사용할 수 있게 됩니다.
진입점 패키징
관리자 모듈을 서버 런타임과 별도로 내보내 Astro가 호스트의 React, Kumo, Lingui 인스턴스와 함께 브라우저용으로 번들할 수 있게 하세요. 네이티브 플러그인 배포에 전체 패키지 레이아웃, 내보내기, peer dependency, 빌드 명령이 있습니다.