feat: replace creators directory with official orgs (#3247)

This commit is contained in:
Patrick Erichsen
2026-07-23 14:07:20 -07:00
committed by GitHub
parent a402451282
commit edd4e01a07
25 changed files with 337 additions and 513 deletions
+5 -1
View File
@@ -5,7 +5,7 @@ import {
} from "./publicRouteReservations";
describe("public route reservations", () => {
it.each(["admin", "clawhub", "creators", "docs", "plugins", "publishers", "skills"])(
it.each(["admin", "clawhub", "docs", "official", "plugins", "publishers", "skills"])(
"reserves @%s as a public owner handle",
(handle) => {
expect(isReservedPublicOwnerHandle(handle)).toBe(true);
@@ -17,6 +17,10 @@ describe("public route reservations", () => {
expect(isReservedPublicOwnerHandle(handle)).toBe(false);
});
it("releases the removed creators route handle", () => {
expect(isReservedPublicOwnerHandle("creators")).toBe(false);
});
it("does not normalize at-sign prefixes", () => {
expect(isReservedPublicOwnerHandle("@clawhub")).toBe(false);
});
+1 -1
View File
@@ -3,8 +3,8 @@ import { OPENCLAW_EXTENSION_SLUG_TO_PACKAGE } from "clawhub-schema";
const RESERVED_PUBLIC_OWNER_HANDLES = new Set([
"admin",
"clawhub",
"creators",
"docs",
"official",
"plugins",
"publishers",
"skills",
+6 -2
View File
@@ -56,6 +56,10 @@ describe("assertValidSkillSlug", () => {
expect(assertValidSkillSlug("A-B-C")).toBe("a-b-c");
});
it("allows the removed creators route slug", () => {
expect(assertValidSkillSlug("creators")).toBe("creators");
});
it.each([
["", "required"],
[" ", "required"],
@@ -82,7 +86,7 @@ describe("assertValidSkillSlug", () => {
"clawhub",
"souls",
"packages",
"creators",
"official",
"publishers",
])("rejects reserved slug %s", (slug) => {
// Some short reserved entries (e.g. "u") are also blocked by the
@@ -90,7 +94,7 @@ describe("assertValidSkillSlug", () => {
expect(() => assertValidSkillSlug(slug)).toThrow();
});
it.each(["openclaw", "creators", "publishers"])(
it.each(["openclaw", "official", "publishers"])(
"emits the reserved-specific error for long reserved slug %s",
(slug) => {
expect(() => assertValidSkillSlug(slug)).toThrow(/reserved/i);
+1 -2
View File
@@ -34,7 +34,7 @@ const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
"orgs",
"packages",
"plugins",
"creators",
"official",
"publishers",
"publish",
"publish-plugin",
@@ -86,7 +86,6 @@ const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
"system",
"root",
"owner",
"official",
"staff",
"team",
"mod",
+12 -5
View File
@@ -126,18 +126,18 @@ function publicRouteCases(): PublicRouteCase[] {
},
},
{
label: "creators browse",
path: () => "/creators",
label: "official browse",
path: () => "/official",
assert: async (page) => {
await expect(page.getByRole("heading", { name: /^Creators/ })).toBeVisible();
await expect(page.getByRole("heading", { name: /^Official/ })).toBeVisible();
},
},
{
label: "publishers browse redirect",
path: () => "/publishers",
assert: async (page) => {
await expect(page).toHaveURL(/\/creators/);
await expect(page.getByRole("heading", { name: /^Creators/ })).toBeVisible();
await expect(page).toHaveURL(/\/official/);
await expect(page.getByRole("heading", { name: /^Official/ })).toBeVisible();
},
},
{
@@ -241,3 +241,10 @@ for (const route of publicRouteCases()) {
await expectPublicRouteHealthy(page, route, fixtures);
});
}
test("removed creators route renders not found", async ({ page }) => {
await stubExternalMediaInVitePreview(page);
await page.goto("/creators", { waitUntil: "domcontentloaded" });
await waitForHydration(page);
await expect(page.getByRole("heading", { name: "We couldn't find that page." })).toBeVisible();
});
-282
View File
@@ -1,282 +0,0 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { loaderDataMock, navigateMock, queryMock, searchMock } = vi.hoisted(() => ({
loaderDataMock: vi.fn(),
navigateMock: vi.fn(),
queryMock: vi.fn(),
searchMock: vi.fn(),
}));
vi.mock("../convex/client", () => ({
convexHttp: { query: (...args: unknown[]) => queryMock(...args) },
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute:
() =>
(config: {
component?: unknown;
head?: unknown;
loader?: unknown;
loaderDeps?: unknown;
validateSearch?: unknown;
}) => ({
__config: config,
useLoaderData: () => loaderDataMock(),
useNavigate: () => navigateMock,
useSearch: () => searchMock(),
}),
Link: ({
children,
className,
resetScroll: _resetScroll,
to,
...props
}: {
children: ReactNode;
className?: string;
resetScroll?: boolean;
to?: string;
"aria-label"?: string;
}) => (
<a className={className} href={to} {...props}>
{children}
</a>
),
}));
vi.mock("../components/PublisherListItem", () => ({
PublisherListItem: ({ publisher }: { publisher: { _id: string } }) => <div>{publisher._id}</div>,
}));
vi.mock("../lib/site", () => ({
SITE_NAME: "ClawHub",
getClawHubSiteUrl: () => "https://clawhub.ai",
}));
async function loadRoute() {
return (await import("../routes/creators/index")).Route as unknown as {
__config: {
component?: ComponentType;
head?: () => {
links?: Array<{ rel: string; href: string }>;
meta?: Array<Record<string, string>>;
};
loader?: (args: {
deps: { kind?: "orgs" | "people"; official?: boolean; q?: string };
}) => Promise<unknown>;
validateSearch?: (search: Record<string, unknown>) => Record<string, unknown>;
};
};
}
describe("creators route", () => {
beforeEach(() => {
vi.resetModules();
loaderDataMock.mockReset();
loaderDataMock.mockReturnValue({
page: [],
counts: { all: 0, organizations: 0, individuals: 0 },
continueCursor: "",
isDone: true,
});
navigateMock.mockReset();
queryMock.mockReset();
queryMock.mockResolvedValue({
page: [],
counts: { all: 0, organizations: 0, individuals: 0 },
continueCursor: "",
isDone: true,
});
searchMock.mockReset();
searchMock.mockReturnValue({});
});
it("renders the public creators listing surface", async () => {
const route = await loadRoute();
const result = await route.__config.loader?.({ deps: {} });
expect(result).toEqual({
page: [],
counts: { all: 0, organizations: 0, individuals: 0 },
continueCursor: "",
isDone: true,
});
expect(queryMock.mock.calls[0]?.[1]).toEqual({
paginationOpts: { cursor: null, numItems: 25 },
kind: undefined,
query: undefined,
});
});
it("passes the organization filter to the public publishers query", async () => {
const route = await loadRoute();
await route.__config.loader?.({ deps: { kind: "orgs" } });
expect(queryMock.mock.calls[0]?.[1]).toEqual({
paginationOpts: { cursor: null, numItems: 25 },
kind: "org",
query: undefined,
});
});
it("normalizes legacy builder URLs to people", async () => {
const route = await loadRoute();
expect(route.__config.validateSearch?.({ kind: "builders" })).toEqual({
kind: "people",
official: undefined,
q: undefined,
view: undefined,
});
expect(route.__config.validateSearch?.({ kind: "individuals" })).toEqual({
kind: "people",
official: undefined,
q: undefined,
view: undefined,
});
});
it("passes the people filter and query to the public publishers query", async () => {
const route = await loadRoute();
await route.__config.loader?.({ deps: { kind: "people", q: "openclaw" } });
expect(queryMock.mock.calls[0]?.[1]).toEqual({
paginationOpts: { cursor: null, numItems: 25 },
kind: "user",
query: "openclaw",
});
});
it("passes the official filter to the public publishers query without a legacy scan", async () => {
const route = await loadRoute();
await route.__config.loader?.({ deps: { official: true } });
expect(queryMock.mock.calls[0]?.[1]).toEqual({
paginationOpts: { cursor: null, numItems: 25 },
kind: undefined,
query: undefined,
official: true,
});
expect(queryMock).toHaveBeenCalledTimes(1);
});
it("renders the loaded publisher results", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByText("Creators")).toBeTruthy();
expect(screen.getByText("No publishers found")).toBeTruthy();
});
it("labels highlighted publishers as popular creators", async () => {
loaderDataMock.mockReturnValue({
page: [{ _id: "publishers:one" }],
counts: { all: 1, organizations: 0, individuals: 1 },
continueCursor: "",
isDone: true,
});
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("heading", { name: "Popular creators" })).toBeTruthy();
expect(screen.queryByText("Popular publishers")).toBeNull();
});
it("does not present the bounded publisher result count as a global total", async () => {
loaderDataMock.mockReturnValue({
page: [],
counts: { all: 17, organizations: 6, individuals: 11 },
globalCounts: { all: 17, organizations: 6, individuals: 11 },
continueCursor: "",
isDone: true,
});
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("heading", { name: "Creators" })).toBeTruthy();
expect(screen.queryByText("17")).toBeNull();
expect(screen.getByRole("radio", { name: "All" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Official" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Organizations" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Users" })).toBeTruthy();
expect(screen.queryByText("Builders")).toBeNull();
expect(screen.queryByText(/Showing/i)).toBeNull();
});
it("keeps the publisher heading unchanged when filters are active", async () => {
searchMock.mockReturnValue({ kind: "orgs" });
loaderDataMock.mockReturnValue({
page: [],
counts: { all: 6, organizations: 6, individuals: 0 },
globalCounts: { all: 17, organizations: 6, individuals: 11 },
continueCursor: "",
isDone: true,
});
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("heading", { name: "Creators" })).toBeTruthy();
expect(screen.queryByText("17")).toBeNull();
});
it("keeps publisher type filters and view controls in the horizontal controls", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
const allTab = screen.getByRole("radio", { name: "All" });
const listView = screen.getByRole("button", { name: "List" });
const searchInput = screen.getByPlaceholderText("Search publishers...");
expect(allTab.closest(".browse-controls")).not.toBeNull();
expect(listView.closest(".browse-controls")).not.toBeNull();
expect(
Boolean(listView.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING),
).toBe(true);
});
it("clears publisher search from the search field", async () => {
searchMock.mockReturnValue({ q: "ope", kind: "orgs" });
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
fireEvent.click(screen.getByRole("button", { name: "Close search" }));
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace?: boolean;
};
expect(lastCall.search({ q: "ope", kind: "orgs" })).toEqual({
q: undefined,
kind: "orgs",
});
expect(lastCall.replace).toBe(true);
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
});
it("sets creators-specific sharing metadata", async () => {
const route = await loadRoute();
const head = route.__config.head?.();
expect(head?.links).toContainEqual({ rel: "canonical", href: "https://clawhub.ai/creators" });
expect(head?.meta).toContainEqual({ property: "og:title", content: "Creators · ClawHub" });
});
});
+4 -4
View File
@@ -298,7 +298,7 @@ describe("Header", () => {
).toBeTruthy();
expect(screen.getAllByText("Skills")).toHaveLength(1);
expect(screen.getAllByText("Plugins")).toHaveLength(1);
expect(screen.getAllByText("Creators")).toHaveLength(1);
expect(screen.getAllByText("Official")).toHaveLength(1);
expect(screen.getAllByText("Docs")).toHaveLength(2);
expect(screen.queryByText("About")).toBeNull();
expect(screen.queryByText("Dashboard")).toBeNull();
@@ -310,7 +310,7 @@ describe("Header", () => {
expect(screen.getAllByText("Home")).toHaveLength(1);
expect(screen.getAllByText("Skills")).toHaveLength(2);
expect(screen.getAllByText("Plugins")).toHaveLength(2);
expect(screen.getAllByText("Creators")).toHaveLength(2);
expect(screen.getAllByText("Official")).toHaveLength(2);
expect(screen.getAllByText("Docs")).toHaveLength(3);
expect(screen.queryByText("About")).toBeNull();
});
@@ -657,7 +657,7 @@ describe("Header", () => {
.map((element) => element.textContent?.trim())
.filter((label): label is string => Boolean(label));
expect(labels.slice(0, 5)).toEqual(["Home", "Skills", "Plugins", "Creators", "Docs"]);
expect(labels.slice(0, 5)).toEqual(["Home", "Skills", "Plugins", "Official", "Docs"]);
expect(
document.querySelector(".mobile-nav-appearance-section .navbar-theme-switcher"),
).toBeTruthy();
@@ -684,7 +684,7 @@ describe("Header", () => {
.map((element) => element.textContent?.trim())
.filter((label): label is string => Boolean(label));
expect(labels).toEqual(["Home", "Skills", "Plugins", "Creators", "Docs"]);
expect(labels).toEqual(["Home", "Skills", "Plugins", "Official", "Docs"]);
});
it("links profile and starred skills from the signed-in avatar menu", () => {
@@ -38,7 +38,7 @@ vi.mock("@tanstack/react-router", () => ({
className={className}
data-search={search ? JSON.stringify(search) : undefined}
href={
params?.slug ? `/${params.slug}` : params?.handle ? `/user/${params.handle}` : "/creators"
params?.slug ? `/${params.slug}` : params?.handle ? `/user/${params.handle}` : "/official"
}
>
{children}
@@ -105,9 +105,7 @@ describe("HomePopularPublishersSection", () => {
expect(convexQueryMock).not.toHaveBeenCalled();
expect(screen.getByRole("heading", { name: "Official creators" })).toBeTruthy();
expect(screen.getByText("Explore skills and plugins from official creators.")).toBeTruthy();
expect(screen.getByRole("link", { name: "Browse creators" }).dataset.search).toBe(
'{"official":true,"kind":"orgs"}',
);
expect(screen.getByRole("link", { name: "Browse official" }).dataset.search).toBeUndefined();
await enterPublisherSection();
await waitFor(() => expect(convexQueryMock).toHaveBeenCalledTimes(1));
+189
View File
@@ -0,0 +1,189 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentType } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { loaderDataMock, navigateMock, publisherListItemMock, queryMock, searchMock } = vi.hoisted(
() => ({
loaderDataMock: vi.fn(),
navigateMock: vi.fn(),
publisherListItemMock: vi.fn(),
queryMock: vi.fn(),
searchMock: vi.fn(),
}),
);
vi.mock("../convex/client", () => ({
convexHttp: { query: (...args: unknown[]) => queryMock(...args) },
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute:
() =>
(config: {
component?: unknown;
head?: unknown;
loader?: unknown;
loaderDeps?: unknown;
validateSearch?: unknown;
}) => ({
__config: config,
useLoaderData: () => loaderDataMock(),
useNavigate: () => navigateMock,
useSearch: () => searchMock(),
}),
}));
vi.mock("../components/PublisherListItem", () => ({
PublisherListItem: (props: {
publisher: { _id: string };
showOfficialBadge?: boolean;
variant?: string;
}) => {
publisherListItemMock(props);
return <div>{props.publisher._id}</div>;
},
}));
vi.mock("../lib/site", () => ({
SITE_NAME: "ClawHub",
getClawHubSiteUrl: () => "https://clawhub.ai",
}));
async function loadRoute() {
return (await import("../routes/official/index")).Route as unknown as {
__config: {
component?: ComponentType;
head?: () => {
links?: Array<{ rel: string; href: string }>;
meta?: Array<Record<string, string>>;
};
loader?: (args: { deps: { q?: string } }) => Promise<unknown>;
validateSearch?: (search: Record<string, unknown>) => Record<string, unknown>;
};
};
}
describe("official route", () => {
beforeEach(() => {
vi.resetModules();
loaderDataMock.mockReset();
loaderDataMock.mockReturnValue({
page: [],
counts: { all: 0, organizations: 0, individuals: 0 },
continueCursor: "",
isDone: true,
});
navigateMock.mockReset();
publisherListItemMock.mockReset();
queryMock.mockReset();
queryMock.mockResolvedValue({
page: [],
counts: { all: 0, organizations: 0, individuals: 0 },
continueCursor: "",
isDone: true,
});
searchMock.mockReset();
searchMock.mockReturnValue({});
});
it("loads only official organizations", async () => {
const route = await loadRoute();
const result = await route.__config.loader?.({ deps: {} });
expect(result).toEqual({
page: [],
counts: { all: 0, organizations: 0, individuals: 0 },
continueCursor: "",
isDone: true,
});
expect(queryMock.mock.calls[0]?.[1]).toEqual({
paginationOpts: { cursor: null, numItems: 25 },
kind: "org",
official: true,
query: undefined,
});
});
it("passes search queries without exposing kind or official controls", async () => {
const route = await loadRoute();
await route.__config.loader?.({ deps: { q: "openclaw" } });
expect(queryMock.mock.calls[0]?.[1]).toEqual({
paginationOpts: { cursor: null, numItems: 25 },
kind: "org",
official: true,
query: "openclaw",
});
});
it("renders the official header and list-only empty state", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("heading", { name: "Official" })).toBeTruthy();
expect(
screen.getByText("The organizations behind the top skills and plugins on ClawHub"),
).toBeTruthy();
expect(screen.getByText("No official organizations found")).toBeTruthy();
expect(screen.queryByRole("radio")).toBeNull();
expect(screen.queryByRole("button", { name: "Grid" })).toBeNull();
expect(screen.queryByText("Popular publishers")).toBeNull();
});
it("renders official organizations in the existing table without redundant badges", async () => {
loaderDataMock.mockReturnValue({
page: [{ _id: "publishers:openclaw" }],
counts: { all: 1, organizations: 1, individuals: 0 },
continueCursor: "",
isDone: true,
});
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByText("Organization")).toBeTruthy();
expect(screen.getByText("Activity")).toBeTruthy();
expect(screen.getByText("publishers:openclaw")).toBeTruthy();
expect(publisherListItemMock).toHaveBeenCalledWith(
expect.objectContaining({
showOfficialBadge: false,
variant: "list",
}),
);
});
it("clears official organization search from the search field", async () => {
searchMock.mockReturnValue({ q: "ope" });
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
fireEvent.click(screen.getByRole("button", { name: "Close search" }));
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace?: boolean;
};
expect(lastCall.search({ q: "ope" })).toEqual({ q: undefined });
expect(lastCall.replace).toBe(true);
});
it("sets official-specific sharing metadata", async () => {
const route = await loadRoute();
const head = route.__config.head?.();
expect(head?.links).toContainEqual({ rel: "canonical", href: "https://clawhub.ai/official" });
expect(head?.meta).toContainEqual({ property: "og:title", content: "Official · ClawHub" });
expect(head?.meta).toContainEqual({
property: "og:description",
content: "The organizations behind the top skills and plugins on ClawHub.",
});
});
});
+4 -4
View File
@@ -421,7 +421,7 @@ describe("search route", () => {
expect(screen.queryByRole("button", { name: "Search all types" })).toBeNull();
});
it("links to creators browse when creator search has no matches", async () => {
it("links to official organizations when creator search has no matches", async () => {
searchMock = { q: "zzzz", type: "creators" };
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
@@ -429,9 +429,9 @@ describe("search route", () => {
render(<Component />);
expect(screen.getByText('No matches for "zzzz"')).toBeTruthy();
expect(screen.getByRole("link", { name: "Show all creators" }).getAttribute("href")).toBe(
"/creators",
);
expect(
screen.getByRole("link", { name: "Browse official organizations" }).getAttribute("href"),
).toBe("/official");
expect(screen.queryByRole("link", { name: "Show all skills" })).toBeNull();
expect(screen.queryByRole("link", { name: "Show all plugins" })).toBeNull();
});
+1 -1
View File
@@ -273,7 +273,7 @@ describe("restored UI design contract", () => {
expect(headerSource).not.toContain('className="navbar-tabs-secondary"');
expect(navSource).toContain("export const SECONDARY_NAV_ITEMS");
expect(navSource).toContain('label: "Creators"');
expect(navSource).toContain('label: "Official"');
expect(navSource).toContain('label: "Docs"');
expect(navSource).toContain("href: CLAWHUB_DOCS_URL");
expect(publicRegistrySource).toContain(
+4 -4
View File
@@ -17,7 +17,7 @@ vi.mock("@tanstack/react-router", () => ({
}));
describe("users route redirect", () => {
it("redirects legacy /users traffic to /creators", async () => {
it("redirects legacy /users traffic to /official", async () => {
const route = (await import("../routes/users/index")).Route as unknown as {
__config: { beforeLoad: (args: { search: Record<string, unknown> }) => unknown };
};
@@ -25,10 +25,10 @@ describe("users route redirect", () => {
const search = { q: "builder" };
expect(() => route.__config.beforeLoad({ search })).toThrow();
expect(redirectMock).toHaveBeenCalledWith({ to: "/creators", search, replace: true });
expect(redirectMock).toHaveBeenCalledWith({ to: "/official", search, replace: true });
});
it("redirects legacy /publishers traffic to /creators", async () => {
it("redirects legacy /publishers traffic to /official", async () => {
const route = (await import("../routes/publishers/index")).Route as unknown as {
__config: { beforeLoad: (args: { search: Record<string, unknown> }) => unknown };
};
@@ -36,7 +36,7 @@ describe("users route redirect", () => {
const search = { kind: "orgs", q: "acme" };
expect(() => route.__config.beforeLoad({ search })).toThrow();
expect(redirectMock).toHaveBeenCalledWith({ to: "/creators", search, replace: true });
expect(redirectMock).toHaveBeenCalledWith({ to: "/official", search, replace: true });
});
it("redirects legacy /p profile routes to user profiles", async () => {
@@ -146,12 +146,8 @@ export function HomePopularPublishersSection() {
</h2>
<p className="oc-section-copy">Explore skills and plugins from official creators.</p>
</div>
<Link
to="/creators"
search={{ official: true, kind: "orgs" }}
className="home-v2-popular-publishers-link oc-action oc-action-ghost"
>
Browse creators <ArrowRight size={14} aria-hidden="true" />
<Link to="/official" className="home-v2-popular-publishers-link oc-action oc-action-ghost">
Browse official <ArrowRight size={14} aria-hidden="true" />
</Link>
</header>
<div
@@ -21,6 +21,15 @@ describe("PublisherListItem", () => {
expect(container.querySelector(".official-badge")).toBeTruthy();
});
it("can hide the official mark when the surrounding page already communicates it", () => {
const { container } = render(
<PublisherListItem publisher={makePublisher()} showOfficialBadge={false} />,
);
expect(screen.queryByLabelText("Official")).toBeNull();
expect(container.querySelector(".official-badge")).toBeNull();
});
it("renders downloads as the adoption metric", () => {
render(<PublisherListItem publisher={makePublisher()} />);
+7 -2
View File
@@ -13,6 +13,7 @@ import { OfficialBadge } from "./OfficialBadge";
type PublisherListItemProps = {
publisher: PublicPublisherListItem;
showOfficialBadge?: boolean;
variant?: "list" | "grid" | "highlight";
};
@@ -29,7 +30,11 @@ function PublishedRail({ items }: { items: PublicPublisherPublishedItem[] }) {
);
}
export function PublisherListItem({ publisher, variant = "list" }: PublisherListItemProps) {
export function PublisherListItem({
publisher,
showOfficialBadge = true,
variant = "list",
}: PublisherListItemProps) {
const handle = publisher.handle.trim();
if (!handle) return null;
@@ -57,7 +62,7 @@ export function PublisherListItem({ publisher, variant = "list" }: PublisherList
<span className="publisher-card-identity">
<span className="publisher-card-title-row">
<span className="publisher-card-name">{publisher.displayName}</span>
{publisher.official ? <OfficialBadge /> : null}
{showOfficialBadge && publisher.official ? <OfficialBadge /> : null}
</span>
<span className="publisher-card-handle">@{handle}</span>
</span>
@@ -64,7 +64,7 @@ export function DashboardWelcome({ ownerHandle, publisherSelector }: DashboardWe
Skills
</Link>
<Link to="/plugins">Plugins</Link>
<Link to="/creators">Creators</Link>
<Link to="/official">Official</Link>
<a href={CLAWHUB_DOCS_URL} target="_blank" rel="noreferrer">
Docs
<ArrowUpRight size={12} aria-hidden="true" />
+4 -5
View File
@@ -44,7 +44,7 @@ const SKILLS_SEARCH = {
// ---------------------------------------------------------------------------
// Primary nav items (desktop tabs row + mobile dropdown top section)
// These map to the content-type tabs: Skills | Plugins | Creators
// These map to the content-type tabs: Skills | Plugins | Official
// ---------------------------------------------------------------------------
export const PRIMARY_NAV_ITEMS: NavItem[] = [
@@ -60,9 +60,8 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
activePathPrefixes: ["/plugin/"],
},
{
label: "Creators",
to: PublicRegistryPaths.creators,
activePathPrefixes: ["/publishers"],
label: "Official",
to: PublicRegistryPaths.official,
},
];
@@ -117,7 +116,7 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
items: [
{ kind: "link", label: "Skills", to: PublicRegistryPaths.skills, search: SKILLS_SEARCH },
{ kind: "link", label: "Plugins", to: PublicRegistryPaths.plugins },
{ kind: "link", label: "Creators", to: PublicRegistryPaths.creators },
{ kind: "link", label: "Official", to: PublicRegistryPaths.official },
{
kind: "link",
label: "Audits",
+4 -4
View File
@@ -8,7 +8,7 @@ export const PublicRegistryPaths = {
home: "/",
skills: "/skills",
plugins: "/plugins",
creators: "/creators",
official: "/official",
search: "/search",
audits: "/audits",
publishSkill: "/skills/publish",
@@ -27,9 +27,9 @@ export const PUBLIC_REGISTRY_SURFACES = [
summary: "Browse and search OpenClaw plugin package records.",
},
{
label: "Creators",
path: PublicRegistryPaths.creators,
summary: "Browse public user and organization creators.",
label: "Official",
path: PublicRegistryPaths.official,
summary: "Browse official organizations publishing on ClawHub.",
},
{
label: "Search",
+17 -17
View File
@@ -28,7 +28,7 @@ import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
import { Route as AuthDocsRouteImport } from './routes/auth/docs'
import { Route as CliAuthRouteImport } from './routes/cli/auth'
import { Route as CliDeviceRouteImport } from './routes/cli/device'
import { Route as CreatorsIndexRouteImport } from './routes/creators/index'
import { Route as OfficialIndexRouteImport } from './routes/official/index'
import { Route as OrgsHandleRouteImport } from './routes/orgs/$handle'
import { Route as PHandleRouteImport } from './routes/p/$handle'
import { Route as PackagesIndexRouteImport } from './routes/packages/index'
@@ -158,9 +158,9 @@ const CliDeviceRoute = CliDeviceRouteImport.update({
path: '/cli/device',
getParentRoute: () => rootRouteImport,
} as any)
const CreatorsIndexRoute = CreatorsIndexRouteImport.update({
id: '/creators/',
path: '/creators/',
const OfficialIndexRoute = OfficialIndexRouteImport.update({
id: '/official/',
path: '/official/',
getParentRoute: () => rootRouteImport,
} as any)
const OrgsHandleRoute = OrgsHandleRouteImport.update({
@@ -369,7 +369,7 @@ export interface FileRoutesByFullPath {
'/skills/publish': typeof SkillsPublishRoute
'/u/$handle': typeof UHandleRoute
'/user/$handle': typeof UserHandleRoute
'/creators/': typeof CreatorsIndexRoute
'/official/': typeof OfficialIndexRoute
'/packages/': typeof PackagesIndexRoute
'/plugins/': typeof PluginsIndexRoute
'/publishers/': typeof PublishersIndexRoute
@@ -424,7 +424,7 @@ export interface FileRoutesByTo {
'/skills/publish': typeof SkillsPublishRoute
'/u/$handle': typeof UHandleRoute
'/user/$handle': typeof UserHandleRoute
'/creators': typeof CreatorsIndexRoute
'/official': typeof OfficialIndexRoute
'/packages': typeof PackagesIndexRoute
'/plugins': typeof PluginsIndexRoute
'/publishers': typeof PublishersIndexRoute
@@ -480,7 +480,7 @@ export interface FileRoutesById {
'/skills/publish': typeof SkillsPublishRoute
'/u/$handle': typeof UHandleRoute
'/user/$handle': typeof UserHandleRoute
'/creators/': typeof CreatorsIndexRoute
'/official/': typeof OfficialIndexRoute
'/packages/': typeof PackagesIndexRoute
'/plugins/': typeof PluginsIndexRoute
'/publishers/': typeof PublishersIndexRoute
@@ -537,7 +537,7 @@ export interface FileRouteTypes {
| '/skills/publish'
| '/u/$handle'
| '/user/$handle'
| '/creators/'
| '/official/'
| '/packages/'
| '/plugins/'
| '/publishers/'
@@ -592,7 +592,7 @@ export interface FileRouteTypes {
| '/skills/publish'
| '/u/$handle'
| '/user/$handle'
| '/creators'
| '/official'
| '/packages'
| '/plugins'
| '/publishers'
@@ -647,7 +647,7 @@ export interface FileRouteTypes {
| '/skills/publish'
| '/u/$handle'
| '/user/$handle'
| '/creators/'
| '/official/'
| '/packages/'
| '/plugins/'
| '/publishers/'
@@ -703,7 +703,7 @@ export interface RootRouteChildren {
SkillsPublishRoute: typeof SkillsPublishRoute
UHandleRoute: typeof UHandleRoute
UserHandleRoute: typeof UserHandleRoute
CreatorsIndexRoute: typeof CreatorsIndexRoute
OfficialIndexRoute: typeof OfficialIndexRoute
PackagesIndexRoute: typeof PackagesIndexRoute
PluginsIndexRoute: typeof PluginsIndexRoute
PublishersIndexRoute: typeof PublishersIndexRoute
@@ -851,11 +851,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CliDeviceRouteImport
parentRoute: typeof rootRouteImport
}
'/creators/': {
id: '/creators/'
path: '/creators'
fullPath: '/creators/'
preLoaderRoute: typeof CreatorsIndexRouteImport
'/official/': {
id: '/official/'
path: '/official'
fullPath: '/official/'
preLoaderRoute: typeof OfficialIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/orgs/$handle': {
@@ -1197,7 +1197,7 @@ const rootRouteChildren: RootRouteChildren = {
SkillsPublishRoute: SkillsPublishRoute,
UHandleRoute: UHandleRoute,
UserHandleRoute: UserHandleRoute,
CreatorsIndexRoute: CreatorsIndexRoute,
OfficialIndexRoute: OfficialIndexRoute,
PackagesIndexRoute: PackagesIndexRoute,
PluginsIndexRoute: PluginsIndexRoute,
PublishersIndexRoute: PublishersIndexRoute,
@@ -1,5 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { BadgeCheck } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../../convex/_generated/api";
import {
@@ -9,8 +8,6 @@ import {
BrowseSearchInput,
BrowseSearchPanel,
BrowseSearchTrigger,
BrowseTabs,
BrowseViewToggle,
useBrowseSearchDisclosure,
} from "../../components/BrowseControls";
import { PublisherListItem } from "../../components/PublisherListItem";
@@ -19,17 +16,11 @@ import { convexHttp } from "../../convex/client";
import type { PublicPublisherListItem } from "../../lib/publicUser";
import { getClawHubSiteUrl, SITE_NAME } from "../../lib/site";
type PublisherKindSearch = "orgs" | "people";
type PublisherViewSearch = "list" | "grid";
type PublishersSearchState = {
kind?: PublisherKindSearch;
official?: boolean;
type OfficialSearchState = {
q?: string;
view?: PublisherViewSearch;
};
type PublishersLoaderResult = {
type OfficialLoaderResult = {
page: PublicPublisherListItem[];
counts: {
all: number;
@@ -46,72 +37,39 @@ type PublishersLoaderResult = {
};
const PUBLISHER_PAGE_SIZE = 25;
const PUBLISHER_KIND_OPTIONS = [
{ value: undefined, label: "All" },
{
value: "official",
label: "Official",
icon: <BadgeCheck size={14} strokeWidth={2.25} aria-hidden="true" />,
},
{ value: "orgs", label: "Organizations", mobileLabel: "Orgs" },
{ value: "people", label: "Users" },
];
function normalizePublisherKind(value: unknown): PublisherKindSearch | undefined {
if (value === "orgs") return "orgs";
if (value === "people" || value === "builders" || value === "individuals") return "people";
return undefined;
}
async function loadPublishersPage({
async function loadOfficialOrganizationsPage({
cursor,
kind,
official,
query,
}: {
cursor: string | null;
kind?: PublisherKindSearch;
official?: boolean;
query?: string;
}): Promise<PublishersLoaderResult> {
const baseArgs = {
kind: kind === "orgs" ? ("org" as const) : kind === "people" ? ("user" as const) : undefined,
}): Promise<OfficialLoaderResult> {
return (await convexHttp.query(api.publishers.listPublicPage, {
kind: "org",
official: true,
query,
paginationOpts: { cursor, numItems: PUBLISHER_PAGE_SIZE },
};
return (await convexHttp.query(api.publishers.listPublicPage, {
...baseArgs,
...(official ? { official: true } : {}),
})) as PublishersLoaderResult;
})) as OfficialLoaderResult;
}
export const Route = createFileRoute("/creators/")({
validateSearch: (search): PublishersSearchState => ({
kind: normalizePublisherKind(search.kind),
official:
search.official === true || search.official === "true" || search.official === "1"
? true
: undefined,
export const Route = createFileRoute("/official/")({
validateSearch: (search): OfficialSearchState => ({
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
view: search.view === "grid" ? "grid" : undefined,
}),
loaderDeps: ({ search }) => ({
kind: search.kind,
official: search.official,
q: search.q,
}),
head: () => {
const siteUrl = getClawHubSiteUrl();
const title = `Creators · ${SITE_NAME}`;
const description =
"Discover the people and organizations publishing skills, plugins, packages, and ecosystem tooling on ClawHub.";
const title = `Official · ${SITE_NAME}`;
const description = "The organizations behind the top skills and plugins on ClawHub.";
return {
links: [
{
rel: "canonical",
href: `${siteUrl}/creators`,
href: `${siteUrl}/official`,
},
],
meta: [
@@ -120,26 +78,24 @@ export const Route = createFileRoute("/creators/")({
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:type", content: "website" },
{ property: "og:url", content: `${siteUrl}/creators` },
{ property: "og:url", content: `${siteUrl}/official` },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
],
};
},
loader: async ({ deps }): Promise<PublishersLoaderResult> =>
await loadPublishersPage({
loader: async ({ deps }): Promise<OfficialLoaderResult> =>
await loadOfficialOrganizationsPage({
cursor: null,
kind: deps.kind,
official: deps.official,
query: deps.q,
}),
component: PublishersIndex,
component: OfficialIndex,
});
function PublishersIndex() {
function OfficialIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const result = Route.useLoaderData() as PublishersLoaderResult;
const result = Route.useLoaderData() as OfficialLoaderResult;
const [query, setQuery] = useState(search.q ?? "");
const [publishers, setPublishers] = useState(result.page);
const [nextCursor, setNextCursor] = useState<string | null>(
@@ -150,14 +106,7 @@ function PublishersIndex() {
const loadMoreInFlightRef = useRef(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchNavigateTimer = useRef<number>(0);
const activeKind = search.kind;
const officialOnly = search.official === true;
const activeView = search.view ?? "list";
const canLoadMore = Boolean(nextCursor);
const hasQuery = Boolean(search.q?.trim());
const showHighlights = !hasQuery && !activeKind && !officialOnly;
const highlightedPublishers = showHighlights ? publishers.slice(0, 3) : [];
const directoryPublishers = showHighlights ? publishers.slice(3) : publishers;
useEffect(() => {
window.clearTimeout(searchNavigateTimer.current);
@@ -176,7 +125,7 @@ function PublishersIndex() {
(next: string, replace: boolean) => {
const trimmed = next.trim();
void navigate({
search: (prev: PublishersSearchState) => ({
search: (prev: OfficialSearchState) => ({
...prev,
q: trimmed ? next : undefined,
}),
@@ -202,7 +151,7 @@ function PublishersIndex() {
setQuery("");
searchInputRef.current?.focus();
void navigate({
search: (prev: PublishersSearchState) => ({
search: (prev: OfficialSearchState) => ({
...prev,
q: undefined,
}),
@@ -220,62 +169,13 @@ function PublishersIndex() {
inputRef: searchInputRef,
});
const handleKindChange = useCallback(
(kind: string | undefined) => {
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
kind: normalizePublisherKind(kind),
official: undefined,
}),
replace: true,
});
},
[navigate],
);
const handleOfficialChange = useCallback(() => {
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
kind: undefined,
official: true,
}),
replace: true,
});
}, [navigate]);
const handlePublisherTabChange = useCallback(
(value: string | undefined) => {
if (value === "official") {
handleOfficialChange();
return;
}
handleKindChange(value);
},
[handleKindChange, handleOfficialChange],
);
const handleToggleView = useCallback(() => {
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
view: prev.view === "grid" ? undefined : "grid",
}),
replace: true,
});
}, [navigate]);
const loadMore = useCallback(async () => {
if (!nextCursor || loadMoreInFlightRef.current) return;
loadMoreInFlightRef.current = true;
setIsLoadingMore(true);
try {
const page = await loadPublishersPage({
const page = await loadOfficialOrganizationsPage({
cursor: nextCursor,
kind: activeKind,
official: officialOnly || undefined,
query: search.q,
});
setPublishers((previous) => [...previous, ...page.page]);
@@ -284,7 +184,7 @@ function PublishersIndex() {
setIsLoadingMore(false);
loadMoreInFlightRef.current = false;
}
}, [activeKind, nextCursor, officialOnly, search.q]);
}, [nextCursor, search.q]);
useEffect(() => {
if (!canLoadMore || typeof IntersectionObserver === "undefined") return () => {};
@@ -304,32 +204,31 @@ function PublishersIndex() {
}, [canLoadMore, loadMore]);
return (
<main className="browse-page browse-page-borderless-header publishers-browse-page">
<div className="browse-page-header">
<h1 className="browse-title">Creators</h1>
<main className="browse-page browse-page-borderless-header official-browse-page">
<div className="browse-page-header official-page-header">
<div className="browse-page-header-main">
<h1 className="browse-title">Official</h1>
<p className="official-page-description">
The organizations behind the top skills and plugins on ClawHub
</p>
</div>
</div>
<BrowseControls>
<BrowseControlsRow>
<BrowseTabs
ariaLabel="Publisher type"
options={PUBLISHER_KIND_OPTIONS}
value={officialOnly ? "official" : activeKind}
onChange={handlePublisherTabChange}
/>
<BrowseActions>
<BrowseSearchTrigger
open={browseSearch.open}
onOpen={browseSearch.openSearch}
label="Search publishers"
label="Search official organizations"
/>
<BrowseViewToggle view={activeView} onToggle={handleToggleView} />
</BrowseActions>
</BrowseControlsRow>
<BrowseSearchPanel open={browseSearch.open}>
<BrowseSearchInput
inputRef={searchInputRef}
label="publisher search"
placeholder="Search publishers..."
label="official organization search"
placeholder="Search official organizations..."
value={query}
onChange={handleQueryChange}
onClear={browseSearch.closeSearch}
@@ -341,42 +240,24 @@ function PublishersIndex() {
<div className="browse-layout">
<div className="browse-results">
{highlightedPublishers.length > 0 ? (
<section className="publisher-highlights" aria-labelledby="publisher-highlights-title">
<div className="publisher-section-heading">
<h2 id="publisher-highlights-title">Popular creators</h2>
</div>
<div className="publisher-highlight-grid">
{highlightedPublishers.map((publisher) => (
<PublisherListItem
key={publisher._id}
publisher={publisher}
variant="highlight"
/>
))}
</div>
</section>
) : null}
{publishers.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No publishers found</p>
</div>
) : activeView === "grid" ? (
<div className={`publisher-directory-list publisher-directory-${activeView}`}>
{directoryPublishers.map((publisher) => (
<PublisherListItem key={publisher._id} publisher={publisher} variant="grid" />
))}
<p className="empty-state-title">No official organizations found</p>
</div>
) : (
<div className="browse-list-stack">
<div className="browse-list-head browse-list-head-publishers" aria-hidden="true">
<span className="browse-list-head-label">Creator</span>
<span className="browse-list-head-label">Organization</span>
<span className="browse-list-head-label browse-list-head-stat">Activity</span>
</div>
<div className="publisher-directory-list">
{directoryPublishers.map((publisher) => (
<PublisherListItem key={publisher._id} publisher={publisher} variant="list" />
{publishers.map((publisher) => (
<PublisherListItem
key={publisher._id}
publisher={publisher}
variant="list"
showOfficialBadge={false}
/>
))}
</div>
</div>
+1 -1
View File
@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/publishers/")({
beforeLoad: ({ search }) => {
throw redirect({ to: "/creators", search, replace: true });
throw redirect({ to: "/official", search, replace: true });
},
});
+2 -2
View File
@@ -315,12 +315,12 @@ function SearchEmptyState({
query: string;
}) {
const browseHref =
activeType === "plugins" ? "/plugins" : activeType === "creators" ? "/creators" : "/skills";
activeType === "plugins" ? "/plugins" : activeType === "creators" ? "/official" : "/skills";
const browseLabel =
activeType === "plugins"
? "Show all plugins"
: activeType === "creators"
? "Show all creators"
? "Browse official organizations"
: "Show all skills";
return (
+1 -1
View File
@@ -709,7 +709,7 @@ export function PublisherProfilePage({
icon={Building2}
title="Publisher not found"
description="This publisher doesn't exist or may have been removed."
action={{ label: "Browse creators", href: "/creators" }}
action={{ label: "Browse official organizations", href: "/official" }}
/>
</Container>
</main>
+1 -1
View File
@@ -2,6 +2,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/users/")({
beforeLoad: ({ search }) => {
throw redirect({ to: "/creators", search, replace: true });
throw redirect({ to: "/official", search, replace: true });
},
});
+15
View File
@@ -18307,6 +18307,17 @@ body:has(.browse-page-borderless-header) .navbar {
min-width: 0;
}
.official-page-header {
margin-bottom: 28px;
}
.official-page-description {
max-width: 760px;
color: var(--ink-muted);
font-size: 18px;
line-height: 1.5;
}
.browse-page-search {
display: flex;
align-items: center;
@@ -19367,6 +19378,10 @@ body:has(.browse-page-borderless-header) .navbar {
.browse-title {
font-size: 32px;
}
.official-page-description {
font-size: 16px;
}
}
.browse-sidebar-toggle {