Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-plugin-page-root-resolution.md
Original file line number Diff line number Diff line change
@@ -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/<id>/`), 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.
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ export const widgets = {
```

<Aside type="caution">
Page paths in `pages` must match the `path` values in `admin.pages`. Widget keys must match the `id` values in `admin.widgets`.
Page paths in `pages` should match the `path` values in `admin.pages`. A trailing slash is treated as equivalent, so `/settings` and `/settings/` resolve to the same page. Opening a plugin at its root resolves to the first registered page. Widget keys must match the `id` values in `admin.widgets`.
</Aside>

## Using admin components
Expand Down
4 changes: 2 additions & 2 deletions packages/admin/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down
25 changes: 23 additions & 2 deletions packages/admin/src/lib/plugin-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, React.ComponentType> | undefined,
path: string,
): React.ComponentType | null {
Comment on lines +56 to +62
if (!pages) return null;
const match = pages[path] ?? pages[togglePagePathTrailingSlash(path)];
if (match) return match;
// The Plugin Manager gear opens a plugin at "/plugins/<id>/", 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);
}

/**
Expand Down
50 changes: 50 additions & 0 deletions packages/admin/tests/lib/plugin-context.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Comment on lines +47 to +49
});
2 changes: 1 addition & 1 deletion skills/creating-plugins/references/admin-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading