diff --git a/.changeset/plugin-directory-browse.md b/.changeset/plugin-directory-browse.md
new file mode 100644
index 0000000000..bdf400a14c
--- /dev/null
+++ b/.changeset/plugin-directory-browse.md
@@ -0,0 +1,32 @@
+---
+"nextly": patch
+"create-nextly-app": patch
+"@nextlyhq/admin": patch
+"@nextlyhq/admin-css": patch
+"@nextlyhq/blocks-engine": patch
+"@nextlyhq/blocks-react": patch
+"@nextlyhq/ui": patch
+"@nextlyhq/adapter-drizzle": patch
+"@nextlyhq/adapter-postgres": patch
+"@nextlyhq/adapter-mysql": patch
+"@nextlyhq/adapter-sqlite": patch
+"@nextlyhq/storage-s3": patch
+"@nextlyhq/storage-uploadthing": patch
+"@nextlyhq/storage-vercel-blob": patch
+"@nextlyhq/plugin-form-builder": patch
+"@nextlyhq/plugin-page-builder": patch
+"@nextlyhq/plugin-seo": patch
+"@nextlyhq/plugin-sdk": patch
+"@nextlyhq/eslint-config": patch
+"@nextlyhq/prettier-config": patch
+"@nextlyhq/telemetry": patch
+"@nextlyhq/tsconfig": patch
+"@nextlyhq/builder": patch
+"@nextlyhq/module-specifiers": patch
+---
+
+The admin now has a plugin directory, at Plugins then Browse plugins.
+
+It lists the plugins Nextly publishes with a description, category and author, marks the ones already installed, and searches by name, description and tags. A curated row sits above the grid while there is more in the grid than in the row.
+
+It is discovery only. Installing a plugin means adding a dependency and a line to `nextly.config.ts`, so the directory never writes to your source or changes plugin state. Where a listed plugin is already installed, its own icon and description are shown rather than the directory's copy of them.
diff --git a/packages/admin/src/components/features/dashboard/DynamicPluginNav.test.tsx b/packages/admin/src/components/features/dashboard/DynamicPluginNav.test.tsx
index 19070770c8..65ac81f685 100644
--- a/packages/admin/src/components/features/dashboard/DynamicPluginNav.test.tsx
+++ b/packages/admin/src/components/features/dashboard/DynamicPluginNav.test.tsx
@@ -44,9 +44,34 @@ vi.mock("@admin/hooks/queries", () => ({
}));
import { DynamicPluginNav } from "@admin/components/features/dashboard/DynamicPluginNav";
import { SidebarProvider } from "@admin/components/layout/sidebar";
+import { useSidebarNavigation } from "@admin/hooks/useSidebarNavigation";
const noop = () => false;
+/**
+ * Renders the panel with the REAL `isActive`, for the one pathname given.
+ *
+ * The sidebar's matcher rather than one written here: a copy would agree on
+ * the day it was written and then answer for itself, and what is under test is
+ * how the overview composes with that matcher, not whether the composition can
+ * be restated.
+ *
+ * Empty navigation items because `isActive` reads only the pathname; the items
+ * feed accordion state, which nothing here asserts on.
+ */
+function ActiveStateHarness({ pathname }: { pathname: string }) {
+ const { isActive } = useSidebarNavigation([], pathname);
+ return ;
+}
+
+function renderAt(pathname: string) {
+ return render(
+
+
+
+ );
+}
+
// The real provider rather than a mocked `useSidebar`: the component reads
// collapsed state from it, so a stub would let a change to that contract pass
// unnoticed here.
@@ -192,6 +217,41 @@ describe("DynamicPluginNav", () => {
});
});
+/**
+ * Which sidebar entry the overview claims.
+ *
+ * A plugin's own pages are descendants of `/admin/plugins`, so an exact match
+ * would leave the secondary navigation with nothing selected while reading a
+ * plugin's detail page. The directory is the case pulling the other way, and
+ * it is answered by living under a different prefix rather than by a rule
+ * here — which is what the last case pins.
+ */
+describe("DynamicPluginNav overview active state", () => {
+ function overviewActive() {
+ return screen
+ .getByRole("link", { name: /installed plugins/i })
+ .getAttribute("data-active");
+ }
+
+ it.each([
+ ["/admin/plugins", "true"],
+ ["/admin/plugins/acme-forms", "true"],
+ ["/admin/plugins/acme-forms/settings", "true"],
+ // A plugin that happens to be called "browse" is still a plugin, and its
+ // page belongs to this entry like any other.
+ ["/admin/plugins/browse", "true"],
+ // The directory, which Browse owns. It is outside the prefix, so this is
+ // false without the overview having to exclude anything.
+ ["/admin/plugin-directory", "false"],
+ ])("at %s the overview is active=%s", (pathname, expected) => {
+ mockBranding = { plugins: [] } as unknown as AdminBranding;
+
+ renderAt(pathname);
+
+ expect(overviewActive()).toBe(expected);
+ });
+});
+
describe("DynamicPluginNav expanded", () => {
/**
* A group is expandable when it retains a collection, and only then.
diff --git a/packages/admin/src/components/features/dashboard/DynamicPluginNav.tsx b/packages/admin/src/components/features/dashboard/DynamicPluginNav.tsx
index 7ed82c2ebf..fad8200700 100644
--- a/packages/admin/src/components/features/dashboard/DynamicPluginNav.tsx
+++ b/packages/admin/src/components/features/dashboard/DynamicPluginNav.tsx
@@ -56,7 +56,11 @@ function PluginOverviewLink({
}: {
isActive: (href?: string) => boolean;
}) {
- const active = isActive("/admin/plugins");
+ // The whole subtree, so a plugin's own pages — `/admin/plugins/` and
+ // its settings — keep the overview selected. Nothing has to be subtracted
+ // here: the directory lives outside this prefix, so it cannot be caught by
+ // a descendant match and light both entries at once.
+ const active = isActive(ROUTES.PLUGINS);
return (
diff --git a/packages/admin/src/components/icons/index.ts b/packages/admin/src/components/icons/index.ts
index b7089ae806..4931e48e29 100644
--- a/packages/admin/src/components/icons/index.ts
+++ b/packages/admin/src/components/icons/index.ts
@@ -108,6 +108,7 @@ export {
Italic, // Rich text editor
Key,
Laptop,
+ Layout, // Plugin appearance: the icon @nextlyhq/plugin-page-builder declares
LayoutGrid, // Collection Builder: blocks field
Layers,
LayoutDashboard,
@@ -219,3 +220,5 @@ export const Discord = ({ size = 24, ...props }: LucideProps) => {
})
);
};
+
+export type { AdminIconName } from "./names";
diff --git a/packages/admin/src/components/icons/names.ts b/packages/admin/src/components/icons/names.ts
new file mode 100644
index 0000000000..e081fc3c95
--- /dev/null
+++ b/packages/admin/src/components/icons/names.ts
@@ -0,0 +1,25 @@
+/**
+ * The set of icon names the barrel can render.
+ *
+ * Its own module rather than a line in the barrel, because the type has to
+ * refer to the barrel's own exports and a module cannot describe itself
+ * without the inline `import()` form that this package's lint rules forbid.
+ *
+ * @module components/icons/names
+ */
+import type * as Icons from "./index";
+
+/**
+ * Every icon name this admin can render, derived from the exports rather than
+ * listed beside them, so a name and the export it refers to cannot disagree.
+ *
+ * Curated data that picks an icon — the plugin catalogue — types its icon
+ * field as this, and a name the barrel does not carry stops compiling instead
+ * of resolving to nothing and silently rendering the caller's generic
+ * fallback.
+ *
+ * Not for icon names that arrive at runtime: a third-party plugin declares
+ * `appearance.icon` as a free string this admin cannot constrain, so that path
+ * keeps its fallback.
+ */
+export type AdminIconName = keyof typeof Icons;
diff --git a/packages/admin/src/components/layout/sidebar/DualSidebar.tsx b/packages/admin/src/components/layout/sidebar/DualSidebar.tsx
index fa3ae74c6f..ef753415ef 100644
--- a/packages/admin/src/components/layout/sidebar/DualSidebar.tsx
+++ b/packages/admin/src/components/layout/sidebar/DualSidebar.tsx
@@ -23,6 +23,7 @@ import {
import { resolveCollectionPlacement } from "@admin/lib/plugins/collection-placement";
import { pluginSlug } from "@admin/lib/plugins/plugin-slug";
import { resolvePluginIcon } from "@admin/lib/plugins/resolve-plugin-icon";
+import { isUnder } from "@admin/lib/routing";
import { cn } from "@admin/lib/utils";
import type { ApiCollection } from "@admin/types/entities";
@@ -338,8 +339,13 @@ export function DualSidebar({ isMobile }: DualSidebarProps = {}) {
});
};
+ // The route constants rather than their spellings. The plugin directory
+ // sits at its own top level so no plugin slug can shadow it, which means
+ // the Plugins category is not one URL prefix and a literal would silently
+ // stop covering it the next time either route moves.
if (
- pathname.includes("/admin/plugins") ||
+ isUnder(pathname, ROUTES.PLUGINS) ||
+ isUnder(pathname, ROUTES.PLUGIN_BROWSE) ||
pathname.includes("/admin/forms") ||
isPluginPath(collectionsData)
) {
diff --git a/packages/admin/src/components/layout/sidebar/SubSidebarContent.tsx b/packages/admin/src/components/layout/sidebar/SubSidebarContent.tsx
index e7ddf0045a..bd2f23788b 100644
--- a/packages/admin/src/components/layout/sidebar/SubSidebarContent.tsx
+++ b/packages/admin/src/components/layout/sidebar/SubSidebarContent.tsx
@@ -127,6 +127,29 @@ export function SubSidebarContent({
+ {/* Gated on the permission `PLUGIN_BROWSE` itself requires. The
+ panel stays open to a user who can only read a plugin-owned
+ collection, so without this they would be offered a destination
+ that redirects them the moment they choose it.
+
+ Below the installed plugins, not above: this panel is for
+ getting to what the project already has, and the directory is
+ the occasional trip. Not filtered by `pluginSearch` either —
+ that box searches installed plugins, and an entry that ignores
+ it while sitting among entries that obey it reads as a bug. */}
+ {hasPermission("manage-settings") && (
+
+
+
+
+ Browse plugins
+
+
+
+ )}
diff --git a/packages/admin/src/components/shared/plugin-icon/index.test.tsx b/packages/admin/src/components/shared/plugin-icon/index.test.tsx
index 743b7d0b7a..e5b2293c1f 100644
--- a/packages/admin/src/components/shared/plugin-icon/index.test.tsx
+++ b/packages/admin/src/components/shared/plugin-icon/index.test.tsx
@@ -7,6 +7,7 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
+import * as Icons from "@admin/components/icons";
import { PluginIcon } from "@admin/components/shared/plugin-icon";
import type { PluginMetadata } from "@admin/types/branding";
@@ -17,6 +18,31 @@ const withAsset = (src: string, icon?: string) =>
>;
describe("PluginIcon", () => {
+ /**
+ * The glyph `@nextlyhq/plugin-page-builder` declares. A name the barrel does
+ * not re-export resolves to nothing and falls through to the caller's
+ * fallback, so the plugin's own icon disappears everywhere in the admin
+ * while every surface still looks intact.
+ */
+ it("renders a first-party plugin's declared glyph rather than the fallback", () => {
+ const { container } = render(
+
+ );
+
+ // Compared against what the barrel's own `Layout` draws rather than a
+ // pinned class name: lucide ships `Layout` as an alias, so the rendered
+ // class is the target's and hard-coding it would tie this to which icon
+ // lucide happens to alias it to.
+ const expected = render().container.querySelector("svg");
+ expect(container.querySelector("svg")?.getAttribute("class")).toBe(
+ expected?.getAttribute("class")
+ );
+ expect(container.querySelector(".lucide-package")).toBeNull();
+ });
+
it("renders a declared asset", () => {
render();
expect(screen.getByRole("presentation", { hidden: true })).toBeTruthy();
diff --git a/packages/admin/src/components/shared/plugin-icon/index.tsx b/packages/admin/src/components/shared/plugin-icon/index.tsx
index 65731669a9..37963d36e9 100644
--- a/packages/admin/src/components/shared/plugin-icon/index.tsx
+++ b/packages/admin/src/components/shared/plugin-icon/index.tsx
@@ -2,13 +2,20 @@ import type React from "react";
import { useState } from "react";
import * as Icons from "@admin/components/icons";
-import { resolvePluginIcon } from "@admin/lib/plugins/resolve-plugin-icon";
+import { resolvePluginIconFrom } from "@admin/lib/plugins/resolve-plugin-icon";
import { cn } from "@admin/lib/utils";
import type { PluginMetadata } from "@admin/types/branding";
-interface PluginIconProps {
- /** The plugin whose icon to render. Only `appearance` is read. */
- plugin: Pick;
+type IconCandidate = Pick | undefined;
+
+interface PluginIconFromProps {
+ /**
+ * Appearance sources in precedence order; the first that declares an icon
+ * wins. A caller with one source passes one. Callers with two — an installed
+ * plugin and the catalogue entry describing it — must not decide the order
+ * here: ask the module that owns the precedence rule for the list.
+ */
+ candidates: readonly IconCandidate[];
/**
* The lucide icon to use when the plugin declares none.
*
@@ -23,6 +30,11 @@ interface PluginIconProps {
alt?: string;
}
+interface PluginIconProps extends Omit {
+ /** The plugin whose icon to render. Only `appearance` is read. */
+ plugin: Pick;
+}
+
/**
* Render a plugin's icon, whether it ships an image or names a lucide glyph.
*
@@ -33,28 +45,31 @@ interface PluginIconProps {
*
* @module components/shared/plugin-icon
*/
-export function PluginIcon({
- plugin,
+export function PluginIconFrom({
+ candidates,
fallback,
className,
alt = "",
-}: PluginIconProps): React.ReactElement {
+}: PluginIconFromProps): React.ReactElement {
// A declared asset can still fail to arrive: a mistyped path, a deleted
// file, or a Content-Security-Policy that blocks the origin. Without this the
// surface keeps a broken-image glyph forever, which is worse than the plain
- // icon it replaced. On failure the component re-resolves with assets
- // disallowed, so it lands on whatever lucide name the plugin declared beside
- // the asset before reaching the caller's fallback.
- // The failed URL rather than a boolean. A boolean survives client-side
- // navigation between two plugin detail pages, because the router renders the
- // same component type without a key, so React keeps the state: one plugin's
- // broken logo would suppress the next plugin's working one. Keying on the
- // source means a different asset is always attempted.
- const [failedSrc, setFailedSrc] = useState(null);
- const declaredAsset = plugin.appearance?.iconAsset;
- const source = resolvePluginIcon(plugin, {
+ // icon it replaced.
+ //
+ // The URLs that have failed, not a boolean. A broken image on one candidate
+ // says nothing about a different image a later candidate ships, so each
+ // failure removes exactly one URL from consideration and the chain is
+ // re-resolved: the next asset is tried, and only when none load does it
+ // settle on a glyph. Keying on the URL also survives client-side navigation
+ // between two plugin detail pages, where the router renders the same
+ // component type without a key so React keeps this state — one plugin's
+ // broken logo must not suppress the next plugin's working one.
+ const [failedSrcs, setFailedSrcs] = useState>(
+ () => new Set()
+ );
+ const source = resolvePluginIconFrom(candidates, {
fallback,
- allowAsset: declaredAsset !== undefined && declaredAsset !== failedSrc,
+ skipAssets: failedSrcs,
});
if (source.kind === "asset") {
@@ -67,7 +82,14 @@ export function PluginIcon({
setFailedSrc(source.src)}
+ onError={() =>
+ setFailedSrcs(prev => {
+ if (prev.has(source.src)) return prev;
+ const next = new Set(prev);
+ next.add(source.src);
+ return next;
+ })
+ }
className={cn("object-contain", className)}
/>
);
@@ -82,3 +104,11 @@ export function PluginIcon({
return ;
}
+
+/** The single-source case, which is every surface but the catalogue. */
+export function PluginIcon({
+ plugin,
+ ...rest
+}: PluginIconProps): React.ReactElement {
+ return ;
+}
diff --git a/packages/admin/src/constants/routes.ts b/packages/admin/src/constants/routes.ts
index cfca143ebc..be6cf00dcc 100644
--- a/packages/admin/src/constants/routes.ts
+++ b/packages/admin/src/constants/routes.ts
@@ -107,6 +107,13 @@ export const ROUTES = {
// Plugin routes
PLUGINS: "/admin/plugins",
+ // Outside the `/admin/plugins/` namespace on purpose. A plugin's detail
+ // address is `/admin/plugins/`, and a slug is derived from a package
+ // name that may be any string, so a sibling static page there is a page a
+ // plugin can be named after — and whichever of the two wins, the other
+ // becomes unreachable. A different parent removes the collision instead of
+ // ranking it.
+ PLUGIN_BROWSE: "/admin/plugin-directory",
PLUGIN_DETAIL: "/admin/plugins/[slug]",
PLUGIN_SETTINGS: "/admin/plugins/[slug]/settings",
} as const;
diff --git a/packages/admin/src/context/providers/BrandingProvider.test.tsx b/packages/admin/src/context/providers/BrandingProvider.test.tsx
new file mode 100644
index 0000000000..224ad752cb
--- /dev/null
+++ b/packages/admin/src/context/providers/BrandingProvider.test.tsx
@@ -0,0 +1,96 @@
+/**
+ * Readers that conclude something from a plugin being MISSING from branding
+ * need to know the list arrived. `useBranding` alone cannot tell them: it
+ * returns `{}` both before the request answers and when the project genuinely
+ * has nothing, so these cover the distinction `useBrandingStatus` adds.
+ */
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { AdminBranding } from "@admin/types/branding";
+
+const get = vi.fn();
+
+vi.mock("@admin/lib/api/publicApi", () => ({
+ publicApi: { get: (...args: unknown[]) => get(...args) },
+}));
+
+import {
+ BrandingProvider,
+ useBrandingStatus,
+} from "@admin/context/providers/BrandingProvider";
+
+function Probe() {
+ const { isPending, isUnavailable } = useBrandingStatus();
+ return (
+
+ {isPending ? "pending" : isUnavailable ? "unavailable" : "answered"}
+
+ );
+}
+
+function renderProbe(client: QueryClient) {
+ return render(
+
+
+
+
+
+ );
+}
+
+function status() {
+ return screen.getByTestId("status").textContent;
+}
+
+afterEach(() => {
+ get.mockReset();
+ vi.restoreAllMocks();
+});
+
+describe("useBrandingStatus", () => {
+ it("reports pending until the request answers", async () => {
+ get.mockReturnValue(new Promise(() => {}));
+
+ renderProbe(
+ new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ );
+
+ expect(status()).toBe("pending");
+ });
+
+ it("reports answered once branding arrives", async () => {
+ get.mockResolvedValue({ plugins: [] } as AdminBranding);
+
+ renderProbe(
+ new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ );
+
+ await waitFor(() => expect(status()).toBe("answered"));
+ });
+
+ it("reports unavailable when the first request fails", async () => {
+ get.mockRejectedValue(new Error("boom"));
+
+ renderProbe(
+ new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ );
+
+ await waitFor(() => expect(status()).toBe("unavailable"));
+ });
+});
+
+/**
+ * NOT COVERED, stated rather than left to look covered: a background refetch
+ * failing while a previous response is cached must leave the status
+ * `answered`, since the cached response is still a complete answer.
+ *
+ * `isUnavailable` is wired to the query's `isLoadingError`, which query-core
+ * defines as `isError && !hasData` (`isRefetchError` is the `&& hasData`
+ * half), so the distinction is the library's own and holds by construction.
+ * A test was written for it and removed: driving a failed refetch through
+ * `refetchQueries` errors the cache entry without the probe observing a
+ * re-render, so the assertion passed against BOTH `isLoadingError` and a
+ * deliberately broken `isError` — coverage in appearance only.
+ */
diff --git a/packages/admin/src/context/providers/BrandingProvider.tsx b/packages/admin/src/context/providers/BrandingProvider.tsx
index b53560a37c..ff3519a24e 100644
--- a/packages/admin/src/context/providers/BrandingProvider.tsx
+++ b/packages/admin/src/context/providers/BrandingProvider.tsx
@@ -2,7 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import type React from "react";
-import { createContext, useContext, useEffect } from "react";
+import { createContext, useContext, useEffect, useMemo } from "react";
import { publicApi } from "../../lib/api/publicApi";
import {
@@ -18,10 +18,55 @@ import type {
// Context
// ============================================================================
-const BrandingContext = createContext(undefined);
+/**
+ * Whether the admin-meta request has answered yet, alongside its answer.
+ *
+ * The two are held together because most readers want only the answer, and
+ * one reader — a page that treats a plugin's ABSENCE from the list as a fact
+ * about the project — needs to know the list has actually arrived. Before it
+ * does, `branding` is undefined, which is indistinguishable from a project
+ * that has no plugins.
+ */
+interface BrandingState {
+ branding: AdminBranding | undefined;
+ /** True until the admin-meta query settles, either way. */
+ isPending: boolean;
+ /**
+ * True when admin-meta has never produced an answer, so absence proves
+ * nothing.
+ *
+ * Not "the last request failed". Once a response is cached, a failed
+ * BACKGROUND refetch leaves that answer intact and still valid, and treating
+ * it as unavailable would replace a correct page with an error for as long
+ * as the server stays unreachable.
+ */
+ isUnavailable: boolean;
+}
+
+const BrandingContext = createContext(undefined);
export function useBranding(): AdminBranding {
- return useContext(BrandingContext) ?? {};
+ return useContext(BrandingContext)?.branding ?? {};
+}
+
+/**
+ * The admin-meta request's state, for readers that draw a conclusion from
+ * something being MISSING from branding.
+ *
+ * Derived from the same context entry `useBranding` reads, rather than from a
+ * second query: two `useQuery` calls on one key would report their states
+ * independently and could disagree about whether the data has arrived.
+ */
+export function useBrandingStatus(): Omit {
+ const state = useContext(BrandingContext);
+ // No provider above: nothing is loading and nothing failed, which is the
+ // same shape a settled empty response has. A reader outside the provider is
+ // already reading `{}` from `useBranding`, and reporting "still pending"
+ // here would hang it forever.
+ return {
+ isPending: state?.isPending ?? false,
+ isUnavailable: state?.isUnavailable ?? false,
+ };
}
// ============================================================================
@@ -136,7 +181,14 @@ interface BrandingProviderProps {
}
export function BrandingProvider({ children }: BrandingProviderProps) {
- const { data: fetchedData } = useQuery({
+ const {
+ data: fetchedData,
+ isPending,
+ // `isLoadingError`, not `isError`: the latter is also true when a
+ // background refetch fails while a previous response is still cached, and
+ // that cached response is a perfectly good answer.
+ isLoadingError,
+ } = useQuery({
queryKey: ["admin-meta"],
queryFn: () => publicApi.get("/admin-meta"),
// Refetch periodically to pick up changes to custom sidebar groups,
@@ -148,8 +200,16 @@ export function BrandingProvider({ children }: BrandingProviderProps) {
useColorInjection(fetchedData?.colors);
useFaviconInjection(fetchedData?.favicon);
+ // Memoized because the value is now an object built here rather than the
+ // query's own stable `data` reference: without this every consumer of the
+ // context re-renders on each render of this provider.
+ const value = useMemo(
+ () => ({ branding: fetchedData, isPending, isUnavailable: isLoadingError }),
+ [fetchedData, isPending, isLoadingError]
+ );
+
return (
-
+
{children}
);
diff --git a/packages/admin/src/hooks/useSidebarNavigation.ts b/packages/admin/src/hooks/useSidebarNavigation.ts
index c8fb348694..f669407502 100644
--- a/packages/admin/src/hooks/useSidebarNavigation.ts
+++ b/packages/admin/src/hooks/useSidebarNavigation.ts
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import type { NavigationItem } from "../constants/navigation";
+import { isUnder } from "../lib/routing";
/**
* Custom hook to manage sidebar navigation state
@@ -61,9 +62,11 @@ export function useSidebarNavigation(
if (exactMatch) return false;
- // Sub-item match (e.g., /admin/users/create should match /admin/users)
- // Ensure we match whole segments to avoid /admin/user matching /admin/users
- return path.startsWith(target + "/") || path === target;
+ // Sub-item match (e.g., /admin/users/create should match /admin/users),
+ // asked of the shared helper rather than restated: the sidebar's category
+ // classifier answers the same question, and two spellings of "is this
+ // route under that one" agree until one of them is adjusted.
+ return isUnder(path, target);
};
return {
diff --git a/packages/admin/src/lib/__tests__/is-under.test.ts b/packages/admin/src/lib/__tests__/is-under.test.ts
new file mode 100644
index 0000000000..622121d7af
--- /dev/null
+++ b/packages/admin/src/lib/__tests__/is-under.test.ts
@@ -0,0 +1,50 @@
+/**
+ * "Is this route under that one" is asked by the sidebar's category
+ * classifier and by its active-item matcher. These cover the answers a
+ * substring test gets wrong, which is why both now ask one function.
+ */
+import { describe, expect, it } from "vitest";
+
+import { ROUTES } from "@admin/constants/routes";
+import { isUnder } from "@admin/lib/routing";
+
+describe("isUnder", () => {
+ it("matches the route itself and its descendants", () => {
+ expect(isUnder("/admin/plugins", "/admin/plugins")).toBe(true);
+ expect(isUnder("/admin/plugins/acme-forms", "/admin/plugins")).toBe(true);
+ expect(
+ isUnder("/admin/plugins/acme-forms/settings", "/admin/plugins")
+ ).toBe(true);
+ });
+
+ /**
+ * The separating case. `"/admin/plugins-archive".includes("/admin/plugins")`
+ * is true, so the substring test this replaced classified an unrelated
+ * sibling as a plugins page — and the miss is silent, because the wrong
+ * answer is a real category rather than an error.
+ */
+ it("rejects a sibling that merely shares the prefix", () => {
+ expect(isUnder("/admin/plugins-archive", "/admin/plugins")).toBe(false);
+ expect(isUnder("/admin/plugin-directory", "/admin/plugins")).toBe(false);
+ });
+
+ it("ignores a trailing slash on either side", () => {
+ expect(isUnder("/admin/plugins/", "/admin/plugins")).toBe(true);
+ expect(isUnder("/admin/plugins", "/admin/plugins/")).toBe(true);
+ });
+
+ it("does not match a parent from a child route", () => {
+ expect(isUnder("/admin", "/admin/plugins")).toBe(false);
+ });
+
+ /**
+ * The directory is deliberately not under `/admin/plugins`, so anything
+ * classifying the Plugins section has to name it separately. Pinned here
+ * because the consequence of forgetting is a silently wrong sidebar rather
+ * than a failure.
+ */
+ it("confirms the directory is outside the installed-plugins subtree", () => {
+ expect(isUnder(ROUTES.PLUGIN_BROWSE, ROUTES.PLUGINS)).toBe(false);
+ expect(isUnder(ROUTES.PLUGIN_BROWSE, ROUTES.PLUGIN_BROWSE)).toBe(true);
+ });
+});
diff --git a/packages/admin/src/lib/admin-version.ts b/packages/admin/src/lib/admin-version.ts
new file mode 100644
index 0000000000..4e7cf94bfe
--- /dev/null
+++ b/packages/admin/src/lib/admin-version.ts
@@ -0,0 +1,36 @@
+/**
+ * The release this admin bundle belongs to.
+ *
+ * Injected at build time from `package.json` by the tsup and vitest `define`
+ * blocks, mirroring how core resolves `__NEXTLY_CORE_VERSION__`. No runtime
+ * `package.json` read as core has: this bundle targets the browser, where
+ * there is no module resolver to fall back to.
+ *
+ * A consumer that bundles this package from SOURCE gets `undefined`, because
+ * neither `define` runs. That is the contributor playground, which aliases
+ * `@nextlyhq/admin` to `src/index.ts` — so the install command it renders is
+ * unpinned, and that is correct rather than broken: a dev checkout has no
+ * published release to name. An installed project resolves the built `dist`,
+ * where the constant is a literal.
+ *
+ * @module lib/admin-version
+ */
+
+declare const __NEXTLY_ADMIN_VERSION__: string | undefined;
+
+/**
+ * The concrete admin version (e.g. `"0.0.2-alpha.57"`), or `undefined` when
+ * the constant was not injected.
+ *
+ * `undefined` rather than a `"0.0.0"` sentinel, because the only caller uses
+ * this to pin a package specifier: a sentinel would produce an install command
+ * for a version that does not exist, which is worse than the unpinned command
+ * it replaced. Callers have to handle not knowing.
+ */
+export function adminVersion(): string | undefined {
+ const injected =
+ typeof __NEXTLY_ADMIN_VERSION__ !== "undefined"
+ ? __NEXTLY_ADMIN_VERSION__
+ : undefined;
+ return injected && injected.length > 0 ? injected : undefined;
+}
diff --git a/packages/admin/src/lib/plugins/registry/__tests__/install-command.test.ts b/packages/admin/src/lib/plugins/registry/__tests__/install-command.test.ts
new file mode 100644
index 0000000000..44988f0a44
--- /dev/null
+++ b/packages/admin/src/lib/plugins/registry/__tests__/install-command.test.ts
@@ -0,0 +1,150 @@
+/**
+ * The lines a reader copies to add a plugin.
+ *
+ * Two properties matter and they are separate: the lines must be derived from
+ * the entry rather than stored beside it, and the install must name a version.
+ * An unpinned install is the failure with teeth — every first-party plugin
+ * declares an exact `nextly` peer for its own release, so resolving `latest`
+ * on a project that is not on the newest one is a peer conflict on npm and a
+ * startup compatibility failure elsewhere.
+ */
+import { describe, expect, it } from "vitest";
+
+import { adminVersion } from "@admin/lib/admin-version";
+
+import pkg from "../../../../../package.json";
+import {
+ adminImportStatement,
+ adminStylesImport,
+ importStatement,
+ installCommand,
+ pluginsArrayEntry,
+} from "../install-command";
+import type { RegistryPlugin } from "../types";
+
+function entry(config: RegistryPlugin["config"]): RegistryPlugin {
+ return {
+ id: "@acme/thing",
+ name: "Thing",
+ description: "d",
+ author: "Acme",
+ category: "content",
+ icon: { lucide: "Archive" },
+ config,
+ };
+}
+
+describe("installCommand", () => {
+ it("pins to the given release", () => {
+ expect(installCommand("@acme/p", "pnpm", "1.2.3")).toBe(
+ "pnpm add @acme/p@1.2.3"
+ );
+ });
+
+ /**
+ * The separating case for the pin. Asserting only that a version appears
+ * would pass on a build that injected a sentinel, and a command naming a
+ * version that was never published is worse than one naming none.
+ */
+ it("omits the version rather than inventing one when it is unknown", () => {
+ expect(installCommand("@acme/p", "pnpm", undefined)).toBe(
+ "pnpm add @acme/p"
+ );
+ });
+
+ /**
+ * What the call site actually passes. `adminVersion()` reads a constant the
+ * build injects from this package.json, so comparing the two confirms the
+ * injection is wired rather than merely that some string came back — a
+ * missing `define` would have it return undefined and the command would
+ * silently go back to resolving `latest`.
+ */
+ it("names this package's own release when given the injected version", () => {
+ expect(installCommand("@acme/p", "pnpm", adminVersion())).toBe(
+ `pnpm add @acme/p@${pkg.version}`
+ );
+ });
+
+ it("uses each manager's own add subcommand", () => {
+ expect(installCommand("@acme/p", "npm", "1.0.0")).toBe(
+ "npm install @acme/p@1.0.0"
+ );
+ expect(installCommand("@acme/p", "yarn", "1.0.0")).toBe(
+ "yarn add @acme/p@1.0.0"
+ );
+ expect(installCommand("@acme/p", "bun", "1.0.0")).toBe(
+ "bun add @acme/p@1.0.0"
+ );
+ });
+});
+
+describe("config lines", () => {
+ it("imports the binding the array entry references", () => {
+ const plugin = entry({ exportName: "thing", callArgs: "" });
+
+ // Asserted together, because the defect they guard is disagreement: an
+ // entry naming a symbol the import does not bring in compiles nowhere.
+ expect(importStatement(plugin)).toBe(
+ 'import { thing } from "@acme/thing";'
+ );
+ expect(pluginsArrayEntry(plugin)).toBe("thing()");
+ });
+
+ it("leaves an uncalled export uncalled", () => {
+ expect(
+ pluginsArrayEntry(entry({ exportName: "thing", callArgs: null }))
+ ).toBe("thing");
+ });
+
+ /**
+ * A fourth edit, in the admin route page rather than the config. Skipping it
+ * leaves the plugin installed and its server half running while its admin UI
+ * silently never registers, so the recipe has to name it.
+ */
+ it("asks for the admin module only when the package ships one", () => {
+ expect(
+ adminImportStatement(
+ entry({ exportName: "thing", callArgs: "", adminModule: true })
+ )
+ ).toBe('import "@acme/thing/admin";');
+
+ // The separating case: a plugin without an `/admin` export must not be
+ // told to import one, or the recipe names a subpath that does not resolve.
+ expect(
+ adminImportStatement(entry({ exportName: "thing", callArgs: "" }))
+ ).toBeUndefined();
+ });
+
+ /**
+ * Separate from the admin module, because they are separate facts about the
+ * package. Page Builder's `/admin` entry imports no CSS, so a reader who
+ * takes only the module gets a registered editor with no layout at all.
+ */
+ it("asks for the stylesheet only when the package exports one", () => {
+ expect(
+ adminStylesImport(
+ entry({
+ exportName: "thing",
+ callArgs: "",
+ adminStyles: "styles/editor.css",
+ })
+ )
+ ).toBe('import "@acme/thing/styles/editor.css";');
+
+ // A plugin with an admin module but no stylesheet must not be told to
+ // import one — the two are independent.
+ expect(
+ adminStylesImport(
+ entry({ exportName: "thing", callArgs: "", adminModule: true })
+ )
+ ).toBeUndefined();
+ });
+
+ it("places required arguments inside the call", () => {
+ expect(
+ pluginsArrayEntry(
+ entry({ exportName: "thing", callArgs: '{ collections: ["posts"] }' })
+ )
+ ).toBe('thing({ collections: ["posts"] })');
+ });
+});
diff --git a/packages/admin/src/lib/plugins/registry/__tests__/resolve-catalogue-presentation.test.ts b/packages/admin/src/lib/plugins/registry/__tests__/resolve-catalogue-presentation.test.ts
index b495bf667f..5cf57822ed 100644
--- a/packages/admin/src/lib/plugins/registry/__tests__/resolve-catalogue-presentation.test.ts
+++ b/packages/admin/src/lib/plugins/registry/__tests__/resolve-catalogue-presentation.test.ts
@@ -16,8 +16,11 @@ const ENTRY: RegistryPlugin = {
description: "The catalogue's description",
author: "Acme",
category: "content",
- icon: { lucide: "CatalogueGlyph" },
- configSnippet: "plugins: [thing()]",
+ // A real barrel export, unlike `PluginGlyph` on the installed side below: a
+ // catalogue entry's glyph is type-checked against the icon barrel, while a
+ // plugin declares its own as a free string this admin cannot constrain.
+ icon: { lucide: "Archive" },
+ config: { exportName: "thing", callArgs: "" },
};
function installed(
@@ -31,7 +34,7 @@ describe("resolveCataloguePresentation", () => {
it("uses the catalogue when the plugin is not installed", () => {
const p = resolveCataloguePresentation(ENTRY, undefined);
- expect(p.icon).toEqual({ kind: "lucide", name: "CatalogueGlyph" });
+ expect(p.icon).toEqual({ kind: "lucide", name: "Archive" });
expect(p.description).toBe("The catalogue's description");
expect(p.isInstalled).toBe(false);
});
@@ -59,7 +62,7 @@ describe("resolveCataloguePresentation", () => {
);
expect(p.description).toBe("The plugin's own description");
- expect(p.icon).toEqual({ kind: "lucide", name: "CatalogueGlyph" });
+ expect(p.icon).toEqual({ kind: "lucide", name: "Archive" });
});
it("keeps the catalogue text when an installed plugin declares none", () => {
@@ -96,7 +99,7 @@ describe("resolveCataloguePresentation", () => {
it("lets an installed glyph outrank a catalogue asset", () => {
const withAsset: RegistryPlugin = {
...ENTRY,
- icon: { lucide: "CatalogueGlyph", asset: "/catalogue.svg" },
+ icon: { lucide: "Archive", asset: "/catalogue.svg" },
};
// Positive control: with nothing installed the catalogue asset IS chosen,
@@ -117,7 +120,7 @@ describe("resolveCataloguePresentation", () => {
it("skips every asset for a surface that cannot render one", () => {
const withAsset: RegistryPlugin = {
...ENTRY,
- icon: { lucide: "CatalogueGlyph", asset: "/catalogue.svg" },
+ icon: { lucide: "Archive", asset: "/catalogue.svg" },
};
const p = resolveCataloguePresentation(
@@ -126,6 +129,6 @@ describe("resolveCataloguePresentation", () => {
{ allowAsset: false }
);
- expect(p.icon).toEqual({ kind: "lucide", name: "CatalogueGlyph" });
+ expect(p.icon).toEqual({ kind: "lucide", name: "Archive" });
});
});
diff --git a/packages/admin/src/lib/plugins/registry/entries.ts b/packages/admin/src/lib/plugins/registry/entries.ts
index 142bdee413..b01913c175 100644
--- a/packages/admin/src/lib/plugins/registry/entries.ts
+++ b/packages/admin/src/lib/plugins/registry/entries.ts
@@ -31,7 +31,12 @@ export const REGISTRY_ENTRIES: RegistryPlugin[] = [
category: "content",
tags: ["blocks", "editor", "pages"],
icon: { lucide: "Layout" },
- configSnippet: "plugins: [pageBuilder()]",
+ config: {
+ exportName: "pageBuilder",
+ callArgs: "",
+ adminModule: true,
+ adminStyles: "styles/editor.css",
+ },
links: {
homepage: "https://nextlyhq.com",
repository: "https://github.com/nextlyhq/nextly",
@@ -45,10 +50,14 @@ export const REGISTRY_ENTRIES: RegistryPlugin[] = [
category: "forms",
tags: ["forms", "submissions"],
icon: { lucide: "FileText" },
- // `formBuilderPlugin`, not `formBuilder()`: the factory returns a
+ // `callArgs: null` — the export goes in uncalled. The factory returns a
// FormBuilderPluginResult whose definition is at `.plugin`, and the package
// exports the unwrapped value for exactly this use.
- configSnippet: "plugins: [formBuilderPlugin]",
+ config: {
+ exportName: "formBuilderPlugin",
+ callArgs: null,
+ adminModule: true,
+ },
links: {
homepage: "https://nextlyhq.com",
repository: "https://github.com/nextlyhq/nextly",
@@ -63,9 +72,19 @@ export const REGISTRY_ENTRIES: RegistryPlugin[] = [
category: "seo",
tags: ["seo", "meta"],
icon: { lucide: "Search" },
- // `seoPlugin`, and `collections` is required: the plugin adds the SEO group
- // only to the collections it is given, so a bare call does not type-check.
- configSnippet: 'plugins: [seoPlugin({ collections: ["posts"] })]',
+ // `collections` is required rather than illustrative: the plugin adds the
+ // SEO group only to the collections it is given, so a bare call does not
+ // type-check.
+ //
+ // A named placeholder, not a plausible slug like "posts". The blank
+ // template ships `collections: []`, and naming a collection the project
+ // does not have makes the eager schema fold throw
+ // NEXTLY_SCHEMA_EXTEND_TARGET_UNKNOWN at startup — a copied line that
+ // stops the app is worse than one that obviously has to be edited.
+ config: {
+ exportName: "seoPlugin",
+ callArgs: '{ collections: ["your-collection"] }',
+ },
links: {
homepage: "https://nextlyhq.com",
repository: "https://github.com/nextlyhq/nextly",
diff --git a/packages/admin/src/lib/plugins/registry/install-command.ts b/packages/admin/src/lib/plugins/registry/install-command.ts
new file mode 100644
index 0000000000..823e0e69eb
--- /dev/null
+++ b/packages/admin/src/lib/plugins/registry/install-command.ts
@@ -0,0 +1,122 @@
+/**
+ * The three lines that add a catalogue plugin to a project: the shell command
+ * that installs it, and the two edits to nextly.config.ts.
+ *
+ * All derived from the entry rather than stored beside it, so a package rename
+ * cannot leave the install command fetching the old name while the detail page
+ * joins installed state on the new one, and an entry cannot name a binding its
+ * import does not bring in.
+ *
+ * @module lib/plugins/registry/install-command
+ */
+import type { RegistryPlugin } from "./types";
+
+/**
+ * Package managers a Nextly project can be installed with.
+ *
+ * Enumerated rather than free-form because the add subcommand differs: npm and
+ * bun take `install`, pnpm and yarn take `add`. A caller passing its own string
+ * would have to know that, which is the knowledge this module exists to hold.
+ */
+export const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun"] as const;
+
+export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
+
+const ADD_SUBCOMMAND: Record = {
+ pnpm: "add",
+ npm: "install",
+ yarn: "add",
+ bun: "add",
+};
+
+/**
+ * `installCommand("@acme/p", "pnpm", "1.2.3")` → `"pnpm add @acme/p@1.2.3"`.
+ *
+ * Answerable for all four managers, so a reader on npm is not handed a command
+ * their project cannot run.
+ *
+ * Pinned to the running admin's release, and that is not a nicety. Every
+ * first-party plugin declares an EXACT `nextly` peer for its own release, so
+ * an unpinned install on a project that is not on the newest one resolves a
+ * plugin whose peer names a core the project does not have: npm rejects it,
+ * and a manager that installs it anyway produces a plugin core refuses to load
+ * at startup. Admin's own version is the release the project is on, since the
+ * whole train publishes in lockstep and this bundle came from that install.
+ *
+ * Unpinned when `version` is undefined — a build that did not inject the
+ * constant. Naming a version that may not exist would be a worse answer than
+ * declining to name one.
+ *
+ * `version` is required rather than defaulted to `adminVersion()`, so a caller
+ * has to answer the question instead of inheriting an answer. A default would
+ * also make the unpinned branch unreachable from a test, since passing
+ * `undefined` re-triggers the default.
+ */
+export function installCommand(
+ packageName: string,
+ manager: PackageManager,
+ version: string | undefined
+): string {
+ const specifier = version ? `${packageName}@${version}` : packageName;
+ return `${manager} ${ADD_SUBCOMMAND[manager]} ${specifier}`;
+}
+
+/**
+ * The import that brings the plugin's binding into nextly.config.ts.
+ *
+ * Installing the package does not introduce the identifier, so the entry below
+ * references nothing without this line. It is the reader's first edit, and
+ * omitting it hands them a recipe that does not compile.
+ */
+export function importStatement(plugin: RegistryPlugin): string {
+ return `import { ${plugin.config.exportName} } from "${plugin.id}";`;
+}
+
+/**
+ * The single element to append inside an existing `plugins: [...]`.
+ *
+ * The element alone, not the whole `plugins:` property. A reader who already
+ * has plugins configured — the likely one, since the directory is reached from
+ * the installed list — cannot use a property: pasting it inside the array is
+ * not valid TypeScript, and pasting it over the existing property silently
+ * drops every plugin already there.
+ *
+ * `callArgs` decides whether the binding is called at all: `null` means the
+ * package exports a ready-made plugin value, which is how
+ * `@nextlyhq/plugin-form-builder` ships one — its factory returns a result
+ * object whose definition sits at `.plugin`, so calling it here would be
+ * wrong rather than merely verbose.
+ */
+/**
+ * The side-effect import the app's admin route needs, for plugins that ship
+ * an `/admin` module — `undefined` for those that do not.
+ *
+ * Its own line rather than part of the config recipe because it goes in a
+ * different file: the admin route page, not `nextly.config.ts`. Undefined
+ * rather than an empty string, so a caller has to decide whether to render a
+ * step at all instead of rendering a blank one.
+ */
+export function adminImportStatement(
+ plugin: RegistryPlugin
+): string | undefined {
+ return plugin.config.adminModule ? `import "${plugin.id}/admin";` : undefined;
+}
+
+/**
+ * The stylesheet import the admin route needs, for plugins that ship one.
+ *
+ * A separate line from `adminImportStatement`, because the module and the
+ * stylesheet are separate imports in the same file and a plugin can ship
+ * either without the other. Taking only the module leaves the editor
+ * registered and unstyled, which looks like a broken build rather than a
+ * missing step.
+ */
+export function adminStylesImport(plugin: RegistryPlugin): string | undefined {
+ const subpath = plugin.config.adminStyles;
+ return subpath ? `import "${plugin.id}/${subpath}";` : undefined;
+}
+
+export function pluginsArrayEntry(plugin: RegistryPlugin): string {
+ const { exportName, callArgs } = plugin.config;
+ return callArgs === null ? exportName : `${exportName}(${callArgs})`;
+}
diff --git a/packages/admin/src/lib/plugins/registry/resolve-catalogue-presentation.ts b/packages/admin/src/lib/plugins/registry/resolve-catalogue-presentation.ts
index ba590eb3bd..adeee6bdec 100644
--- a/packages/admin/src/lib/plugins/registry/resolve-catalogue-presentation.ts
+++ b/packages/admin/src/lib/plugins/registry/resolve-catalogue-presentation.ts
@@ -43,6 +43,21 @@ function catalogueAppearance(
};
}
+/**
+ * The appearance sources for a catalogue entry, in precedence order.
+ *
+ * Exported so a component rendering the icon takes the ordering from here
+ * instead of writing `[installed, entry]` itself. That second spelling would be
+ * a second answer to which source wins, and the two would agree until one of
+ * them gained a step.
+ */
+export function cataloguePresentationCandidates(
+ entry: RegistryPlugin,
+ installed: Pick | undefined
+): readonly (Pick | undefined)[] {
+ return [installed, catalogueAppearance(entry)];
+}
+
export interface CataloguePresentation {
icon: PluginIconSource;
description: string;
@@ -66,13 +81,16 @@ export function resolveCataloguePresentation(
opts: { allowAsset?: boolean } = {}
): CataloguePresentation {
return {
- icon: resolvePluginIconFrom([installed, catalogueAppearance(entry)], {
- // Unreachable in practice: `catalogueAppearance` always carries a lucide
- // name, since `RegistryPlugin.icon.lucide` is required. Named rather
- // than asserted so the chain has a total answer if that ever loosens.
- fallback: entry.icon.lucide,
- allowAsset: opts.allowAsset,
- }),
+ icon: resolvePluginIconFrom(
+ cataloguePresentationCandidates(entry, installed),
+ {
+ // Unreachable in practice: `catalogueAppearance` always carries a lucide
+ // name, since `RegistryPlugin.icon.lucide` is required. Named rather
+ // than asserted so the chain has a total answer if that ever loosens.
+ fallback: entry.icon.lucide,
+ allowAsset: opts.allowAsset,
+ }
+ ),
// An installed plugin declaring an empty description says nothing, so it
// does not get to blank the catalogue's text.
description: installed?.description?.trim()
diff --git a/packages/admin/src/lib/plugins/registry/types.ts b/packages/admin/src/lib/plugins/registry/types.ts
index d70fe37f53..e1c0142a15 100644
--- a/packages/admin/src/lib/plugins/registry/types.ts
+++ b/packages/admin/src/lib/plugins/registry/types.ts
@@ -1,3 +1,5 @@
+import type { AdminIconName } from "@admin/components/icons";
+
import type { PluginCategory } from "../plugin-categories";
/**
@@ -22,16 +24,59 @@ export interface RegistryPlugin {
author: string;
category: PluginCategory;
tags?: string[];
- icon: { lucide: string; asset?: string };
/**
- * The line to add inside `plugins: [...]` in nextly.config.ts.
+ * `lucide` is checked against the icon barrel, unlike a plugin's own
+ * `appearance.icon`: this catalogue is ours, so a name nothing exports is a
+ * mistake we can refuse at compile time rather than a third party's string
+ * we have to tolerate at runtime.
+ */
+ icon: { lucide: AdminIconName; asset?: string };
+ /**
+ * What the reader has to write in nextly.config.ts, in the two parts they
+ * write it in: an import at the top of the file and an entry in the plugins
+ * array.
*
- * No package name beside it: that is `id`, and storing it twice means a
- * rename can update one and leave the other, so the detail page would join
- * on the new name while the install command still fetched the old one.
- * Derive the command with `installCommand()`.
+ * Held as the binding and its arguments rather than as finished lines,
+ * because the two mention the same identifier and a stored pair can
+ * disagree — an entry naming a symbol the import does not bring in is a
+ * recipe that does not compile. Neither part repeats the package name
+ * either; that is `id`, and `importStatement()` reads it from there so a
+ * rename cannot leave the import fetching the old package while the detail
+ * page joins installed state on the new one.
*/
- configSnippet: string;
+ config: {
+ /** The binding the package exports and the plugins array references. */
+ exportName: string;
+ /**
+ * The arguments as written inside the call: `""` for a call that takes
+ * none, or `null` when the export goes into the array uncalled. The two
+ * are different facts about the package's API, not two spellings of one.
+ */
+ callArgs: string | null;
+ /**
+ * Whether the package ships an `/admin` side-effect module that the app's
+ * admin route has to import.
+ *
+ * A fourth edit, in a different file, and omitting it is not a small
+ * miss: the plugin installs and its server half runs, so nothing errors,
+ * while its admin UI silently never registers — the form builder degrades
+ * to plain JSON inputs. Declared rather than assumed, because it is a fact
+ * about the package's export map that only some plugins have.
+ */
+ adminModule?: boolean;
+ /**
+ * The package subpath of a stylesheet the admin route must import, when
+ * the plugin ships one.
+ *
+ * Separate from `adminModule` because they are separate facts: the
+ * page builder's `/admin` entry registers components and imports no CSS,
+ * so an app that takes only the module gets a registered editor with none
+ * of its layout — the shell, grid, toolbar and panes all come from
+ * `styles/editor.css`. The subpath is stored rather than assumed, since
+ * only some packages export one and they need not agree on its name.
+ */
+ adminStyles?: string;
+ };
links?: { homepage?: string; repository?: string; docs?: string };
}
diff --git a/packages/admin/src/lib/plugins/resolve-plugin-icon.ts b/packages/admin/src/lib/plugins/resolve-plugin-icon.ts
index 1d1ca5a2cf..cf75bb9b1e 100644
--- a/packages/admin/src/lib/plugins/resolve-plugin-icon.ts
+++ b/packages/admin/src/lib/plugins/resolve-plugin-icon.ts
@@ -39,20 +39,36 @@ export type PluginIconSource =
*/
export function resolvePluginIconFrom(
candidates: readonly (Pick | undefined)[],
- opts: { fallback: string; allowAsset: false }
+ opts: {
+ fallback: string;
+ allowAsset: false;
+ skipAssets?: ReadonlySet;
+ }
): Extract;
export function resolvePluginIconFrom(
candidates: readonly (Pick | undefined)[],
- opts: { fallback: string; allowAsset?: boolean }
+ opts: {
+ fallback: string;
+ allowAsset?: boolean;
+ skipAssets?: ReadonlySet;
+ }
): PluginIconSource;
export function resolvePluginIconFrom(
candidates: readonly (Pick | undefined)[],
- opts: { fallback: string; allowAsset?: boolean }
+ opts: {
+ fallback: string;
+ allowAsset?: boolean;
+ skipAssets?: ReadonlySet;
+ }
): PluginIconSource {
for (const candidate of candidates) {
const asset = candidate?.appearance?.iconAsset;
- if (asset && opts.allowAsset !== false)
+ // A skipped asset drops only that candidate's image; the candidate's own
+ // glyph is still preferred over the next candidate, because a plugin
+ // naming a glyph beside a broken logo has said what to show instead.
+ if (asset && opts.allowAsset !== false && !opts.skipAssets?.has(asset)) {
return { kind: "asset", src: asset };
+ }
const name = candidate?.appearance?.icon;
if (name) return { kind: "lucide", name };
diff --git a/packages/admin/src/lib/routing.ts b/packages/admin/src/lib/routing.ts
index 2739cc2182..37b1facf31 100644
--- a/packages/admin/src/lib/routing.ts
+++ b/packages/admin/src/lib/routing.ts
@@ -8,6 +8,24 @@ import registry, { routeConfig } from "../pages/registry";
import { matchPluginPage } from "./plugins/plugin-route-registry";
+/**
+ * Whether `pathname` is `base` or sits beneath it.
+ *
+ * Whole segments, so `/admin/plugins` does not claim `/admin/plugins-archive`
+ * — the reason this exists rather than each caller writing `includes()`. A
+ * substring test is a proxy for "is this route under that one", and the
+ * sibling paths that motivate writing the check are exactly the ones the proxy
+ * gets wrong.
+ *
+ * Callers pass `ROUTES.*` rather than a spelling, so a route that moves takes
+ * its classification with it.
+ */
+export function isUnder(pathname: string, base: string): boolean {
+ const path = pathname.replace(/\/$/, "");
+ const target = base.replace(/\/$/, "");
+ return path === target || path.startsWith(`${target}/`);
+}
+
/** Props passed to page components by the router */
export interface PageProps {
params?: Record;
diff --git a/packages/admin/src/pages/dashboard/plugins/[slug].test.tsx b/packages/admin/src/pages/dashboard/plugins/[slug].test.tsx
index 8aa87c8729..c59dc18afb 100644
--- a/packages/admin/src/pages/dashboard/plugins/[slug].test.tsx
+++ b/packages/admin/src/pages/dashboard/plugins/[slug].test.tsx
@@ -12,6 +12,9 @@ import { renderWithProviders } from "../../../__tests__/utils";
let mockBranding: AdminBranding | undefined;
vi.mock("@admin/context/providers/BrandingProvider", () => ({
useBranding: () => mockBranding,
+ // Settled with an answer: every case here is about a plugin the branding
+ // payload already carries.
+ useBrandingStatus: () => ({ isPending: false, isUnavailable: false }),
}));
import PluginDetailPage from "./[slug]";
diff --git a/packages/admin/src/pages/dashboard/plugins/[slug].tsx b/packages/admin/src/pages/dashboard/plugins/[slug].tsx
index eec6eba8fd..67a4045e12 100644
--- a/packages/admin/src/pages/dashboard/plugins/[slug].tsx
+++ b/packages/admin/src/pages/dashboard/plugins/[slug].tsx
@@ -1,6 +1,8 @@
"use client";
import { Badge } from "@nextlyhq/ui";
+import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
+import { Suspense } from "react";
import {
BookOpen,
@@ -18,16 +20,25 @@ import {
} from "@admin/components/icons";
import { PageContainer } from "@admin/components/layout/page-container";
import { Breadcrumbs } from "@admin/components/shared";
-import { PageErrorFallback } from "@admin/components/shared/error-fallbacks";
+import {
+ PageErrorFallback,
+ SectionErrorFallback,
+} from "@admin/components/shared/error-fallbacks";
import { PluginIcon } from "@admin/components/shared/plugin-icon";
import { QueryErrorBoundary } from "@admin/components/shared/query-error-boundary";
import { Link } from "@admin/components/ui/link";
import { ROUTES, buildRoute } from "@admin/constants/routes";
-import { useBranding } from "@admin/context/providers/BrandingProvider";
+import {
+ useBranding,
+ useBrandingStatus,
+} from "@admin/context/providers/BrandingProvider";
import { categoryLabel } from "@admin/lib/plugins/plugin-categories";
import { pluginSlug } from "@admin/lib/plugins/plugin-slug";
+import { staticRegistrySource } from "@admin/lib/plugins/registry/static-source";
import type { PluginMetadata } from "@admin/types/branding";
+import { NotInstalledPlugin } from "./components/NotInstalledPlugin";
+import { PluginPageLoading } from "./components/PluginPageLoading";
import { PluginStatusPill } from "./components/PluginsTable";
const PLACEMENT_LABELS: Record = {
@@ -66,35 +77,116 @@ export default function PluginDetailPage({
);
}
-function PluginDetailContent({ activeSlug }: { activeSlug?: string }) {
- const branding = useBranding();
- const plugins = branding?.plugins ?? [];
- const plugin = activeSlug
- ? plugins.find(p => pluginSlug(p.name) === activeSlug)
+/**
+ * A slug the project has no plugin for.
+ *
+ * Two outcomes, and they are different facts: the catalogue knows this package
+ * and the reader has simply not installed it, or nothing knows it at all. The
+ * first is the ordinary path from the directory, where most entries are not
+ * installed, so it must not be reported as an error.
+ *
+ * Its own component because it queries the catalogue. Held inside
+ * `PluginDetailContent` the query would run for every installed plugin too,
+ * making a `QueryClientProvider` a requirement of rendering a page that has no
+ * need of one.
+ */
+function UninstalledOrMissing({ activeSlug }: { activeSlug?: string }) {
+ const { data: entries } = useSuspenseQuery({
+ queryKey: ["plugin-registry"],
+ queryFn: () => staticRegistrySource.list(),
+ });
+ const entry = activeSlug
+ ? entries.find(e => pluginSlug(e.id) === activeSlug)
: undefined;
- if (!plugin) {
+ if (entry) {
return (
-
-
-
- Plugin not found
-
-
- No installed plugin matches this address. It may have been removed
- from your Nextly config.
-
-
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Plugin not found
+
+
+ No installed plugin matches this address, and it is not in the plugin
+ directory either. It may have been removed from your Nextly config.
+
+
+ );
+}
+
+/**
+ * Shown when admin-meta failed, so whether this plugin is installed is
+ * unknown.
+ *
+ * Not the catalogue view: that one states the plugin is absent, which is a
+ * claim this page cannot make when the request that would have told it failed.
+ */
+function InstalledPluginsUnavailable() {
+ const queryClient = useQueryClient();
+
+ return (
+ {
+ void queryClient.invalidateQueries({ queryKey: ["admin-meta"] });
+ }}
+ />
+ );
+}
+
+function PluginDetailContent({ activeSlug }: { activeSlug?: string }) {
+ const branding = useBranding();
+ const { isPending, isUnavailable } = useBrandingStatus();
+ const plugins = branding?.plugins ?? [];
+ const plugin = activeSlug
+ ? plugins.find(p => pluginSlug(p.name) === activeSlug)
+ : undefined;
+
+ // Installed metadata is observed; the catalogue is a claim, so the observed
+ // one decides. Only when the project has no such plugin is the catalogue
+ // consulted, and that lookup lives inside `UninstalledOrMissing` so its query
+ // is not a dependency of rendering an installed plugin.
+ //
+ // Absence is only a fact once admin-meta has answered. Until then the list is
+ // empty for a reason that says nothing about the project, and reading it as
+ // "not installed" tells someone who HAS this plugin to go and install it.
+ if (!plugin) {
+ if (isPending) return ;
+ if (isUnavailable) return ;
+ // Its own Suspense boundary: `UninstalledOrMissing` suspends on the
+ // catalogue, and the nearest boundary above is RootLayout's
+ // `fallback={null}`, which would blank the page for the duration.
+ return (
+ }>
+
+
);
}
diff --git a/packages/admin/src/pages/dashboard/plugins/browse.test.tsx b/packages/admin/src/pages/dashboard/plugins/browse.test.tsx
new file mode 100644
index 0000000000..7e5099ffe0
--- /dev/null
+++ b/packages/admin/src/pages/dashboard/plugins/browse.test.tsx
@@ -0,0 +1,167 @@
+/**
+ * The directory joins two sources: a curated catalogue, and what the server
+ * reports as installed. These cover that join, and the route that reaches it —
+ * the directory sits outside `/admin/plugins/` so that no plugin name can
+ * shadow it, and that separation is worth pinning rather than assuming.
+ */
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { ROUTES } from "@admin/constants/routes";
+import { resolveRoute } from "@admin/lib/routing";
+import type { AdminBranding } from "@admin/types/branding";
+
+let mockBranding: AdminBranding = { plugins: [] } as unknown as AdminBranding;
+
+vi.mock("@admin/lib/api/publicApi", () => ({
+ publicApi: { get: () => Promise.resolve(mockBranding) },
+}));
+
+import PluginBrowsePage from "./browse";
+
+function renderBrowse() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+
+ );
+}
+
+afterEach(() => {
+ mockBranding = { plugins: [] } as unknown as AdminBranding;
+ vi.restoreAllMocks();
+});
+
+describe("plugin browse route", () => {
+ it("resolves the directory path to the browse page", () => {
+ const resolved = resolveRoute(ROUTES.PLUGIN_BROWSE, "");
+
+ expect(resolved.Component).toBe(PluginBrowsePage);
+ expect(resolved.params).toEqual({});
+ });
+
+ it("still resolves a real plugin slug to the detail page", () => {
+ // Positive control on the matcher: the dynamic route is reachable, so the
+ // assertion above is about the directory's own path rather than about a
+ // detail route that stopped matching anything.
+ const resolved = resolveRoute("/admin/plugins/nextlyhq-plugin-seo", "");
+
+ expect(resolved.Component).not.toBe(PluginBrowsePage);
+ expect(resolved.params).toEqual({ slug: "nextlyhq-plugin-seo" });
+ });
+
+ /**
+ * The separating case, and the reason the directory does not live at
+ * `/admin/plugins/browse`.
+ *
+ * `PluginDefinition.name` is an arbitrary string, so a plugin can slugify to
+ * any segment a static sibling might occupy — `browse` included. With the
+ * two sharing a parent, one of them has to win, and the loser is a page the
+ * UI still links to and nothing can reach. Under a different parent the
+ * question does not arise, so this asserts the slug the old layout would
+ * have swallowed still reaches its own page.
+ */
+ it("reaches the detail page for a plugin whose slug is the old directory segment", () => {
+ const resolved = resolveRoute("/admin/plugins/browse", "");
+
+ expect(resolved.Component).not.toBe(PluginBrowsePage);
+ expect(resolved.params).toEqual({ slug: "browse" });
+ });
+});
+
+describe("PluginBrowsePage", () => {
+ it("marks a catalogue entry installed when admin-meta reports it", async () => {
+ mockBranding = {
+ plugins: [{ name: "@nextlyhq/plugin-page-builder" }],
+ } as unknown as AdminBranding;
+
+ renderBrowse();
+
+ expect(await screen.findAllByText("Page Builder")).not.toHaveLength(0);
+ expect(
+ screen.getAllByTestId("installed-@nextlyhq/plugin-page-builder").length
+ ).toBeGreaterThan(0);
+ });
+
+ /**
+ * Required alongside the case above: on its own, the positive test passes on
+ * a page that marks everything installed, and this one passes on a page that
+ * renders nothing. Only the pair separates them, which is why each asserts
+ * the entry IS rendered before asserting what it is not.
+ */
+ it("does not mark it installed when admin-meta does not report it", async () => {
+ mockBranding = { plugins: [] } as unknown as AdminBranding;
+
+ renderBrowse();
+
+ expect(await screen.findAllByText("Page Builder")).not.toHaveLength(0);
+ expect(
+ screen.queryByTestId("installed-@nextlyhq/plugin-page-builder")
+ ).toBeNull();
+ });
+
+ /**
+ * Search reads what the card renders, not what the catalogue stores. An
+ * installed plugin's own description is what a reader sees, so typing a word
+ * from it must keep the card rather than filter it away.
+ */
+ it("finds a card by the installed description it actually shows", async () => {
+ mockBranding = {
+ plugins: [
+ {
+ name: "@nextlyhq/plugin-seo",
+ description: "Zebra crossing metadata",
+ },
+ ],
+ } as unknown as AdminBranding;
+
+ renderBrowse();
+ await screen.findByText("Zebra crossing metadata");
+
+ fireEvent.change(screen.getByPlaceholderText("Search the directory"), {
+ target: { value: "zebra" },
+ });
+
+ // Waited for, not asserted immediately: `SearchBar` debounces by 300ms, so
+ // a synchronous check runs before the query reaches this page and passes
+ // whatever the search would have done.
+ //
+ // The disappearance is the control. Asserting only that SEO survives is
+ // satisfied by a page that never filtered at all, which is the same green
+ // a search reading the wrong field produces.
+ // The timeout is sized to the debounce, not to taste. `waitFor` defaults
+ // to 1000ms, which is only ~3x a delay the component takes deliberately,
+ // and under a full parallel suite that margin is not enough — this passed
+ // alone and failed in the whole run. The property being asserted is
+ // unchanged; only the patience is.
+ await waitFor(
+ () =>
+ expect(
+ screen.queryByRole("heading", { name: "Page Builder" })
+ ).toBeNull(),
+ { timeout: 5000 }
+ );
+ expect(screen.getByRole("heading", { name: "SEO" })).toBeInTheDocument();
+ });
+
+ it("prefers an installed plugin's own description over the catalogue's", async () => {
+ mockBranding = {
+ plugins: [
+ {
+ name: "@nextlyhq/plugin-seo",
+ description: "What the installed plugin says about itself",
+ },
+ ],
+ } as unknown as AdminBranding;
+
+ renderBrowse();
+
+ expect(
+ await screen.findByText("What the installed plugin says about itself")
+ ).toBeInTheDocument();
+ });
+});
diff --git a/packages/admin/src/pages/dashboard/plugins/browse.tsx b/packages/admin/src/pages/dashboard/plugins/browse.tsx
new file mode 100644
index 0000000000..00c7872567
--- /dev/null
+++ b/packages/admin/src/pages/dashboard/plugins/browse.tsx
@@ -0,0 +1,219 @@
+"use client";
+
+/**
+ * Plugin Directory
+ *
+ * Browse the plugins Nextly publishes, see which are already installed, and
+ * get the commands to add one. Discovery only: installing a plugin means
+ * adding a dependency and a line to `nextly.config.ts`, so this page never
+ * writes to a project's source and never mutates plugin state.
+ *
+ * @module pages/dashboard/plugins/browse
+ */
+
+import { useSuspenseQuery } from "@tanstack/react-query";
+import type React from "react";
+import { Suspense, useMemo, useState } from "react";
+
+import { PageContainer } from "@admin/components/layout/page-container";
+import { Breadcrumbs } from "@admin/components/shared";
+import { PageErrorFallback } from "@admin/components/shared/error-fallbacks";
+import { QueryErrorBoundary } from "@admin/components/shared/query-error-boundary";
+import { SearchBar } from "@admin/components/shared/search-bar";
+import { ROUTES } from "@admin/constants/routes";
+import { publicApi } from "@admin/lib/api/publicApi";
+import { resolveCataloguePresentation } from "@admin/lib/plugins/registry/resolve-catalogue-presentation";
+import {
+ shouldShowFeatured,
+ staticRegistrySource,
+} from "@admin/lib/plugins/registry/static-source";
+import type { RegistryPlugin } from "@admin/lib/plugins/registry/types";
+import type { AdminBranding, PluginMetadata } from "@admin/types/branding";
+
+import { PluginCard } from "./components/PluginCard";
+
+/**
+ * Name, the RENDERED description, and tags.
+ *
+ * The description comes from the same resolution the card renders, so an
+ * installed plugin is searched by the text a reader can actually see. Reading
+ * `plugin.description` here instead would search the catalogue's copy while
+ * the card shows the plugin's own, and typing a word visibly on screen would
+ * make that card disappear.
+ *
+ * Author is deliberately not searched: every first-party entry shares one, so
+ * it would match the whole catalogue.
+ */
+function matches(
+ plugin: RegistryPlugin,
+ installed: Pick | undefined,
+ query: string
+): boolean {
+ const { description } = resolveCataloguePresentation(plugin, installed);
+ const haystack = [plugin.name, description, ...(plugin.tags ?? [])]
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(query);
+}
+
+function PluginGrid({
+ plugins,
+ installedByName,
+}: {
+ plugins: RegistryPlugin[];
+ installedByName: Map;
+}): React.ReactElement {
+ return (
+
+ {plugins.map(plugin => (
+
+ ))}
+
+ );
+}
+
+function BrowseContent(): React.ReactElement {
+ const [query, setQuery] = useState("");
+
+ const { data: branding } = useSuspenseQuery({
+ queryKey: ["admin-meta"],
+ queryFn: () => publicApi.get("/admin-meta"),
+ staleTime: 5 * 60 * 1000,
+ });
+ const { data: entries } = useSuspenseQuery({
+ queryKey: ["plugin-registry"],
+ queryFn: () => staticRegistrySource.list(),
+ });
+ const { data: featuredIds } = useSuspenseQuery({
+ queryKey: ["plugin-registry-featured"],
+ queryFn: () => staticRegistrySource.featured(),
+ });
+
+ // Installed truth comes from admin-meta and nothing else. An entry absent
+ // from it is not installed; an installed plugin absent from the catalogue is
+ // private or third-party and is correctly not listed here.
+ const installedByName = useMemo(() => {
+ const map = new Map();
+ for (const plugin of branding?.plugins ?? []) map.set(plugin.name, plugin);
+ return map;
+ }, [branding?.plugins]);
+
+ const normalized = query.trim().toLowerCase();
+ const visible = useMemo(
+ () =>
+ normalized
+ ? entries.filter(e => matches(e, installedByName.get(e.id), normalized))
+ : entries,
+ [entries, installedByName, normalized]
+ );
+
+ const featured = useMemo(
+ () =>
+ featuredIds
+ .map(id => entries.find(e => e.id === id))
+ .filter((e): e is RegistryPlugin => e !== undefined),
+ [entries, featuredIds]
+ );
+
+ // The strip is a recommendation, so it is hidden while searching: a filtered
+ // grid is the user's own list, and a fixed "start here" row above it answers
+ // a question they stopped asking.
+ const showFeatured = !normalized && shouldShowFeatured(entries, featuredIds);
+
+ // The grid holds what the strip does not. Showing every entry below a strip
+ // that repeats two of them puts the same card on screen twice, which reads
+ // as a rendering fault rather than as a recommendation.
+ const featuredIdSet = useMemo(
+ () => new Set(showFeatured ? featuredIds : []),
+ [showFeatured, featuredIds]
+ );
+ const rest = useMemo(
+ () => visible.filter(e => !featuredIdSet.has(e.id)),
+ [visible, featuredIdSet]
+ );
+
+ return (
+ <>
+
+
+
+
+ {showFeatured && (
+
+
+ Start here
+
+
+
+ )}
+
+
+
+ {showFeatured ? "More plugins" : "Plugins"}
+
+ {rest.length > 0 ? (
+
+ ) : (
+ // Two different nothings. A search that matched nothing names the
+ // term, because the reader chose it and can edit it; an empty
+ // catalogue must not, since quoting an empty string reads as a bug.
+
+ {normalized
+ ? `No plugins match “${query.trim()}”.`
+ : "No plugins to show yet."}
+
+ Plugins published by Nextly. Adding one is a dependency and a line in
+ your Nextly config, so open a plugin to get both.
+
+
+
+
+ }
+ >
+
+
+
+
+);
+
+export default PluginBrowsePage;
diff --git a/packages/admin/src/pages/dashboard/plugins/components/NotInstalledPlugin.tsx b/packages/admin/src/pages/dashboard/plugins/components/NotInstalledPlugin.tsx
new file mode 100644
index 0000000000..7415ef686b
--- /dev/null
+++ b/packages/admin/src/pages/dashboard/plugins/components/NotInstalledPlugin.tsx
@@ -0,0 +1,288 @@
+"use client";
+
+import { Button } from "@nextlyhq/ui";
+import type React from "react";
+import { useState } from "react";
+
+import { AlertCircle, Check, Copy } from "@admin/components/icons";
+import { PluginIcon } from "@admin/components/shared/plugin-icon";
+import { adminVersion } from "@admin/lib/admin-version";
+import { categoryLabel } from "@admin/lib/plugins/plugin-categories";
+import {
+ PACKAGE_MANAGERS,
+ adminImportStatement,
+ adminStylesImport,
+ importStatement,
+ installCommand,
+ pluginsArrayEntry,
+ type PackageManager,
+} from "@admin/lib/plugins/registry/install-command";
+import type { RegistryPlugin } from "@admin/lib/plugins/registry/types";
+
+/**
+ * A copyable command line.
+ *
+ * The confirmation lives on the button rather than in a toast: the reader is
+ * looking at the thing they clicked, and a toast in the corner asks them to
+ * look somewhere else to learn that something local succeeded.
+ */
+function CopyLine({
+ value,
+ label,
+ file,
+}: {
+ value: string;
+ label: string;
+ /** The file this line goes in, when it is not the one the section names. */
+ file?: string;
+}) {
+ const [outcome, setOutcome] = useState<"idle" | "copied" | "failed">("idle");
+
+ // The Clipboard API needs a secure context, so an admin served over plain
+ // HTTP — a LAN host, a colleague's dev box — has no `navigator.clipboard` at
+ // all, and `writeText` can be rejected by permissions even where it exists.
+ // Both end with the reader having to select the line by hand, so both have
+ // to say so: a button that silently does nothing reads as a broken page.
+ const copy = () => {
+ const clipboard = navigator.clipboard;
+ if (!clipboard) {
+ setOutcome("failed");
+ return;
+ }
+ try {
+ void clipboard.writeText(value).then(
+ () => {
+ setOutcome("copied");
+ window.setTimeout(() => setOutcome("idle"), 2000);
+ },
+ () => setOutcome("failed")
+ );
+ } catch {
+ // A synchronous throw rather than a rejection, which some
+ // implementations do when the document is not focused.
+ setOutcome("failed");
+ }
+ };
+
+ return (
+
+
+ {label}
+ {file && (
+
+ {file}
+
+ )}
+
+
+
+ {value}
+
+
+
+ {outcome === "failed" && (
+ // Announced, because the only other signal is an icon on the button
+ // the reader just pressed, and the instruction matters more than the
+ // failure: the line above is still there to be selected.
+
+ Could not copy to the clipboard. Select the line above and copy it
+ manually.
+
+ )}
+ {outcome === "copied" && (
+ // Success is announced too, and only to assistive technology. The
+ // button's `aria-label` overrides its descendants, so swapping the
+ // glyph changes nothing a screen reader can perceive — sighted users
+ // already have the tick, and this is the equivalent for everyone else.
+
+ {label} copied to the clipboard.
+
+ )}
+
+ );
+}
+
+/**
+ * What a catalogue plugin's page shows when the project has not installed it.
+ *
+ * Deliberately thin, and the reason is the invariant the whole surface is
+ * built on: verified content only ever appears in the verified section. Nothing
+ * here has been observed running, so there is no contributions section, no
+ * permissions and no routes — only what the catalogue claims, plus the lines
+ * that would make the claims checkable.
+ *
+ * @module pages/dashboard/plugins/components/NotInstalledPlugin
+ */
+export function NotInstalledPlugin({
+ plugin,
+}: {
+ plugin: RegistryPlugin;
+}): React.ReactElement {
+ const [manager, setManager] = useState("pnpm");
+ const label = categoryLabel(plugin.category);
+ const adminImport = adminImportStatement(plugin);
+ const adminStyles = adminStylesImport(plugin);
+
+ return (
+
+ Install the package, then make two edits in{" "}
+ nextly.config.ts: import the
+ plugin at the top of the file, and add it to the{" "}
+ plugins array. Nextly picks it up
+ on the next start.
+
+
+
+
+ {PACKAGE_MANAGERS.map(pm => (
+
+ ))}
+
+
+ {/* One line per edit, each copyable on its own: the import and the
+ array entry land in different places in the same file, so joining
+ them into one block would be a snippet nobody can paste anywhere. */}
+
+
+
+ {(adminImport || adminStyles) && (
+
+
+ This plugin also ships admin UI, registered by importing it in
+ your admin route page. Skip these and the plugin still loads — its
+ editors just fall back to plain inputs
+ {adminStyles ? ", and render unstyled" : ""}.
+
+ )}
+
+
+ {/* States the boundary rather than leaving it implied: a reader looking
+ for permissions and routes should learn why they are absent instead
+ of concluding the plugin contributes nothing. */}
+
+ What this plugin adds — its collections, permissions and API routes — is
+ only known once it is installed and Nextly has loaded it.
+
+ );
+}
diff --git a/packages/admin/src/pages/dashboard/plugins/components/PluginCard.tsx b/packages/admin/src/pages/dashboard/plugins/components/PluginCard.tsx
new file mode 100644
index 0000000000..733f7713d7
--- /dev/null
+++ b/packages/admin/src/pages/dashboard/plugins/components/PluginCard.tsx
@@ -0,0 +1,95 @@
+import { Badge } from "@nextlyhq/ui";
+import type React from "react";
+
+import { PluginIconFrom } from "@admin/components/shared/plugin-icon";
+import { Link } from "@admin/components/ui/link";
+import { ROUTES, buildRoute } from "@admin/constants/routes";
+import { categoryLabel } from "@admin/lib/plugins/plugin-categories";
+import { pluginSlug } from "@admin/lib/plugins/plugin-slug";
+import {
+ cataloguePresentationCandidates,
+ resolveCataloguePresentation,
+} from "@admin/lib/plugins/registry/resolve-catalogue-presentation";
+import type { RegistryPlugin } from "@admin/lib/plugins/registry/types";
+import type { PluginMetadata } from "@admin/types/branding";
+
+interface PluginCardProps {
+ plugin: RegistryPlugin;
+ /**
+ * The installed plugin this entry describes, when the project has it.
+ *
+ * The metadata itself rather than a boolean: an installed plugin is the
+ * authority on its own icon and description, so the card needs what it
+ * declared and not merely the fact that it exists.
+ */
+ installed: Pick | undefined;
+}
+
+/**
+ * One plugin in the directory grid.
+ *
+ * A link rather than a card with a button inside it: the whole card is one
+ * destination, so making the card the anchor gives a keyboard user one stop
+ * instead of a container they must enter to find the real control.
+ *
+ * @module pages/dashboard/plugins/components/PluginCard
+ */
+export function PluginCard({
+ plugin,
+ installed,
+}: PluginCardProps): React.ReactElement {
+ const presentation = resolveCataloguePresentation(plugin, installed);
+ const label = categoryLabel(plugin.category);
+
+ return (
+
+
+
+
+
+
+
+ {/* `truncate` needs the min-w-0 above: a flex child defaults to
+ min-width:auto, which refuses to shrink below its content. */}
+
+
+ {/* Two lines, not a character count: a clamp adapts to the card's real
+ width, where a truncated string would cut at a different place on
+ every breakpoint. */}
+
+ {presentation.description}
+
+
+ {label && (
+
+ {label}
+
+ )}
+
+ );
+}
diff --git a/packages/admin/src/pages/dashboard/plugins/components/PluginPageLoading.tsx b/packages/admin/src/pages/dashboard/plugins/components/PluginPageLoading.tsx
new file mode 100644
index 0000000000..c4ff443b8e
--- /dev/null
+++ b/packages/admin/src/pages/dashboard/plugins/components/PluginPageLoading.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import type React from "react";
+
+import { Loader2 } from "@admin/components/icons";
+
+/**
+ * What a plugin page shows while the data it suspends on is in flight.
+ *
+ * A `Suspense` fallback is not optional for these pages. `QueryErrorBoundary`
+ * handles errors and nothing else, and the only boundary above it is
+ * `RootLayout`'s `fallback={null}` — which exists to hide a lazy chunk
+ * swapping in, not to stand in for a page. Without a local boundary a
+ * suspending page is a blank screen for as long as the request takes.
+ *
+ * @module pages/dashboard/plugins/components/PluginPageLoading
+ */
+export function PluginPageLoading({
+ label = "Loading…",
+}: {
+ label?: string;
+}): React.ReactElement {
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/packages/admin/src/pages/dashboard/plugins/index.tsx b/packages/admin/src/pages/dashboard/plugins/index.tsx
index 95d4b9d55d..6523d462ac 100644
--- a/packages/admin/src/pages/dashboard/plugins/index.tsx
+++ b/packages/admin/src/pages/dashboard/plugins/index.tsx
@@ -10,6 +10,7 @@
* @module pages/dashboard/plugins
*/
+import { Button } from "@nextlyhq/ui";
import type React from "react";
import { Suspense } from "react";
@@ -17,6 +18,7 @@ import { PageContainer } from "@admin/components/layout/page-container";
import { Breadcrumbs } from "@admin/components/shared";
import { PageErrorFallback } from "@admin/components/shared/error-fallbacks";
import { QueryErrorBoundary } from "@admin/components/shared/query-error-boundary";
+import { Link } from "@admin/components/ui/link";
import { ROUTES } from "@admin/constants/routes";
import PluginsTable from "./components/PluginsTable";
@@ -52,6 +54,14 @@ const PluginsOverviewPage: React.FC = () => {
whether it is enabled.
+
+ {/* In the header rather than only in the empty state: a project that
+ already has plugins is the one most likely to want another, and an
+ action that appears only while the list is empty is unreachable
+ exactly then. */}
+
{/* Plugins table */}
diff --git a/packages/admin/src/pages/dashboard/plugins/not-installed-detail.test.tsx b/packages/admin/src/pages/dashboard/plugins/not-installed-detail.test.tsx
new file mode 100644
index 0000000000..3296510959
--- /dev/null
+++ b/packages/admin/src/pages/dashboard/plugins/not-installed-detail.test.tsx
@@ -0,0 +1,291 @@
+/**
+ * A plugin's page has one URL whether or not the project has it. These cover
+ * the uninstalled half: the directory links every card here, and most cards
+ * are for plugins nobody has installed yet, so that is the ordinary path
+ * rather than an error.
+ */
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { AdminBranding } from "@admin/types/branding";
+
+/**
+ * The install line, as a pattern shared by the assertions that require it and
+ * the ones that require its absence.
+ *
+ * A literal `"pnpm add @nextlyhq/plugin-seo"` would match nothing now that the
+ * command is pinned, so every `queryByText(...).toBeNull()` below would pass
+ * on a page that rendered the install block in full. Matching the version
+ * loosely keeps the absence assertions answering the question they ask, while
+ * `install-command.test.ts` pins the exact strings.
+ */
+const INSTALL_COMMAND = /^pnpm add @nextlyhq\/plugin-seo@\d/;
+
+/**
+ * jsdom's navigator has no `clipboard` property at all, which is the same
+ * shape a browser on a plain-HTTP origin presents — so the "unavailable" case
+ * below is the environment's own default rather than something simulated.
+ */
+const ORIGINAL_CLIPBOARD = Object.getOwnPropertyDescriptor(
+ navigator,
+ "clipboard"
+);
+
+function setClipboard(clipboard: Clipboard | undefined) {
+ Object.defineProperty(navigator, "clipboard", {
+ value: clipboard,
+ configurable: true,
+ });
+}
+
+/**
+ * Puts back what was there, which in jsdom is no own property at all —
+ * defining one with `undefined` is not the same thing, and it would outlive
+ * these tests for anything else sharing this environment.
+ */
+function restoreClipboard() {
+ if (ORIGINAL_CLIPBOARD) {
+ Object.defineProperty(navigator, "clipboard", ORIGINAL_CLIPBOARD);
+ } else {
+ delete (navigator as { clipboard?: Clipboard }).clipboard;
+ }
+}
+
+let mockBranding: AdminBranding = { plugins: [] } as unknown as AdminBranding;
+let mockBrandingStatus = { isPending: false, isUnavailable: false };
+
+vi.mock("@admin/context/providers/BrandingProvider", () => ({
+ useBranding: () => mockBranding,
+ useBrandingStatus: () => mockBrandingStatus,
+}));
+
+import PluginDetailPage from "./[slug]";
+
+function renderDetail(slug: string) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+
+ );
+}
+
+afterEach(() => {
+ mockBranding = { plugins: [] } as unknown as AdminBranding;
+ mockBrandingStatus = { isPending: false, isUnavailable: false };
+ restoreClipboard();
+ vi.restoreAllMocks();
+});
+
+describe("plugin detail, not installed", () => {
+ /**
+ * The separating case. Before this view existed the page searched only
+ * `branding.plugins`, so every catalogue entry the directory linked to
+ * rendered "Plugin not found" — a dead end on the primary discovery path.
+ */
+ it("shows the catalogue entry instead of a not-found page", async () => {
+ renderDetail("nextlyhq-plugin-seo");
+
+ expect(
+ await screen.findByRole("heading", { name: "SEO" })
+ ).toBeInTheDocument();
+ expect(screen.queryByText("Plugin not found")).toBeNull();
+ });
+
+ /**
+ * All three lines, because the recipe only works as a set: installing the
+ * package does not introduce the binding, so an array entry shown without
+ * its import names an identifier that does not exist in the file.
+ */
+ it("offers the install command, the import, and the array entry", async () => {
+ renderDetail("nextlyhq-plugin-seo");
+
+ expect(await screen.findByText(INSTALL_COMMAND)).toBeInTheDocument();
+ expect(
+ screen.getByText('import { seoPlugin } from "@nextlyhq/plugin-seo";')
+ ).toBeInTheDocument();
+ // The element alone, with no `plugins:` property around it. A reader who
+ // already has plugins configured appends this; a property could only be
+ // pasted by replacing theirs.
+ expect(
+ screen.getByText('seoPlugin({ collections: ["your-collection"] })')
+ ).toBeInTheDocument();
+ });
+
+ /**
+ * A plugin the project HAS, on a page opened cold. Until admin-meta answers
+ * the installed list is empty for a reason that says nothing about the
+ * project, and reading that as "not installed" tells the reader to install
+ * something they already have.
+ */
+ it("does not offer to install while the installed list is still loading", async () => {
+ mockBrandingStatus = { isPending: true, isUnavailable: false };
+
+ renderDetail("nextlyhq-plugin-seo");
+
+ expect(await screen.findByRole("status")).toBeInTheDocument();
+ expect(screen.queryByText(INSTALL_COMMAND)).toBeNull();
+ expect(screen.queryByText("Plugin not found")).toBeNull();
+ });
+
+ /** Same reasoning, permanent: a failed request is not evidence of absence. */
+ it("does not offer to install when the installed list could not be loaded", async () => {
+ mockBrandingStatus = { isPending: false, isUnavailable: true };
+
+ renderDetail("nextlyhq-plugin-seo");
+
+ expect(
+ await screen.findByText("Could not load your installed plugins")
+ ).toBeInTheDocument();
+ expect(screen.queryByText(INSTALL_COMMAND)).toBeNull();
+ });
+
+ /**
+ * The admin-module step, which only some plugins need. Form Builder ships an
+ * `/admin` side-effect module; skip it and the plugin installs, its server
+ * half runs, and its builder silently degrades to plain inputs.
+ */
+ it("asks for the admin route import when the plugin ships one", async () => {
+ renderDetail("nextlyhq-plugin-form-builder");
+
+ expect(
+ await screen.findByText('import "@nextlyhq/plugin-form-builder/admin";')
+ ).toBeInTheDocument();
+ });
+
+ /**
+ * Page Builder needs both the module and its stylesheet. Form Builder needs
+ * only the module, so a page that showed a stylesheet line for it would be
+ * naming a subpath that does not resolve.
+ */
+ it("asks for the editor stylesheet only where the package exports one", async () => {
+ renderDetail("nextlyhq-plugin-page-builder");
+ expect(
+ await screen.findByText(
+ 'import "@nextlyhq/plugin-page-builder/styles/editor.css";'
+ )
+ ).toBeInTheDocument();
+
+ cleanup();
+
+ renderDetail("nextlyhq-plugin-form-builder");
+ await screen.findByText('import "@nextlyhq/plugin-form-builder/admin";');
+ expect(screen.queryByText("Editor stylesheet")).toBeNull();
+ });
+
+ /**
+ * The separating case. SEO has no `/admin` export, so telling a reader to
+ * import one would name a subpath that does not resolve.
+ */
+ it("omits the admin route import for a plugin without one", async () => {
+ renderDetail("nextlyhq-plugin-seo");
+
+ await screen.findByRole("heading", { name: "SEO" });
+ expect(screen.queryByText(/\/admin";$/)).toBeNull();
+ expect(screen.queryByText("Admin route import")).toBeNull();
+ });
+
+ /**
+ * The catalogue query suspends. The only boundary above this page is
+ * `RootLayout`'s `fallback={null}`, which exists to hide a lazy chunk
+ * swapping in, so without a local one the page is blank for the duration of
+ * the request rather than merely slow.
+ */
+ it("shows a loading state while the catalogue is in flight", () => {
+ renderDetail("nextlyhq-plugin-seo");
+
+ // Read synchronously, before the catalogue promise settles: this is the
+ // frame the missing boundary would have rendered as nothing.
+ expect(screen.getByRole("status")).toBeInTheDocument();
+ });
+
+ /**
+ * The Clipboard API needs a secure context, so an admin on plain HTTP has no
+ * `navigator.clipboard` at all. Without this the button is inert and the
+ * page looks broken; the line is still selectable, so the fix is to say so.
+ */
+ it("says copying failed when the clipboard is unavailable", async () => {
+ setClipboard(undefined);
+
+ renderDetail("nextlyhq-plugin-seo");
+ fireEvent.click(
+ await screen.findByRole("button", { name: /copy install command/i })
+ );
+
+ expect(await screen.findByText(/could not copy/i)).toBeInTheDocument();
+ });
+
+ it("says copying failed when the write is rejected", async () => {
+ setClipboard({
+ writeText: () => Promise.reject(new Error("denied")),
+ } as unknown as Clipboard);
+
+ renderDetail("nextlyhq-plugin-seo");
+ fireEvent.click(
+ await screen.findByRole("button", { name: /copy install command/i })
+ );
+
+ expect(await screen.findByText(/could not copy/i)).toBeInTheDocument();
+ });
+
+ /**
+ * The positive control for the two above: a working clipboard must NOT show
+ * the failure text, or both would pass on a component that always shows it.
+ */
+ it("stays quiet when the copy succeeds", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ setClipboard({ writeText } as unknown as Clipboard);
+
+ renderDetail("nextlyhq-plugin-seo");
+ fireEvent.click(
+ await screen.findByRole("button", { name: /copy install command/i })
+ );
+
+ await waitFor(() => expect(writeText).toHaveBeenCalled());
+ expect(screen.queryByText(/could not copy/i)).toBeNull();
+ });
+
+ /**
+ * The invariant the whole surface rests on: verified content only ever
+ * appears in the verified section. Nothing here has been observed running,
+ * so the sections that report what a plugin contributes must be absent.
+ */
+ it("reports no contributions, because none have been observed", async () => {
+ renderDetail("nextlyhq-plugin-seo");
+
+ await screen.findByRole("heading", { name: "SEO" });
+ expect(screen.queryByText("Permissions")).toBeNull();
+ expect(screen.queryByText("API routes")).toBeNull();
+ });
+
+ it("still says not found for a slug no plugin and no entry matches", async () => {
+ renderDetail("no-such-plugin");
+
+ expect(await screen.findByText("Plugin not found")).toBeInTheDocument();
+ });
+
+ /**
+ * Installed metadata is observed; the catalogue is a claim. When both
+ * describe the same package the observed one decides which view renders,
+ * otherwise installing a plugin would leave it looking uninstalled.
+ */
+ it("prefers the installed plugin over the catalogue entry", async () => {
+ mockBranding = {
+ plugins: [{ name: "@nextlyhq/plugin-seo", version: "1.0.0" }],
+ } as unknown as AdminBranding;
+
+ renderDetail("nextlyhq-plugin-seo");
+
+ expect(await screen.findByText("v1.0.0")).toBeInTheDocument();
+ expect(screen.queryByText(INSTALL_COMMAND)).toBeNull();
+ });
+});
diff --git a/packages/admin/src/pages/dashboard/plugins/plugin-detail.test.tsx b/packages/admin/src/pages/dashboard/plugins/plugin-detail.test.tsx
index c669474b44..69bfc7f0f5 100644
--- a/packages/admin/src/pages/dashboard/plugins/plugin-detail.test.tsx
+++ b/packages/admin/src/pages/dashboard/plugins/plugin-detail.test.tsx
@@ -1,3 +1,4 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
@@ -43,6 +44,8 @@ const plugins: PluginMetadata[] = [
vi.mock("@admin/context/providers/BrandingProvider", () => ({
useBranding: () => ({ plugins }),
+ // Settled with an answer, so a slug missing from `plugins` really is absent.
+ useBrandingStatus: () => ({ isPending: false, isUnavailable: false }),
}));
describe("PluginDetailPage", () => {
@@ -101,8 +104,21 @@ describe("PluginDetailPage", () => {
expect(screen.getByText(/its behavior does not load/i)).toBeInTheDocument();
});
- it("renders a not-found state for an unknown slug", () => {
- render();
- expect(screen.getByText("Plugin not found")).toBeInTheDocument();
+ /**
+ * Not found now means neither installed NOR in the plugin directory, so this
+ * path reads the catalogue and needs a query client. A slug that IS in the
+ * catalogue renders the uninstalled view instead, covered in
+ * `not-installed-detail.test.tsx`.
+ */
+ it("renders a not-found state for a slug nothing knows", async () => {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ render(
+
+
+
+ );
+ expect(await screen.findByText("Plugin not found")).toBeInTheDocument();
});
});
diff --git a/packages/admin/src/pages/registry.ts b/packages/admin/src/pages/registry.ts
index 7bc48ffb8e..848f260b21 100644
--- a/packages/admin/src/pages/registry.ts
+++ b/packages/admin/src/pages/registry.ts
@@ -20,6 +20,7 @@ import DashboardPage from "./dashboard/index";
import MediaLibraryPage from "./dashboard/media/index";
import PluginDetailPage from "./dashboard/plugins/[slug]";
import PluginSettingsPage from "./dashboard/plugins/[slug]/settings";
+import PluginBrowsePage from "./dashboard/plugins/browse";
import PluginsOverviewPage from "./dashboard/plugins/index";
import CollectionsLandingRedirect from "./dashboard/redirects/CollectionsLandingRedirect";
import FieldGroupsLandingRedirect from "./dashboard/redirects/FieldGroupsLandingRedirect";
@@ -372,6 +373,14 @@ export const routeConfig: Record = {
type: "private",
requiredPermission: "manage-settings",
},
+ // Registered outside `/admin/plugins/`, so no ordering rule holds this in
+ // place: the directory and a plugin's detail page cannot match the same
+ // path, whatever the plugin is called.
+ [ROUTES.PLUGIN_BROWSE]: {
+ component: PluginBrowsePage,
+ type: "private",
+ requiredPermission: "manage-settings",
+ },
[ROUTES.PLUGIN_DETAIL]: {
component: PluginDetailPage,
type: "private",
diff --git a/packages/admin/tsup.config.ts b/packages/admin/tsup.config.ts
index 7659f286f6..2a117154c8 100644
--- a/packages/admin/tsup.config.ts
+++ b/packages/admin/tsup.config.ts
@@ -1,4 +1,5 @@
import fs from "fs";
+import { createRequire } from "module";
import path from "path";
import { fileURLToPath } from "url";
@@ -8,6 +9,13 @@ import { defineConfig } from "tsup";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
+// Read at build time so `adminVersion()` can name the release this bundle was
+// published in. The browser bundle has no module resolver to read it later.
+const require = createRequire(import.meta.url);
+const { version: ADMIN_VERSION } = require("./package.json") as {
+ version: string;
+};
+
/**
* Bundle size target in KB (minified, not gzipped)
* Based on industry standards for comprehensive UI libraries:
@@ -100,6 +108,9 @@ export default defineConfig(options => [
resolve: true,
},
tsconfig: "tsconfig.json",
+ define: {
+ __NEXTLY_ADMIN_VERSION__: JSON.stringify(ADMIN_VERSION),
+ },
external: EXTERNAL_DEPS,
noExternal: NO_EXTERNAL_DEPS,
splitting: true,
diff --git a/packages/admin/vitest.config.ts b/packages/admin/vitest.config.ts
index 8853a7cd9b..e740b12f34 100644
--- a/packages/admin/vitest.config.ts
+++ b/packages/admin/vitest.config.ts
@@ -1,9 +1,20 @@
+import { createRequire } from "module";
+
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
+// Mirror the tsup `define` so `adminVersion()` resolves under test.
+const require = createRequire(import.meta.url);
+const { version: adminVersion } = require("./package.json") as {
+ version: string;
+};
+
export default defineConfig({
plugins: [react(), tsconfigPaths()],
+ define: {
+ __NEXTLY_ADMIN_VERSION__: JSON.stringify(adminVersion),
+ },
test: {
name: "admin",
globals: true,