diff --git a/.changeset/calm-cats-edit.md b/.changeset/calm-cats-edit.md
new file mode 100644
index 0000000000..c444e6fdf3
--- /dev/null
+++ b/.changeset/calm-cats-edit.md
@@ -0,0 +1,6 @@
+---
+"emdash": minor
+"@emdash-cms/admin": minor
+---
+
+Add a trusted plugin extension point for contributing isolated, host-framed panels to the saved content editor settings sidebar. Panels support collection and role filtering, deterministic ordering, manifest lifecycle checks, and render-error recovery.
diff --git a/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx b/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx
index a44c92c934..0a511245bf 100644
--- a/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx
+++ b/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx
@@ -224,9 +224,46 @@ export function SEOWidget() {
Widgets wrap automatically based on screen width.
+## Content editor panels
+
+A trusted React plugin can add host-framed sections to the settings sidebar for saved content entries. EmDash owns the section heading and placement, applies collection and role filters, and isolates rendering failures so one plugin panel cannot unmount the editor.
+
+Export a `contentEditorPanels` array from the plugin admin entry:
+
+```tsx title="src/admin.tsx"
+import type { ContentEditorPanelContext } from "@emdash-cms/admin";
+
+function ContentInsights({ entry, locale }: ContentEditorPanelContext) {
+ return (
+
+ Analysis for {entry.slug} in {locale ?? "the default locale"}
+
+ );
+}
+
+export const contentEditorPanels = [
+ {
+ id: "content-insights",
+ title: "Content insights",
+ component: ContentInsights,
+ collections: ["posts", "pages"],
+ minRole: 40,
+ order: 10,
+ },
+];
+```
+
+Each panel receives the saved `entry`, its `collection`, and resolved `locale`. Panels are not mounted for new, unsaved entries. `collections` may be an array or a predicate and can be omitted to support every collection. Lower `order` values render first among contributed panels; ties are resolved deterministically by plugin and panel ID.
+
+Panel IDs must be unique within the plugin. Keep panel content responsive to the narrow settings sidebar and perform authorization in plugin API routes rather than relying on `minRole`, which only controls visibility.
+
+
+
## Export structure
-The admin entry point exports two objects:
+The admin entry point exports the objects used by the features it implements:
```typescript title="src/admin.tsx"
import { SettingsPage } from "./components/SettingsPage";
diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx
index 8607665641..dff368847d 100644
--- a/packages/admin/src/components/ContentEditor.tsx
+++ b/packages/admin/src/components/ContentEditor.tsx
@@ -847,6 +847,7 @@ export function ContentEditor({
collection={collection}
item={item}
isNew={isNew}
+ manifest={manifest}
entryLocale={entryLocale}
slug={slug}
onSlugChange={handleSlugChange}
diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx
index 9516fa7778..4a2cf80795 100644
--- a/packages/admin/src/components/ContentSettingsPanel.tsx
+++ b/packages/admin/src/components/ContentSettingsPanel.tsx
@@ -17,6 +17,7 @@ import type { Editor } from "@tiptap/react";
import * as React from "react";
import type {
+ AdminManifest,
BylineCreditInput,
BylineSummary,
ContentItem,
@@ -25,8 +26,13 @@ import type {
UserListItem,
} from "../lib/api";
import { fetchBylines } from "../lib/api";
+import {
+ ContentEditorPanelBoundary,
+ resolveContentEditorPanels,
+} from "../lib/content-editor-panels";
import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js";
import { useDebouncedValue } from "../lib/hooks.js";
+import { usePluginAdmins } from "../lib/plugin-context";
import { cn, slugify } from "../lib/utils";
import type { CurrentUserInfo } from "./ContentEditor.js";
import { ContentStatusBadge, isContentStatusState } from "./ContentStatusBadge.js";
@@ -290,6 +296,7 @@ export interface ContentSettingsPanelProps {
collection: string;
item?: ContentItem | null;
isNew?: boolean;
+ manifest?: AdminManifest | null;
/** Locale this entry is bound to (URL `?locale=` for new entries). */
entryLocale?: string | null;
slug: string;
@@ -346,6 +353,7 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
collection,
item,
isNew,
+ manifest,
entryLocale,
slug,
onSlugChange,
@@ -385,6 +393,19 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
}: ContentSettingsPanelProps) {
const { t } = useLingui();
const navigate = useNavigate();
+ const pluginAdmins = usePluginAdmins();
+ const extensionPanels = React.useMemo(
+ () =>
+ !isNew && item
+ ? resolveContentEditorPanels(
+ pluginAdmins,
+ collection,
+ currentUser?.role ?? 0,
+ manifest?.plugins,
+ )
+ : [],
+ [collection, currentUser?.role, isNew, item, manifest?.plugins, pluginAdmins],
+ );
const [scheduleDate, setScheduleDate] = React.useState("");
const [showScheduler, setShowScheduler] = React.useState(false);
@@ -680,6 +701,35 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({
)}
+ {item &&
+ extensionPanels.map(({ pluginId, extension }) => {
+ const Panel = extension.component;
+ const sectionId = `plugin:${pluginId}:${extension.id}`;
+
+ return (
+
+
+
+ {extension.title}
+
+
+
+
+
+
+
+
+ );
+ })}
+
{portableTextEditor && (
diff --git a/packages/admin/src/components/SortableContentSettingsSections.tsx b/packages/admin/src/components/SortableContentSettingsSections.tsx
index dabd3dc030..090c6c1636 100644
--- a/packages/admin/src/components/SortableContentSettingsSections.tsx
+++ b/packages/admin/src/components/SortableContentSettingsSections.tsx
@@ -90,7 +90,6 @@ export function SortableContentSettingsSections({
setStoredLayout(readStoredLayout(storageKey));
}, [storageKey]);
- const layout = React.useMemo(() => resolveContentSettingsLayout(storedLayout), [storedLayout]);
const sectionsById = React.useMemo(() => {
const sections = React.Children.toArray(children).filter(
(child): child is React.ReactElement =>
@@ -98,6 +97,11 @@ export function SortableContentSettingsSections({
);
return new Map(sections.map((section) => [section.props.id, section]));
}, [children]);
+ const sectionIds = React.useMemo(() => [...sectionsById.keys()], [sectionsById]);
+ const layout = React.useMemo(
+ () => resolveContentSettingsLayout(storedLayout, sectionIds),
+ [sectionIds, storedLayout],
+ );
const visibleIds = React.useMemo(
() => layout.order.filter((id) => sectionsById.has(id)),
[layout.order, sectionsById],
@@ -108,7 +112,7 @@ export function SortableContentSettingsSections({
);
const handleDragStart = React.useCallback(
(event: DragStartEvent) => {
- setActiveId(String(event.active.id) as ContentSettingsSectionId);
+ setActiveId(String(event.active.id));
onSortingChange?.(true);
},
[onSortingChange],
@@ -123,11 +127,11 @@ export function SortableContentSettingsSections({
setActiveId(null);
onSortingChange?.(false);
if (event.over && event.active.id !== event.over.id) {
- const movedId = String(event.active.id) as ContentSettingsSectionId;
- const overId = String(event.over.id) as ContentSettingsSectionId;
+ const movedId = String(event.active.id);
+ const overId = String(event.over.id);
setStoredLayout((current) => {
const next = reorderContentSettingsLayout(
- resolveContentSettingsLayout(current),
+ resolveContentSettingsLayout(current, sectionIds),
movedId,
overId,
);
@@ -136,7 +140,7 @@ export function SortableContentSettingsSections({
});
}
},
- [onSortingChange, storageKey],
+ [onSortingChange, sectionIds, storageKey],
);
return (
diff --git a/packages/admin/src/index.ts b/packages/admin/src/index.ts
index 08b943ba2a..2af80a5a52 100644
--- a/packages/admin/src/index.ts
+++ b/packages/admin/src/index.ts
@@ -13,6 +13,11 @@ export * from "./lib/api";
// Utilities
export { cn } from "./lib/utils";
+export {
+ type ContentEditorPanelContext,
+ type ContentEditorPanelExtension,
+} from "./lib/content-editor-panels";
+
// Plugin admin context (for accessing plugin components)
export {
PluginAdminProvider,
diff --git a/packages/admin/src/lib/content-editor-panels.tsx b/packages/admin/src/lib/content-editor-panels.tsx
new file mode 100644
index 0000000000..8b09c0e40b
--- /dev/null
+++ b/packages/admin/src/lib/content-editor-panels.tsx
@@ -0,0 +1,206 @@
+import { Button } from "@cloudflare/kumo";
+import { Trans } from "@lingui/react/macro";
+import * as React from "react";
+
+import type { AdminManifest, ContentItem } from "./api";
+
+export interface ContentEditorPanelContext {
+ collection: string;
+ entry: ContentItem;
+ locale?: string;
+}
+
+export interface ContentEditorPanelExtension {
+ /** Stable identifier, unique within this plugin's editor panels. */
+ id: string;
+ /** Host-rendered section heading. */
+ title: string;
+ component: React.ComponentType;
+ /** Restrict this panel to selected collections. Omit to support every collection. */
+ collections?: readonly string[] | ((collection: string) => boolean);
+ /** Minimum numeric admin role required to see this panel. */
+ minRole?: number;
+ /** Lower values render first among contributed panels. */
+ order?: number;
+}
+
+export interface ResolvedContentEditorPanel {
+ pluginId: string;
+ extension: ContentEditorPanelExtension;
+}
+
+type ContentEditorPanelRegistry = Record<
+ string,
+ { contentEditorPanels?: readonly ContentEditorPanelExtension[] } | undefined
+>;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+function warn(pluginId: string, message: string): void {
+ console.warn(`[content-editor-panels] Plugin "${pluginId}": ${message}`);
+}
+
+function isValidCollections(value: unknown): boolean {
+ return (
+ value === undefined ||
+ typeof value === "function" ||
+ (Array.isArray(value) && value.every((collection) => typeof collection === "string"))
+ );
+}
+
+function isValidPanel(value: unknown, pluginId: string): value is ContentEditorPanelExtension {
+ if (!isRecord(value)) {
+ warn(pluginId, "ignored a panel that is not an object.");
+ return false;
+ }
+ if (typeof value.id !== "string" || value.id.trim() === "") {
+ warn(pluginId, "ignored a panel without a non-empty id.");
+ return false;
+ }
+ if (typeof value.title !== "string" || value.title.trim() === "") {
+ warn(pluginId, `ignored panel "${value.id}" because its title is invalid.`);
+ return false;
+ }
+ if (typeof value.component !== "function") {
+ warn(pluginId, `ignored panel "${value.id}" because its component is invalid.`);
+ return false;
+ }
+ if (!isValidCollections(value.collections)) {
+ warn(
+ pluginId,
+ `ignored panel "${value.id}" because collections must be an array of strings or a predicate.`,
+ );
+ return false;
+ }
+ if (value.minRole !== undefined && !Number.isFinite(value.minRole)) {
+ warn(pluginId, `ignored panel "${value.id}" because minRole must be finite.`);
+ return false;
+ }
+ if (value.order !== undefined && !Number.isFinite(value.order)) {
+ warn(pluginId, `ignored panel "${value.id}" because order must be finite.`);
+ return false;
+ }
+ return true;
+}
+
+function appliesToCollection(
+ pluginId: string,
+ panel: ContentEditorPanelExtension,
+ collection: string,
+): boolean {
+ if (panel.collections === undefined) return true;
+ if (typeof panel.collections !== "function") {
+ return panel.collections.includes(collection);
+ }
+
+ try {
+ return panel.collections(collection);
+ } catch (error) {
+ console.error(
+ `Plugin "${pluginId}" failed while checking content editor panel "${panel.id}".`,
+ error,
+ );
+ return false;
+ }
+}
+
+/**
+ * Select valid trusted-plugin panels for one saved content editor.
+ * Invalid, disabled, unauthorized, and inapplicable contributions are omitted
+ * so the host settings sidebar remains usable.
+ */
+export function resolveContentEditorPanels(
+ pluginAdmins: ContentEditorPanelRegistry,
+ collection: string,
+ userRole: number,
+ pluginStates?: AdminManifest["plugins"],
+): ResolvedContentEditorPanel[] {
+ const resolved: ResolvedContentEditorPanel[] = [];
+ const seen = new Set();
+
+ for (const pluginId of Object.keys(pluginAdmins).toSorted()) {
+ const pluginState = pluginStates?.[pluginId];
+ if (pluginStates && (!pluginState || pluginState.enabled === false)) continue;
+
+ const panels: unknown = pluginAdmins[pluginId]?.contentEditorPanels;
+ if (panels === undefined) continue;
+ if (!Array.isArray(panels)) {
+ warn(pluginId, "ignored contentEditorPanels because it is not an array.");
+ continue;
+ }
+
+ for (const candidate of panels) {
+ if (!isValidPanel(candidate, pluginId)) continue;
+ const identity = `${pluginId}:${candidate.id}`;
+ if (seen.has(identity)) {
+ warn(pluginId, `ignored duplicate panel id "${candidate.id}".`);
+ continue;
+ }
+ seen.add(identity);
+
+ if (!appliesToCollection(pluginId, candidate, collection)) continue;
+ if (candidate.minRole !== undefined && userRole < candidate.minRole) continue;
+ resolved.push({ pluginId, extension: candidate });
+ }
+ }
+
+ return resolved.toSorted(
+ (a, b) =>
+ (a.extension.order ?? 0) - (b.extension.order ?? 0) ||
+ a.pluginId.localeCompare(b.pluginId) ||
+ a.extension.id.localeCompare(b.extension.id),
+ );
+}
+
+interface ContentEditorPanelBoundaryProps {
+ pluginId: string;
+ panelId: string;
+ children: React.ReactNode;
+}
+
+interface ContentEditorPanelBoundaryState {
+ hasError: boolean;
+}
+
+/** Prevents one faulty trusted panel from unmounting the content editor. */
+export class ContentEditorPanelBoundary extends React.Component<
+ ContentEditorPanelBoundaryProps,
+ ContentEditorPanelBoundaryState
+> {
+ override state: ContentEditorPanelBoundaryState = { hasError: false };
+
+ static getDerivedStateFromError(): ContentEditorPanelBoundaryState {
+ return { hasError: true };
+ }
+
+ override componentDidCatch(error: Error, info: React.ErrorInfo): void {
+ console.error(
+ `Plugin "${this.props.pluginId}" failed while rendering content editor panel "${this.props.panelId}".`,
+ error,
+ info,
+ );
+ }
+
+ override render(): React.ReactNode {
+ if (!this.state.hasError) return this.props.children;
+
+ return (
+
+
+ Plugin panel unavailable.
+
+
+
+ );
+ }
+}
diff --git a/packages/admin/src/lib/content-settings-layout.ts b/packages/admin/src/lib/content-settings-layout.ts
index ee42e4fd5c..58be005b29 100644
--- a/packages/admin/src/lib/content-settings-layout.ts
+++ b/packages/admin/src/lib/content-settings-layout.ts
@@ -11,23 +11,17 @@ export const DEFAULT_CONTENT_SETTINGS_SECTION_ORDER = [
"revisions",
] as const;
-export type ContentSettingsSectionId = (typeof DEFAULT_CONTENT_SETTINGS_SECTION_ORDER)[number];
+export type ContentSettingsSectionId = string;
export interface ContentSettingsLayout {
version: typeof CONTENT_SETTINGS_LAYOUT_VERSION;
order: ContentSettingsSectionId[];
}
-const KNOWN_SECTION_IDS = new Set(DEFAULT_CONTENT_SETTINGS_SECTION_ORDER);
-
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null;
}
-function isKnownSectionId(value: unknown): value is ContentSettingsSectionId {
- return typeof value === "string" && KNOWN_SECTION_IDS.has(value);
-}
-
/** Parse a browser preference without allowing malformed state to break the editor. */
export function parseContentSettingsLayout(raw: string | null): ContentSettingsLayout | null {
if (!raw) return null;
@@ -44,7 +38,7 @@ export function parseContentSettingsLayout(raw: string | null): ContentSettingsL
return {
version: CONTENT_SETTINGS_LAYOUT_VERSION,
- order: value.order.filter(isKnownSectionId),
+ order: value.order.filter((id): id is ContentSettingsSectionId => typeof id === "string"),
};
} catch {
return null;
@@ -52,20 +46,22 @@ export function parseContentSettingsLayout(raw: string | null): ContentSettingsL
}
/**
- * Reconcile saved order with the current defaults. Duplicate and unknown ids
- * disappear, while sections introduced by a later EmDash version append.
+ * Reconcile saved order with the sections available in the current editor.
+ * Duplicate and unavailable ids disappear, while newly registered sections append.
*/
export function resolveContentSettingsLayout(
stored: ContentSettingsLayout | null,
+ availableSectionIds: readonly ContentSettingsSectionId[] = DEFAULT_CONTENT_SETTINGS_SECTION_ORDER,
): ContentSettingsLayout {
const seen = new Set();
+ const available = new Set(availableSectionIds);
const order = (stored?.order ?? []).filter((id) => {
- if (seen.has(id)) return false;
+ if (!available.has(id) || seen.has(id)) return false;
seen.add(id);
return true;
});
- for (const id of DEFAULT_CONTENT_SETTINGS_SECTION_ORDER) {
+ for (const id of availableSectionIds) {
if (seen.has(id)) continue;
order.push(id);
seen.add(id);
diff --git a/packages/admin/src/lib/plugin-context.tsx b/packages/admin/src/lib/plugin-context.tsx
index f07e807830..fb0e6e2a12 100644
--- a/packages/admin/src/lib/plugin-context.tsx
+++ b/packages/admin/src/lib/plugin-context.tsx
@@ -1,7 +1,7 @@
/**
* Plugin Admin Context
*
- * Provides plugin admin modules (widgets, pages, fields) to the admin UI
+ * Provides plugin admin modules (widgets, pages, fields, editor panels) to the admin UI
* via React context. This avoids cross-module registry issues by keeping
* everything in React's component tree.
*/
@@ -9,11 +9,14 @@
import * as React from "react";
import { createContext, useContext } from "react";
+import type { ContentEditorPanelExtension } from "./content-editor-panels";
+
/** Shape of a plugin's admin exports */
export interface PluginAdminModule {
widgets?: Record;
pages?: Record;
fields?: Record;
+ contentEditorPanels?: readonly ContentEditorPanelExtension[];
}
/** All plugin admin modules keyed by plugin ID */
diff --git a/packages/admin/tests/components/ContentSettingsPanel.test.tsx b/packages/admin/tests/components/ContentSettingsPanel.test.tsx
index 24f9e95bbe..d165022d03 100644
--- a/packages/admin/tests/components/ContentSettingsPanel.test.tsx
+++ b/packages/admin/tests/components/ContentSettingsPanel.test.tsx
@@ -2,7 +2,7 @@ import { i18n } from "@lingui/core";
import { act, fireEvent } from "@testing-library/react";
import type { Editor } from "@tiptap/react";
import * as React from "react";
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ContentEditorProps } from "../../src/components/ContentEditor";
import {
@@ -12,7 +12,9 @@ import {
type SettingsActionBarProps,
} from "../../src/components/ContentSettingsPanel";
import type { BlockSidebarPanel } from "../../src/components/PortableTextEditor";
-import type { ContentItem } from "../../src/lib/api";
+import type { AdminManifest, ContentItem } from "../../src/lib/api";
+import type { ContentEditorPanelContext } from "../../src/lib/content-editor-panels";
+import { PluginAdminProvider, type PluginAdmins } from "../../src/lib/plugin-context";
import { render } from "../utils/render.tsx";
// Mock child components with their own data fetching so the panel tests
@@ -79,6 +81,27 @@ const USERS = [
{ id: "u1", name: "Editor One", email: "editor@example.com", role: 40 },
] as ContentSettingsPanelProps["users"];
+const TEST_MANIFEST: AdminManifest = {
+ version: "0.30.0",
+ hash: "test",
+ collections: {},
+ plugins: { insights: { enabled: true } },
+};
+
+function InsightsPanel({ entry, locale }: ContentEditorPanelContext) {
+ return (
+