diff --git a/.changeset/fix-plugin-page-root-resolution.md b/.changeset/fix-plugin-page-root-resolution.md new file mode 100644 index 000000000..c4f3825ae --- /dev/null +++ b/.changeset/fix-plugin-page-root-resolution.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes plugin admin pages showing a Plugin Error when opened from the Plugin Manager. The Settings gear opens a plugin at its root (`/plugins//`), but a plugin that registers its page at `/settings` rather than `/` had no page there, so the admin fell through to a 404. Plugin page resolution now resolves the plugin root to the first registered page and treats `/settings` and `/settings/` as the same path, so a single registration works from the Plugin Manager gear and the sidebar regardless of trailing slash. 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 ed81338ba..5c6207e30 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,7 +224,7 @@ export const widgets = { ``` ## Using admin components diff --git a/packages/admin/src/components/Sidebar.tsx b/packages/admin/src/components/Sidebar.tsx index edcb5a185..0c4c2f976 100644 --- a/packages/admin/src/components/Sidebar.tsx +++ b/packages/admin/src/components/Sidebar.tsx @@ -23,7 +23,7 @@ import * as React from "react"; import { fetchCommentCounts } from "../lib/api/comments"; import { useCurrentUser } from "../lib/api/current-user"; -import { usePluginAdmins } from "../lib/plugin-context"; +import { resolvePluginPagePath, usePluginAdmins } from "../lib/plugin-context"; import { cn } from "../lib/utils"; import { BrandIcon } from "./Logo.js"; @@ -252,7 +252,7 @@ export function SidebarNav({ manifest }: SidebarNavProps) { const pluginPages = pluginAdmins[pluginId]?.pages; const isBlocksMode = config.adminMode === "blocks"; for (const page of config.adminPages) { - if (!isBlocksMode && !pluginPages?.[page.path]) continue; + if (!isBlocksMode && !resolvePluginPagePath(pluginPages, page.path)) continue; const label = page.label || pluginId diff --git a/packages/admin/src/lib/plugin-context.tsx b/packages/admin/src/lib/plugin-context.tsx index cba658164..f07e80783 100644 --- a/packages/admin/src/lib/plugin-context.tsx +++ b/packages/admin/src/lib/plugin-context.tsx @@ -48,12 +48,33 @@ export function usePluginWidget(pluginId: string, widgetId: string): React.Compo return admins[pluginId]?.widgets?.[widgetId] ?? null; } +function togglePagePathTrailingSlash(path: string): string { + if (path === "/") return path; + return path.endsWith("/") ? path.slice(0, -1) : `${path}/`; +} + +/** + * Resolve a plugin page component by path, treating a trailing slash as equivalent and falling back to the first registered page at the root + */ +export function resolvePluginPagePath( + pages: Record | undefined, + path: string, +): React.ComponentType | null { + if (!pages) return null; + const match = pages[path] ?? pages[togglePagePathTrailingSlash(path)]; + if (match) return match; + // The Plugin Manager gear opens a plugin at "/plugins//", so the root + // falls back to the first registered page when no page is keyed at "/". + if (path === "/") return Object.values(pages)[0] ?? null; + return null; +} + /** - * Get a plugin page component by plugin ID and path + * Get a plugin page component by plugin ID and path, with trailing-slash and root-fallback resolution */ export function usePluginPage(pluginId: string, path: string): React.ComponentType | null { const admins = useContext(PluginAdminContext); - return admins[pluginId]?.pages?.[path] ?? null; + return resolvePluginPagePath(admins[pluginId]?.pages, path); } /** diff --git a/packages/admin/tests/lib/plugin-context.test.ts b/packages/admin/tests/lib/plugin-context.test.ts new file mode 100644 index 000000000..0d75805df --- /dev/null +++ b/packages/admin/tests/lib/plugin-context.test.ts @@ -0,0 +1,50 @@ +import type * as React from "react"; +import { describe, it, expect } from "vitest"; + +import { resolvePluginPagePath } from "../../src/lib/plugin-context"; + +const SettingsPage: React.ComponentType = () => null; +const SettingsPageWithSlash: React.ComponentType = () => null; +const RootPage: React.ComponentType = () => null; +const AdvancedPage: React.ComponentType = () => null; + +describe("resolvePluginPagePath", () => { + it("resolves an exact page path", () => { + expect(resolvePluginPagePath({ "/settings": SettingsPage }, "/settings")).toBe(SettingsPage); + }); + + it("resolves a trailing-slash path to the page registered without one", () => { + expect(resolvePluginPagePath({ "/settings": SettingsPage }, "/settings/")).toBe(SettingsPage); + }); + + it("resolves a path without a trailing slash to the page registered with one", () => { + expect(resolvePluginPagePath({ "/settings/": SettingsPage }, "/settings")).toBe(SettingsPage); + }); + + it("prefers an exact match when both slash variants are registered", () => { + const pages = { "/settings": SettingsPage, "/settings/": SettingsPageWithSlash }; + expect(resolvePluginPagePath(pages, "/settings")).toBe(SettingsPage); + expect(resolvePluginPagePath(pages, "/settings/")).toBe(SettingsPageWithSlash); + }); + + it("resolves the root path to a page registered at the root", () => { + expect(resolvePluginPagePath({ "/": RootPage }, "/")).toBe(RootPage); + }); + + it("falls back to the first registered page at the root when no root page exists", () => { + expect(resolvePluginPagePath({ "/settings": SettingsPage }, "/")).toBe(SettingsPage); + }); + + it("falls back to the first of several pages at the root", () => { + const pages = { "/settings": SettingsPage, "/advanced": AdvancedPage }; + expect(resolvePluginPagePath(pages, "/")).toBe(SettingsPage); + }); + + it("does not fall back for an unregistered non-root path", () => { + expect(resolvePluginPagePath({ "/settings": SettingsPage }, "/missing")).toBeNull(); + }); + + it("returns null when the plugin has no pages", () => { + expect(resolvePluginPagePath(undefined, "/settings")).toBeNull(); + }); +}); diff --git a/skills/creating-plugins/references/admin-ui.md b/skills/creating-plugins/references/admin-ui.md index e2289e1a6..9e26b8dc8 100644 --- a/skills/creating-plugins/references/admin-ui.md +++ b/skills/creating-plugins/references/admin-ui.md @@ -12,7 +12,7 @@ import { SettingsPage } from "./components/SettingsPage"; import { ReportsPage } from "./components/ReportsPage"; import { StatusWidget } from "./components/StatusWidget"; -// Pages keyed by path (must match admin.pages paths) +// Pages keyed by path (trailing slash optional, so /settings and /settings/ both resolve) export const pages = { "/settings": SettingsPage, "/reports": ReportsPage,