React 管理拡張

このページ

ネイティブプラグインは、信頼できる 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 は任意です。

typeValueAdditional fields
stringstringdefault, multiline
numbernumberdefault, min, max
booleanbooleandefault
selectstringrequired options: Array<{ value, label }> and optional default
secretstringno additional fields; the stored value is never returned to the browser
urlstringdefault, placeholder
emailstringdefault, placeholder

フォームは Plugins のプラグインカード上の設定コントロールから利用できます。読み取りまたは変更には plugins:manage が必要です。

設定はプラグインの名前空間付き設定ストアを使います。retentionDays という名前のフィールドは、プラグインから retentionDays として利用できます。

const retentionDays =
	(await ctx.settings.get<number>("retentionDays")) ?? 30;

スキーマのデフォルトは、値が保存されていないときに生成フォームを埋めますが、EmDash はそのデフォルトを設定ストアに書き込みません。ランタイムが設定を読むときも同じフォールバックを適用してください。シークレット以外のフィールドをクリアすると保存値が削除され、フォームはデフォルトに戻ります。シークレット値は永続化前に暗号化され、ブラウザには返されません。フォームはシークレットが設定されているかどうかだけを報告し、管理者が置換またはクリアできます。

設定のラベルと説明は宣言どおりにレンダリングされます。それらの文字列を管理画面のロケールに合わせて変える必要がある場合は、代わりにカスタム React 設定ページを構築してください。

React エントリポイント

信頼できる React 拡張には、つながった 3 つの宣言があります。

  1. ディスクリプタの adminEntry は、管理画面にバンドルするモジュールを Astro に伝えます。
  2. ランタイムの admin.entry、admin.pages、admin.widgets は、表示される管理面を記述します。
  3. 管理モジュールは、宣言されたページパスとウィジェット 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 管理コンポーネントを使うことをランタイムに伝えます。

管理ページ

各ページ宣言には次のフィールドがあります。

FieldRequiredBehavior
pathYesMounts the page at /_emdash/admin/plugins/<plugin-id><path>. Use a leading slash.
labelYesSupplies the sidebar and command-palette label.
iconNoNames 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>
	);
}

ネイティブランタイムに対応するルートを定義します。ネイティブハンドラはコンテキスト引数を 1 つ受け取ります。

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 を見出しとしてレンダリングします。コンポーネントはコンパクトに保ち、2 つ目のカードシェルを追加しないでください。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 はコンポーネントとコレクション述語の失敗を隔離し、1 つのパネルがエディターをアンマウントできないようにします。

コンテンツリスト列

コンテンツリスト列は、アクティブなコレクションリストに読み取り専用セルを追加します。ページネーション、行アクション、読み込み・空状態、テーブル自体は引き続きホストが所有します。列はゴミ箱には表示されません。

次の列は visibleItems を使って 1 ページ分のステータスを取得します。各セルは同じ React Query キーを使うため、行ごとに 1 リクエストするのではなく、結果を共有します。

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、ビルドコマンドがあります。