このページで分かること

  • settingsSchema による設定フォームの自動生成と、保存された設定値の読み方
  • Reactで管理画面を拡張するのに必要な3つの宣言と、管理画面のページ・ダッシュボードのウィジェットの作り方(翻訳の読み込みを含む)
  • 編集画面のパネルとコンテンツ一覧の列の追加、プラグインを無効にしたときの動作、パッケージへのまとめ方
難易度
上級
読む時間
7分
このページの目次

ネイティブ型プラグインは、信頼されたReactコンポーネントをEmDashの管理画面に読み込めます。ナビゲーション、ページのルーティング、ダッシュボードのカード、編集画面のレイアウト、コンテンツのテーブル、認証、エラー境界は、引き続きホストが制御します。プラグインが提供するのは、コンポーネントと、それを配置するために必要なメタデータです。

プラグインに必要なのが設定フォームだけであれば、admin.settingsSchema から始めます。これはホストのフォームコンポーネントを使い、Reactのエントリーポイントを必要としません。この自動生成のフォームはサンドボックス型プラグインでも使えます。このページで説明するReactによる独自の拡張には、ネイティブ型プラグインが必要です。

自動生成の設定フォーム

実行時の定義の中で settingsSchema を宣言します。次のスキーマからは、複数行のテキストフィールド、選択、数値入力、スイッチ、書き込み専用の秘密情報の入力が作られます。

src/index.ts
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 追加のフィールド
string 文字列 defaultmultiline
number 数値 defaultminmax
boolean 真偽値 default
select 文字列 必須の options: Array<{ value, label }> と、省略可能な default
secret 文字列 追加のフィールドはない。保存された値がブラウザーに返されることはない
url 文字列 defaultplaceholder
email 文字列 defaultplaceholder

フォームは、「プラグイン」にあるそのプラグインのカードの設定ボタンから開けます。フォームを読み取ったり変更したりするには、plugins:manage が必要です。

設定は、プラグインごとに名前空間が分かれたKVストアを使います。retentionDays という名前のフィールドは、プラグインからは settings:retentionDays として読めます。

src/index.ts
const retentionDays =
	(await ctx.kv.get<number>("settings:retentionDays")) ?? 30;

スキーマのデフォルト値は、値が保存されていないときに自動生成のフォームを埋めますが、EmDashはそのデフォルト値をKVに書き込みません。実行時に設定を読むときにも、同じ代替値を使います。秘密情報でないフィールドを空にすると、保存された値が削除され、フォームはデフォルト値に戻ります。秘密情報の値がブラウザーに送り返されることはありません。フォームは秘密情報が設定されているかどうかだけを示し、管理者が値を置き換えたり消去したりできるようにします。

設定のラベルと説明は、宣言したとおりに表示されます。これらの文字列を管理画面の表示言語に合わせて変える必要がある場合は、代わりにReactで独自の設定ページを作ります。

本サイトの補足 やさしい解説

公式の対応表では、WordPressのOptions APIは、EmDashのサイトの設定、またはプラグインの ctx.kv にあたります。プラグインの設定は、settingsSchema に項目を並べるだけで、管理画面が設定フォームを自動で作ります。保存された値はプラグイン専用のKVに settings:項目名 として入ります。スキーマのデフォルト値はKVには保存されないため、読むときのコードにも同じデフォルト値を書いておく必要があります。

Reactのエントリーポイント

信頼されたReactの拡張には、互いにつながった3つの宣言があります。

  1. ディスクリプターの adminEntry は、どのモジュールを管理画面にバンドルするかをAstroに伝えます。
  2. 実行時の定義の admin.entryadmin.pagesadmin.widgets は、管理画面に表示される場所を記述します。
  3. 管理画面用のモジュールは、宣言したページのパスとウィジェットのIDに一致するキーを持つ、コンポーネントの対応表をエクスポートします。

ディスクリプターに必要なのはモジュールの指定子だけです。ページとウィジェットのメタデータは、実行時の定義に書きます。

src/index.ts
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" }],
		},
	});
}

adminEntryadmin.entry は同じ値にします。前者はビルド時のインポートで、後者はプラグインが信頼されたReactの管理画面コンポーネントを使うことを実行時に伝えます。

管理画面のページ

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

フィールド 必須 動作
path はい ページを /_emdash/admin/plugins/<plugin-id><path> に配置する。先頭にスラッシュを付ける。
label はい サイドバーとコマンドパレットに表示するラベル。
icon いいえ Phosphorのアイコン名を、ケバブケース、スネークケース、スペース区切り、パスカルケースのいずれかで指定する。不明な名前の場合は、プラグインのアイコンが使われる。

管理画面用のモジュールは、宣言した各パスをReactコンポーネントに対応付けます。末尾のスラッシュはあってもなくても同じに扱われます。/ のページがない場合、プラグインのルートを開くと、エクスポートされた最初のページが開きます。

次のページは、非公開のプラグインルートからデータを読み込みます。操作部品にはKumoを、プラグインのAPIへのリクエストには apiFetch() を使います。apiFetch() は、Cookieで認証する非公開のルートに必要な X-EmDash-Request: 1 ヘッダーを付けます。

src/admin/ActivityPage.tsx
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つのコンテキスト引数を受け取ります。

src/index.ts
routes: {
	summary: {
		permission: "plugins:read",
		handler: async (ctx) => ({
			count: await ctx.storage.events.count(),
		}),
	},
},

非公開のルートのデフォルトは、管理者だけに許可される plugins:manage 権限です。操作に合った、既存の権限のうち最も範囲の狭いものを宣言します。public: true は、認証されていないインターネットからのアクセスを想定したエンドポイントにだけ使います。

管理画面のエントリーポイントからページをエクスポートします。

src/admin/index.tsx
import type { PluginAdminExports } from "emdash";

import { ActivityPage } from "./ActivityPage.js";

export const pages: PluginAdminExports["pages"] = {
	"/activity": ActivityPage,
};

ページのラベルは、管理画面で共有しているLinguiのインスタンスを通して表示されます。Settings のようなラベルは、管理画面に翻訳があればその翻訳が使われます。プラグインは、プラグイン独自のラベルやコンポーネントのメッセージのために、自分のメッセージカタログを共有のインスタンスに読み込めます。読み込まない場合は、宣言した英語のメッセージが代わりに使われます。

プラグインのカタログは、管理画面のエントリーポイントがインポートされたときに読み込み、管理者が表示言語を変えたあとにもう一度読み込みます。次の小さなドイツ語のカタログは、ページとウィジェットの例と同じIDを使っています。

src/admin/i18n.ts
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);

コンポーネントをエクスポートする前に、登録の副作用のために読み込み用のモジュールをインポートします。

src/admin/index.tsx
import "./i18n.js";

// Page, widget, panel, and column exports follow.

管理画面は、表示言語が変わると有効なカタログを置き換えます。change のリスナーがプラグインのメッセージを戻し、メッセージIDの確認によって i18n.load() が繰り返し呼ばれるのを防ぎます。より多くの言語に対応する場合は、メッセージのオブジェクトを手作業で管理せず、プラグインのLinguiのビルドで生成します。プラグインがホストの共有インスタンスを使うように、@lingui/core@lingui/react はpeer dependencyにしておきます。

ダッシュボードのウィジェット

ウィジェットの宣言には、必須の id と、省略可能な titlesize があります。

src/index.ts
admin: {
	entry: "@example/plugin-activity/admin",
	widgets: [
		{ id: "recent-activity", title: "Recent activity", size: "half" },
	],
},

同じIDでコンポーネントをエクスポートします。このウィジェットはページと同じ集計用のルートを読み、カードの中身だけを提供します。周りのダッシュボードのカードと見出しはEmDashが用意します。

src/admin/index.tsx
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 には fullhalfthird を指定できます。これはレイアウトのヒントとして保存されますが、現在のダッシュボードは、このヒントを適用せず、レスポンシブな2列のグリッドにプラグインのウィジェットを描画します。

コンテンツの編集画面のパネル

編集画面のパネルは、保存済みのエントリーの設定用のサイドバーに、ホストが枠を用意したセクションを追加します。新しいエントリーでは、保存済みの entry がまだ存在しないため、パネルは表示されません。

パネルとコンテンツ一覧の列は、信頼された管理画面用のモジュールから直接見つけ出されます。モジュールを読み込むために adminEntryadmin.entry は必要ですが、admin.pagesadmin.widgets に項目を書く必要はありません。

src/admin/index.tsx
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[];

パネルのフィールドの動作は次のとおりです。

  • idtitlecomponent は必須です。IDは、このプラグインのパネルの中で一意である必要があります。
  • collections は、コレクション名の配列か、判定用の関数です。省略すると、すべてのコレクションでパネルが表示されます。
  • minRole は、表示するかどうかを決める数値のしきい値です。APIの呼び出しを認可するものではありません。
  • order は、値の小さいものから順に並べます。同じ値の場合は、プラグインIDとパネルIDで並べます。

コンポーネントは、保存済みの entry、その collection、決定済みの locale を受け取ります。レイアウトは、幅の狭いサイドバーに合わせて調整できるようにします。EmDashは、コンポーネントやコレクションの判定用の関数の失敗を切り離して扱うため、1つのパネルが原因で編集画面全体が表示されなくなることはありません。

コンテンツ一覧の列

コンテンツ一覧の列は、有効なコレクションの一覧に読み取り専用のセルを追加します。ページ分割、行の操作、読み込み中と空の状態の表示、テーブルそのものは、引き続きホストが管理します。列はゴミ箱には表示されません。

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

src/admin/index.tsx
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[];

列のフィールドの動作は次のとおりです。

  • idlabelcell は必須です。IDは、このプラグインの列の中で一意である必要があります。
  • header は、見出しの中身をコンポーネントで置き換えます。label は、ホストが代わりに使う値として残ります。
  • collectionsminRoleorder は、パネルの同じ名前のフィールドと同じように動作します。
  • align には start または end を指定でき、左から右に書く言語と右から左に書く言語の両方に対応した論理的な配置を使います。

列では、ブラウザーだけで動く並べ替えや絞り込みを追加できません。そうした操作は、読み込まれたカーソルのページだけに影響し、サーバー側にあるコレクション全体には影響しないためです。

本サイトの補足 やさしい解説

ネイティブ型プラグインがReactで追加できるのは、管理画面のページ、ダッシュボードのウィジェット、編集画面のパネル、コンテンツ一覧の列の4つです。画面の枠組み(メニュー、カード、テーブルなど)はEmDashの管理画面が用意し、プラグインは中身のコンポーネントだけを渡します。

プラグインを無効にした場合

管理者がプラグインを無効にすると、EmDashはそのプラグインのページ、ウィジェット、パネル、列を管理画面から取り除きます。非公開のルートはnot foundを返し、フックは実行されなくなります。プラグインをもう一度有効にすると、フックの処理の流れが組み立て直され、信頼された管理画面用のエクスポートが再び使えるようになります。

エントリーポイントのパッケージ化

管理画面用のモジュールは、サーバーの実行時の処理とは別にエクスポートします。こうすることで、Astroが、ホストのReact、Kumo、Linguiのインスタンスとともに、ブラウザー向けにバンドルできます。パッケージの完全な構成、エクスポート、peer dependency、ビルドのコマンドは、ネイティブ型プラグインの配布で説明しています。