feat: add owner-qualified catalog routes

Route publisher profiles to /<owner>, skill pages to /<owner>/skills/<slug>, and plugins to /<owner>/plugins/<slug> while preserving legacy redirects.

Update API/webhook/card URLs, ClawHub CLI fixtures, docs, share copy, and route tests for the new canonical paths.

Autoreview findings addressed:

- [P2] Legacy scoped audit URLs redirect to the plugin detail page

  Preserve legacy scoped plugin security-audit and scanner URLs when the parent scoped route handles the redirect first.
This commit is contained in:
Patrick Erichsen
2026-06-22 21:27:55 -07:00
parent d4205c8a7e
commit a1328b8678
77 changed files with 1193 additions and 438 deletions
+4 -4
View File
@@ -81,13 +81,13 @@ describe("ambiguous skill slug responses", () => {
ownerHandle: "openclaw",
slug: "demo",
ref: "@openclaw/demo",
url: "https://example.com/openclaw/demo",
url: "https://example.com/openclaw/skills/demo",
},
{
ownerHandle: "patrick",
slug: "demo",
ref: "@patrick/demo",
url: "https://example.com/patrick/demo",
url: "https://example.com/patrick/skills/demo",
},
],
});
@@ -120,13 +120,13 @@ describe("ambiguous skill slug responses", () => {
ownerHandle: "openclaw",
slug: "demo",
ref: "@openclaw/demo",
url: "https://example.com/openclaw/demo",
url: "https://example.com/openclaw/skills/demo",
},
{
ownerHandle: "patrick",
slug: "demo",
ref: "@patrick/demo",
url: "https://example.com/patrick/demo",
url: "https://example.com/patrick/skills/demo",
},
],
});
+10 -10
View File
@@ -2328,13 +2328,13 @@ describe("httpApiV1 handlers", () => {
ownerHandle: "openclaw",
slug: "demo",
ref: "@openclaw/demo",
url: "https://example.com/openclaw/demo",
url: "https://example.com/openclaw/skills/demo",
},
{
ownerHandle: "patrick",
slug: "demo",
ref: "@patrick/demo",
url: "https://example.com/patrick/demo",
url: "https://example.com/patrick/skills/demo",
},
],
});
@@ -2359,8 +2359,8 @@ describe("httpApiV1 handlers", () => {
expect(response.status).toBe(409);
const body = await response.json();
expect(body.matches).toEqual([
expect.objectContaining({ url: "https://clawhub.ai/openclaw/demo" }),
expect.objectContaining({ url: "https://clawhub.ai/patrick/demo" }),
expect.objectContaining({ url: "https://clawhub.ai/openclaw/skills/demo" }),
expect.objectContaining({ url: "https://clawhub.ai/patrick/skills/demo" }),
]);
});
@@ -5284,8 +5284,8 @@ describe("httpApiV1 handlers", () => {
version: "1.0.0",
createdAt: 1,
checkedAt: 3,
skillUrl: "https://example.com/acme/demo",
securityAuditUrl: "https://example.com/acme/demo/security-audit?version=1.0.0",
skillUrl: "https://example.com/acme/skills/demo",
securityAuditUrl: "https://example.com/acme/skills/demo/security-audit?version=1.0.0",
security: {
status: "clean",
passed: true,
@@ -5349,8 +5349,8 @@ describe("httpApiV1 handlers", () => {
expect(response.status).toBe(200);
const json = await response.json();
expect(json.items[0]).toMatchObject({
skillUrl: "https://clawhub.ai/acme/demo",
securityAuditUrl: "https://clawhub.ai/acme/demo/security-audit?version=1.0.0",
skillUrl: "https://clawhub.ai/acme/skills/demo",
securityAuditUrl: "https://clawhub.ai/acme/skills/demo/security-audit?version=1.0.0",
});
});
@@ -5757,10 +5757,10 @@ describe("httpApiV1 handlers", () => {
reasons: [],
slug: "demo",
displayName: "Demo",
pageUrl: "https://clawhub.ai/acme/demo",
pageUrl: "https://clawhub.ai/acme/skills/demo",
publisherHandle: "acme",
publisherDisplayName: "Acme",
publisherProfileUrl: "https://clawhub.ai/user/acme",
publisherProfileUrl: "https://clawhub.ai/acme",
version: "1.0.0",
resolvedFrom: "tag",
tag: "stable",
+1 -1
View File
@@ -3227,7 +3227,7 @@ function ambiguousSkillChoicesForPackageRequest(
slug,
ref: `@${ownerHandle}/${slug}`,
url: new URL(
`/${encodeURIComponent(ownerHandle)}/${encodeURIComponent(slug)}`,
`/${encodeURIComponent(ownerHandle)}/skills/${encodeURIComponent(slug)}`,
request.url,
).toString(),
},
+5 -5
View File
@@ -1013,7 +1013,7 @@ function buildSkillPageUrl(request: Request, owner: SkillUrlOwner, slug: string)
return new URL(`/api/v1/skills/${encodeURIComponent(slug)}`, origin).toString();
}
return new URL(
`/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}`,
`/${encodeURIComponent(ownerSegment)}/skills/${encodeURIComponent(slug)}`,
origin,
).toString();
}
@@ -1028,7 +1028,7 @@ function buildSecurityAuditUrl(
if (!ownerSegment) return null;
const url = new URL(
`/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}/security-audit`,
`/${encodeURIComponent(ownerSegment)}/skills/${encodeURIComponent(slug)}/security-audit`,
publicApiOrigin(request),
);
url.searchParams.set("version", version);
@@ -1632,7 +1632,7 @@ function ambiguousSkillChoicesForRequest(
slug,
ref: `@${ownerHandle}/${slug}`,
url: new URL(
`/${encodeURIComponent(ownerHandle)}/${encodeURIComponent(slug)}`,
`/${encodeURIComponent(ownerHandle)}/skills/${encodeURIComponent(slug)}`,
origin,
).toString(),
},
@@ -2327,12 +2327,12 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
slug: skillResult.skill.slug,
displayName: skillResult.skill.displayName,
pageUrl: publisherOwnerHandle
? `https://clawhub.ai/${publisherOwnerHandle}/${skillResult.skill.slug}`
? `https://clawhub.ai/${publisherOwnerHandle}/skills/${skillResult.skill.slug}`
: `https://clawhub.ai/api/v1/skills/${skillResult.skill.slug}`,
publisherHandle: publisherOwnerHandle,
publisherDisplayName: ownerDisplayName,
publisherProfileUrl: publisherOwnerHandle
? `https://clawhub.ai/user/${publisherOwnerHandle}`
? `https://clawhub.ai/${publisherOwnerHandle}`
: null,
version: version.version,
resolvedFrom,
+1 -1
View File
@@ -72,7 +72,7 @@ describe("payload building", () => {
{ slug: "beeper", displayName: "Beeper", ownerHandle: "KrauseFx" },
"https://clawhub.ai",
);
expect(url).toBe("https://clawhub.ai/KrauseFx/beeper");
expect(url).toBe("https://clawhub.ai/KrauseFx/skills/beeper");
});
it("builds a publish embed", () => {
+1 -1
View File
@@ -82,7 +82,7 @@ export function buildDiscordPayload(
export function buildSkillUrl(skill: WebhookSkillPayload, siteUrl: string) {
const owner = skill.ownerHandle?.trim();
if (owner) return `${siteUrl}/${owner}/${skill.slug}`;
if (owner) return `${siteUrl}/${owner}/skills/${skill.slug}`;
return `${siteUrl}/skills/${skill.slug}`;
}
+1 -1
View File
@@ -288,7 +288,7 @@ function buildEvidencePacket(
summary: skill.summary ?? null,
badges: skill.badges ?? null,
pageUrl: publisherHandle
? `https://clawhub.ai/${publisherHandle}/${skill.slug}`
? `https://clawhub.ai/${publisherHandle}/skills/${skill.slug}`
: `https://clawhub.ai/api/v1/skills/${skill.slug}`,
},
release: {
+1 -1
View File
@@ -20,7 +20,7 @@ Guidelines:
- Use public read endpoints such as `GET /api/v1/skills`, `GET /api/v1/search`, and `GET /api/v1/skills/{slug}` for catalog listings.
- Cache responses and respect `429`, `Retry-After`, and rate-limit headers instead of polling aggressively.
- Link back to the canonical ClawHub skill URL when displaying listings so users can inspect the source registry record.
- Use canonical page URLs in the form `https://clawhub.ai/<owner>/<slug>`.
- Use canonical page URLs in the form `https://clawhub.ai/<owner>/skills/<slug>`.
- Do not imply that ClawHub endorses, verifies, or operates the third-party site.
- Do not mirror hidden, private, or moderation-blocked content by bypassing public API filters or auth boundaries.
+1 -1
View File
@@ -14,7 +14,7 @@ rights, submit a [ClawHub Content Rights Request](https://forms.openclaw.ai/claw
Include:
- one or more exact `https://clawhub.ai/<owner>/<skill>` URLs
- one or more exact `https://clawhub.ai/<owner>/skills/<skill>` URLs
- your name, organization, and contact email
- a brief explanation of the rights concern
- supporting evidence, if available
+3 -3
View File
@@ -15,7 +15,7 @@ OpenAPI: `/api/v1/openapi.json`.
## Public catalog reuse
Third-party directories may use the public read endpoints to list or search ClawHub skills. Please cache results, honor `429`/`Retry-After`, link users back to the canonical ClawHub listing (`https://clawhub.ai/<owner>/<slug>`), and avoid implying ClawHub endorsement of the third-party site. Do not attempt to mirror hidden, private, or moderation-blocked content outside the public API surface.
Third-party directories may use the public read endpoints to list or search ClawHub skills. Please cache results, honor `429`/`Retry-After`, link users back to the canonical ClawHub listing (`https://clawhub.ai/<owner>/skills/<slug>`), and avoid implying ClawHub endorsement of the third-party site. Do not attempt to mirror hidden, private, or moderation-blocked content outside the public API surface.
Web slug shortcuts resolve across registry families, but API clients should use
the canonical URLs returned by read endpoints instead of reconstructing route
@@ -485,8 +485,8 @@ Response:
"version": "1.2.3",
"createdAt": 0,
"checkedAt": 0,
"skillUrl": "https://clawhub.ai/steipete/gifgrep",
"securityAuditUrl": "https://clawhub.ai/steipete/gifgrep/security-audit?version=1.2.3",
"skillUrl": "https://clawhub.ai/steipete/skills/gifgrep",
"securityAuditUrl": "https://clawhub.ai/steipete/skills/gifgrep/security-audit?version=1.2.3",
"security": {
"status": "clean",
"passed": true,
+1 -1
View File
@@ -105,7 +105,7 @@ power is expected, disclosed, and proportionate.
Artifact pages link to the full audit at:
```text
/<owner>/<slug>/security-audit
/<owner>/skills/<slug>/security-audit
```
The audit page combines:
@@ -330,10 +330,10 @@ describe("cmdVerifySkill", () => {
reasons: [],
slug: "demo",
displayName: "Demo",
pageUrl: "https://clawhub.ai/acme/demo",
pageUrl: "https://clawhub.ai/acme/skills/demo",
publisherHandle: "acme",
publisherDisplayName: "Acme",
publisherProfileUrl: "https://clawhub.ai/user/acme",
publisherProfileUrl: "https://clawhub.ai/acme",
version: "1.2.3",
resolvedFrom: "tag",
tag: "stable",
@@ -389,10 +389,10 @@ describe("cmdVerifySkill", () => {
reasons: ["card.missing", "security.status_not_clean"],
slug: "demo",
displayName: "Demo",
pageUrl: "https://clawhub.ai/acme/demo",
pageUrl: "https://clawhub.ai/acme/skills/demo",
publisherHandle: "acme",
publisherDisplayName: "Acme",
publisherProfileUrl: "https://clawhub.ai/user/acme",
publisherProfileUrl: "https://clawhub.ai/acme",
version: "1.2.3",
resolvedFrom: "latest",
tag: null,
@@ -419,10 +419,10 @@ describe("cmdVerifySkill", () => {
reasons: [],
slug: "demo",
displayName: "Demo",
pageUrl: "https://clawhub.ai/acme/demo",
pageUrl: "https://clawhub.ai/acme/skills/demo",
publisherHandle: "acme",
publisherDisplayName: "Acme",
publisherProfileUrl: "https://clawhub.ai/user/acme",
publisherProfileUrl: "https://clawhub.ai/acme",
version: "1.2.3",
resolvedFrom: "latest",
tag: null,
+4 -4
View File
@@ -204,13 +204,13 @@ describe("node http client", () => {
ownerHandle: "openclaw",
slug: "discrawl",
ref: "@openclaw/discrawl",
url: "https://clawhub.ai/openclaw/discrawl",
url: "https://clawhub.ai/openclaw/skills/discrawl",
},
{
ownerHandle: "patrick",
slug: "discrawl",
ref: "@patrick/discrawl",
url: "https://clawhub.ai/patrick/discrawl",
url: "https://clawhub.ai/patrick/skills/discrawl",
},
],
}),
@@ -225,12 +225,12 @@ describe("node http client", () => {
"",
" 1.",
" Skill: openclaw/discrawl",
" Page: https://clawhub.ai/openclaw/discrawl",
" Page: https://clawhub.ai/openclaw/skills/discrawl",
" Run: clawhub install @openclaw/discrawl",
"",
" 2.",
" Skill: patrick/discrawl",
" Page: https://clawhub.ai/patrick/discrawl",
" Page: https://clawhub.ai/patrick/skills/discrawl",
" Run: clawhub install @patrick/discrawl",
].join("\n"),
);
+2 -2
View File
@@ -68,10 +68,10 @@ describe("packages/clawhub skill metadata schema", () => {
reasons: [],
slug: "demo",
displayName: "Demo",
pageUrl: "https://clawhub.ai/openclaw/demo",
pageUrl: "https://clawhub.ai/openclaw/skills/demo",
publisherHandle: "openclaw",
publisherDisplayName: "OpenClaw",
publisherProfileUrl: "https://clawhub.ai/user/openclaw",
publisherProfileUrl: "https://clawhub.ai/openclaw",
version: "1.0.0",
resolvedFrom: "latest",
tag: null,
+2 -2
View File
@@ -355,10 +355,10 @@ describe("clawhub-schema", () => {
reasons: [],
slug: "demo",
displayName: "Demo",
pageUrl: "https://clawhub.ai/openclaw/demo",
pageUrl: "https://clawhub.ai/openclaw/skills/demo",
publisherHandle: "openclaw",
publisherDisplayName: "OpenClaw",
publisherProfileUrl: "https://clawhub.ai/user/openclaw",
publisherProfileUrl: "https://clawhub.ai/openclaw",
version: "1.0.0",
resolvedFrom: "latest",
tag: null,
+46 -37
View File
@@ -20,34 +20,41 @@ request is a skill slug, an official OpenClaw plugin alias, or a package route.
## Canonical URLs
Publisher profiles:
- Canonical page: `/<handle>`
- Legacy compatibility pages: `/user/<handle>`, `/p/<handle>`, `/u/<handle>`,
and `/orgs/<handle>` redirect to `/<handle>`
Skills:
- Canonical page: `/<owner>/<slug>`
- Security audit page: `/<owner>/<slug>/security-audit`
- Legacy scanner pages: `/<owner>/<slug>/security/:scanner` redirect to
`/<owner>/<slug>/security-audit`
- Canonical page: `/<owner>/skills/<slug>`
- Legacy compatibility page: `/<owner>/<slug>` redirects to
`/<owner>/skills/<slug>`
- Security audit page: `/<owner>/skills/<slug>/security-audit`
- Legacy security audit page: `/<owner>/<slug>/security-audit` redirects to
`/<owner>/skills/<slug>/security-audit`
- Legacy scanner pages: `/<owner>/<slug>/security/:scanner` and
`/<owner>/skills/<slug>/security/:scanner` redirect to
`/<owner>/skills/<slug>/security-audit`
- API detail: `/api/v1/skills/<slug>`
Plugins:
- Canonical page: `/plugins/@scope/name`
- Canonical page: `/<publisher>/plugins/<slug>`
- Legacy readable scoped page: `/plugins/@scope/name`
- Encoded compatibility page: `/plugins/%40scope%2Fname`
- Security audit page: `/plugins/@scope/name/security-audit`
- Security audit page: `/<publisher>/plugins/<slug>/security-audit`
- Legacy readable scoped security page: `/plugins/@scope/name/security-audit`
- Encoded security compatibility page: `/plugins/%40scope%2Fname/security-audit`
- Legacy scanner pages redirect to the corresponding plugin security audit page.
Publisher profiles:
- Canonical page: `/user/<handle>`
- Legacy compatibility pages: `/p/<handle>`, `/u/<handle>`, and
`/orgs/<handle>` redirect to `/user/<handle>`
Bare `/<handle>` routes are not profile routes. They remain reserved for static
routes, official OpenClaw extension aliases, and skill slug resolution.
Bare `/<handle>` routes are profile routes after static routes and official
OpenClaw plugin aliases have won precedence.
Encoded compatibility routes are npm-style package-name routes. They redirect
with `308` to the readable scoped route so the address bar shows
`/plugins/@openclaw/codex`, not `/plugins/%40openclaw%2Fcodex`.
`/openclaw/plugins/codex`, not `/plugins/%40openclaw%2Fcodex`.
## Official OpenClaw aliases
@@ -73,9 +80,9 @@ For every official alias, these URLs redirect to the canonical plugin page:
Example:
```text
/codex -> /plugins/@openclaw/codex
/openclaw/codex -> /plugins/@openclaw/codex
/@openclaw/codex -> /plugins/@openclaw/codex
/codex -> /openclaw/plugins/codex
/openclaw/codex -> /openclaw/plugins/codex
/@openclaw/codex -> /openclaw/plugins/codex
```
## Route precedence
@@ -86,15 +93,18 @@ The effective precedence is:
`/api/...`.
2. A top-level path matching an official OpenClaw extension alias redirects to
that plugin package.
3. Any other top-level path may resolve through the skill registry and redirect
to `/<owner>/<slug>`.
4. `/openclaw/<alias>` and `/@openclaw/<alias>` only resolve official OpenClaw
3. Any other top-level path may resolve as a publisher profile.
4. Unknown top-level paths may still resolve through the historical skill slug
fallback and redirect to `/<owner>/skills/<slug>`.
5. `/openclaw/<alias>` and `/@openclaw/<alias>` only resolve official OpenClaw
plugin aliases.
5. Other `/:owner/:slug` paths resolve as skills.
6. `/:owner/:slug` with an unsupported `@scope` owner returns not found instead
6. Other `/:owner/:slug` paths are legacy skill routes and redirect to
`/:owner/skills/:slug`.
7. `/:owner/:slug` with an unsupported `@scope` owner returns not found instead
of accidentally resolving a skill by slug.
7. `/plugins/@scope/name` is the readable scoped plugin package route.
8. `/plugins/<name>` probes package candidates in this order: official OpenClaw
8. `/:owner/plugins/:slug` is the canonical publisher plugin route.
9. `/plugins/@scope/name` and `/plugins/<name>` remain compatibility routes.
`/plugins/<name>` probes package candidates in this order: official OpenClaw
alias package, `@openclaw/<name>`, then the unscoped package name.
This means official OpenClaw aliases are reserved before skills at the root.
@@ -105,15 +115,13 @@ Codex plugin even if a skill named `codex` exists.
Do not make every `/:owner/:slug` path a universal package route. Owners can
have skills and plugins, and skill slugs are already unique in the skill
registry. Package names have separate npm-like semantics. The only owner-style
plugin redirects currently reserved are for the official OpenClaw owner:
- `/openclaw/<alias>`
- `/@openclaw/<alias>`
registry. Package names have separate npm-like semantics. Publisher plugin
routes must include the `plugins` segment.
Unknown top-level slugs still fall back to skill resolution. Unknown
`@scope/name` owner routes return not found unless a dedicated package route
handles them under `/plugins/...`.
handles them under `/:owner/plugins/...` or the legacy `/plugins/...`
compatibility routes.
Skill write paths must reject platform and trust-signal namespace squatting.
Exact route/brand/role words are reserved, and slugs that start or end with
@@ -152,10 +160,11 @@ When OpenClaw ships a new extension:
The route tests should cover:
- `/<alias>` redirects to `/plugins/@openclaw/<package>`.
- `/openclaw/<alias>` redirects to `/plugins/@openclaw/<package>`.
- `/@openclaw/<alias>` redirects to `/plugins/@openclaw/<package>`.
- `/<alias>` redirects to `/openclaw/plugins/<package>`.
- `/openclaw/<alias>` redirects to `/openclaw/plugins/<package>`.
- `/@openclaw/<alias>` redirects to `/openclaw/plugins/<package>`.
- `/plugins/%40openclaw%2F<package>` redirects to
`/plugins/@openclaw/<package>`.
- `/plugins/@openclaw/<package>` renders the plugin page.
- Security routes keep the same readable scoped URL behavior.
`/openclaw/plugins/<package>`.
- `/plugins/@openclaw/<package>` redirects to `/openclaw/plugins/<package>`.
- `/openclaw/plugins/<package>` renders the plugin page.
- Security routes keep the same canonical publisher plugin URL behavior.
+6 -3
View File
@@ -71,10 +71,13 @@ vi.mock("@tanstack/react-router", () => ({
children: ReactNode;
className?: string;
hash?: string;
params?: { handle?: string };
params?: { handle?: string; slug?: string };
to?: string;
}) => {
const to = props.to?.replace("$handle", props.params?.handle ?? "$handle") ?? "/";
const to =
props.to
?.replace("$handle", props.params?.handle ?? "$handle")
.replace("$slug", props.params?.slug ?? "$slug") ?? "/";
return (
<a href={`${to}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
{props.children}
@@ -608,7 +611,7 @@ describe("Header", () => {
const profile = screen.getByText("Profile");
const dashboard = screen.getAllByText("Dashboard").at(-1)!;
expect(profile.closest("a")?.getAttribute("href")).toBe("/user/patrick-profile");
expect(profile.closest("a")?.getAttribute("href")).toBe("/patrick-profile");
expect(profile.compareDocumentPosition(dashboard) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
@@ -23,14 +23,16 @@ vi.mock("@tanstack/react-router", () => ({
}: {
children: React.ReactNode;
className?: string;
params?: { handle?: string };
params?: { handle?: string; slug?: string };
to?: string;
[key: string]: unknown;
}) => (
<a
{...props}
className={className}
href={params?.handle ? `/user/${params.handle}` : "/publishers"}
href={
params?.slug ? `/${params.slug}` : params?.handle ? `/user/${params.handle}` : "/publishers"
}
>
{children}
</a>
@@ -53,7 +55,7 @@ describe("HomePopularPublishersSection", () => {
render(<HomePopularPublishersSection />);
const card = screen.getByRole("link", { name: "OpenClaw, @openclaw" });
expect(card.getAttribute("href")).toBe("/user/openclaw");
expect(card.getAttribute("href")).toBe("/openclaw");
const viewport = document.querySelector(".home-v2-popular-publishers-viewport");
expect(viewport).toBeTruthy();
Object.assign(viewport!, { setPointerCapture, hasPointerCapture, releasePointerCapture });
+9 -8
View File
@@ -78,6 +78,7 @@ vi.mock("@tanstack/react-router", () => ({
select?: (state: { location: { pathname: string } }) => string;
}) => (select ? select({ location: { pathname: pathnameMock } }) : pathnameMock),
useRouter: () => ({ invalidate: routerInvalidateMock }),
redirect: (options: unknown) => ({ redirect: options }),
Outlet: () => <div data-testid="nested-plugin-route" />,
Link: ({
children,
@@ -909,7 +910,7 @@ describe("plugin detail route", () => {
const { container } = render(<Component />);
expect(
container.querySelector('nav[aria-label="Plugin breadcrumbs"] a[href="/user/openclaw"]'),
container.querySelector('nav[aria-label="Plugin breadcrumbs"] a[href="/openclaw"]'),
).toBeTruthy();
});
@@ -931,7 +932,7 @@ describe("plugin detail route", () => {
const { container } = render(<Component />);
const packageCrumb = container.querySelector(
'nav[aria-label="Plugin breadcrumbs"] a[href="/plugins/@openclaw/firecrawl-plugin"]',
'nav[aria-label="Plugin breadcrumbs"] a[href="/openclaw/plugins/firecrawl-plugin"]',
);
expect(packageCrumb?.textContent).toBe("firecrawl-plugin");
});
@@ -1978,16 +1979,15 @@ describe("plugin detail route", () => {
fetchPackageReadmeMock.mockResolvedValueOnce("README");
fetchPackageVersionMock.mockResolvedValueOnce({ package: null, version: null });
const result = await loader({ params: { name: "matrix" } });
await expect(loader({ params: { name: "matrix" } })).rejects.toEqual({
redirect: { href: "/openclaw/plugins/matrix", replace: true },
});
expect(fetchPackageDetailMock).toHaveBeenCalledTimes(1);
expect(fetchPackageDetailMock).toHaveBeenCalledWith("@openclaw/matrix");
expect(fetchPackageReadmeMock).toHaveBeenCalledWith("@openclaw/matrix");
expect(fetchPackageVersionMock).toHaveBeenCalledWith("@openclaw/matrix", "2026.3.22");
expect(fetchPackageVersions).toHaveBeenCalledWith("@openclaw/matrix", { limit: 20 });
expect(result.versions).toEqual(emptyVersions);
expect(result.detail.package?.name).toBe("@openclaw/matrix");
expect(result.rateLimited).toBeNull();
});
it("uses extension npm config for short plugin route candidates", async () => {
@@ -2021,7 +2021,9 @@ describe("plugin detail route", () => {
fetchPackageReadmeMock.mockResolvedValueOnce("README");
fetchPackageVersionMock.mockResolvedValueOnce({ package: null, version: null });
const result = await loader({ params: { name: "anthropic" } });
await expect(loader({ params: { name: "anthropic" } })).rejects.toEqual({
redirect: { href: "/openclaw/plugins/anthropic-provider", replace: true },
});
expect(fetchPackageDetailMock).toHaveBeenCalledTimes(1);
expect(fetchPackageDetailMock).toHaveBeenCalledWith("@openclaw/anthropic-provider");
@@ -2030,6 +2032,5 @@ describe("plugin detail route", () => {
"@openclaw/anthropic-provider",
"2026.3.22",
);
expect(result.detail.package?.name).toBe("@openclaw/anthropic-provider");
});
});
@@ -23,7 +23,7 @@ describe("scoped plugin route redirects", () => {
redirectMock.mockClear();
});
it("accepts scoped plugin detail paths", async () => {
it("redirects scoped plugin detail paths to publisher-centric plugin paths", async () => {
const route = await loadRoute("../routes/plugins/$scope/$name");
expect(() =>
@@ -31,8 +31,11 @@ describe("scoped plugin route redirects", () => {
location: { pathname: "/plugins/@clawkit/clawkit-creative-studio" },
params: { scope: "@clawkit", name: "clawkit-creative-studio" },
}),
).not.toThrow();
expect(redirectMock).not.toHaveBeenCalled();
).toThrow();
expect(redirectMock).toHaveBeenCalledWith({
href: "/clawkit/plugins/clawkit-creative-studio",
statusCode: 308,
});
});
it("redirects old scoped plugin security scanner paths to the combined audit", async () => {
@@ -45,12 +48,12 @@ describe("scoped plugin route redirects", () => {
}),
).toThrow();
expect(redirectMock).toHaveBeenCalledWith({
href: "/plugins/@clawkit/clawkit-creative-studio/security-audit",
href: "/clawkit/plugins/clawkit-creative-studio/security-audit",
statusCode: 308,
});
});
it("accepts nested security audit paths through the scoped plugin parent", async () => {
it("preserves nested security audit paths through the scoped plugin parent", async () => {
const route = await loadRoute("../routes/plugins/$scope/$name");
expect(() =>
@@ -58,8 +61,11 @@ describe("scoped plugin route redirects", () => {
location: { pathname: "/plugins/@clawkit/clawkit-creative-studio/security-audit" },
params: { scope: "@clawkit", name: "clawkit-creative-studio" },
}),
).not.toThrow();
expect(redirectMock).not.toHaveBeenCalled();
).toThrow();
expect(redirectMock).toHaveBeenCalledWith({
href: "/clawkit/plugins/clawkit-creative-studio/security-audit",
statusCode: 308,
});
});
it("canonicalizes raw scoped legacy package paths", async () => {
+5 -5
View File
@@ -1040,7 +1040,7 @@ describe("SkillDetailPage", () => {
expect(screen.queryByText(/After install, inspect the skill metadata/i)).toBeNull();
expect(screen.getAllByText("Security audit").length).toBeGreaterThan(0);
expect(screen.getByRole("link", { name: "View Security Audit" }).getAttribute("href")).toBe(
"/steipete/weather/security-audit",
"/steipete/skills/weather/security-audit",
);
const sidebarLabels = Array.from(
sidebarMetadata?.querySelectorAll(".sidebar-metadata-label") ?? [],
@@ -1366,7 +1366,7 @@ describe("SkillDetailPage", () => {
expect(navigateMock).toHaveBeenCalled();
});
expect(navigateMock).toHaveBeenCalledWith({
to: "/$owner/$slug",
to: "/$owner/skills/$slug",
params: { owner: "steipete", slug: "weather" },
replace: true,
});
@@ -1423,7 +1423,7 @@ describe("SkillDetailPage", () => {
expect(navigateMock).toHaveBeenCalled();
});
expect(navigateMock).toHaveBeenCalledWith({
to: "/$owner/$slug",
to: "/$owner/skills/$slug",
params: { owner: "steipete", slug: "weather" },
replace: true,
});
@@ -1525,7 +1525,7 @@ describe("SkillDetailPage", () => {
expect(screen.queryByText(/Loading skill/i)).toBeNull();
expect(screen.getAllByText("Weather").length).toBeGreaterThan(0);
expect(screen.getByRole("link", { name: /settings/i }).getAttribute("href")).toBe(
"/SteiPete/weather/settings",
"/SteiPete/skills/weather/settings",
);
expect(navigateMock).not.toHaveBeenCalled();
});
@@ -1778,7 +1778,7 @@ describe("SkillDetailPage", () => {
const { unmount } = render(<SkillDetailPage slug="weather" />);
const settingsLink = await screen.findByRole("link", { name: /settings/i });
expect(settingsLink.getAttribute("href")).toBe("/steipete/weather/settings");
expect(settingsLink.getAttribute("href")).toBe("/steipete/skills/weather/settings");
expect(screen.queryByText(/Owner tools/i)).toBeNull();
unmount();
+48 -55
View File
@@ -36,7 +36,7 @@ vi.mock("../lib/slugRoute", () => ({
}));
async function loadRoute() {
return (await import("../routes/$owner/$slug")).Route as unknown as {
return (await import("../routes/$owner/skills/$slug")).Route as unknown as {
__config: {
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
@@ -53,6 +53,15 @@ async function loadRoute() {
};
}
async function loadLegacyRoute() {
return (await import("../routes/$owner/$slug")).Route as unknown as {
__config: {
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
};
};
}
async function runBeforeLoad(params: { owner: string; slug: string }) {
const route = await loadRoute();
const beforeLoad = route.__config.beforeLoad as
@@ -74,6 +83,19 @@ async function runLoader(params: { owner: string; slug: string }) {
}
}
async function runLegacyLoader(params: { owner: string; slug: string }) {
const route = await loadLegacyRoute();
const loader = route.__config.loader as (args: {
params: { owner: string; slug: string };
}) => Promise<unknown>;
try {
return await loader({ params });
} catch (error) {
return error;
}
}
function runHead(
params: { owner: string; slug: string },
loaderData?: {
@@ -104,12 +126,16 @@ describe("skill route loader", () => {
expect(() => runBeforeLoad({ owner: "publishers:abc123", slug: "weather" })).not.toThrow();
});
it("allows npm-style scopes in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "@openclaw", slug: "codex" })).not.toThrow();
it("rejects npm-style scopes on canonical skill routes", async () => {
await expect(runBeforeLoad({ owner: "@openclaw", slug: "codex" })).rejects.toEqual({
notFound: true,
});
});
it("allows npm-style scopes with dotted owners in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "@example.tools", slug: "demo-plugin" })).not.toThrow();
it("rejects npm-style dotted scopes on canonical skill routes", async () => {
await expect(runBeforeLoad({ owner: "@example.tools", slug: "demo-plugin" })).rejects.toEqual({
notFound: true,
});
});
beforeEach(() => {
@@ -121,12 +147,12 @@ describe("skill route loader", () => {
resolveOpenClawPluginSlugMock.mockResolvedValue({
kind: "plugin",
name: "@openclaw/codex",
href: "/plugins/@openclaw/codex",
href: "/openclaw/plugins/codex",
});
expect(await runLoader({ owner: "openclaw", slug: "codex" })).toEqual({
expect(await runLegacyLoader({ owner: "openclaw", slug: "codex" })).toEqual({
redirect: {
href: "/plugins/@openclaw/codex",
href: "/openclaw/plugins/codex",
replace: true,
},
});
@@ -138,12 +164,12 @@ describe("skill route loader", () => {
resolveOpenClawPluginSlugMock.mockResolvedValue({
kind: "plugin",
name: "@openclaw/codex",
href: "/plugins/@openclaw/codex",
href: "/openclaw/plugins/codex",
});
expect(await runLoader({ owner: "@openclaw", slug: "codex" })).toEqual({
expect(await runLegacyLoader({ owner: "@openclaw", slug: "codex" })).toEqual({
redirect: {
href: "/plugins/@openclaw/codex",
href: "/openclaw/plugins/codex",
replace: true,
},
});
@@ -154,55 +180,22 @@ describe("skill route loader", () => {
it("does not resolve unsupported npm-style scopes as skill slugs", async () => {
resolveOpenClawPluginSlugMock.mockResolvedValue(null);
expect(await runLoader({ owner: "@someone", slug: "weather" })).toEqual({ notFound: true });
expect(await runLegacyLoader({ owner: "@someone", slug: "weather" })).toEqual({
notFound: true,
});
expect(fetchSkillPageDataMock).not.toHaveBeenCalled();
});
it("redirects to the canonical owner and slug from loader data", async () => {
it("redirects legacy owner/slug paths to publisher-centric skill paths", async () => {
resolveOpenClawPluginSlugMock.mockResolvedValue(null);
fetchSkillPageDataMock.mockResolvedValue({
owner: "steipete",
displayName: "Weather",
summary: "Get current weather.",
version: "1.0.0",
initialData: {
result: {
resolvedSlug: "weather-pro",
skill: {
_id: "skills:1",
slug: "weather-pro",
displayName: "Weather",
summary: "Get current weather.",
ownerUserId: "users:1",
tags: {},
badges: {},
stats: {},
createdAt: 0,
updatedAt: 0,
_creationTime: 0,
},
latestVersion: null,
owner: {
_id: "users:1",
_creationTime: 0,
handle: "steipete",
name: "Peter",
},
forkOf: null,
canonical: null,
},
readme: "# Weather",
readmeError: null,
},
});
expect(await runLoader({ owner: "legacy-owner", slug: "weather" })).toEqual({
expect(await runLegacyLoader({ owner: "legacy-owner", slug: "weather" })).toEqual({
redirect: {
to: "/$owner/$slug",
params: { owner: "steipete", slug: "weather-pro" },
href: "/legacy-owner/skills/weather",
replace: true,
},
});
expect(fetchSkillPageDataMock).not.toHaveBeenCalled();
});
it("returns initial page data when the route is already canonical", async () => {
@@ -332,7 +325,7 @@ describe("skill route loader", () => {
links: [
{
rel: "canonical",
href: "https://clawhub.ai/steipete/weather",
href: "https://clawhub.ai/steipete/skills/weather",
},
],
}),
@@ -341,7 +334,7 @@ describe("skill route loader", () => {
expect.arrayContaining([
{ title: "Weather — ClawHub" },
{ name: "description", content: "Get current weather." },
{ property: "og:url", content: "https://clawhub.ai/steipete/weather" },
{ property: "og:url", content: "https://clawhub.ai/steipete/skills/weather" },
{
property: "og:image",
content: "https://clawhub.ai/og/skill?v=8&slug=weather&owner=steipete&version=1.0.0",
@@ -359,12 +352,12 @@ describe("skill route loader", () => {
links: [
{
rel: "canonical",
href: "https://clawhub.ai/steipete/weather",
href: "https://clawhub.ai/steipete/skills/weather",
},
],
meta: expect.arrayContaining([
{ title: "weather — ClawHub" },
{ property: "og:url", content: "https://clawhub.ai/steipete/weather" },
{ property: "og:url", content: "https://clawhub.ai/steipete/skills/weather" },
]),
});
});
@@ -54,7 +54,7 @@ vi.mock("../lib/skillPage", () => ({
}));
async function loadRoute() {
return (await import("../routes/$owner/$slug/security-audit")).Route as unknown as {
return (await import("../routes/$owner/skills/$slug/security-audit")).Route as unknown as {
__config: {
component?: ComponentType;
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
+15 -3
View File
@@ -38,12 +38,12 @@ describe("top-level slug route loader", () => {
resolveTopLevelSlugRouteMock.mockResolvedValue({
kind: "plugin",
name: "@openclaw/codex",
href: "/plugins/@openclaw/codex",
href: "/openclaw/plugins/codex",
});
expect(await runLoader("codex")).toEqual({
redirect: {
href: "/plugins/@openclaw/codex",
href: "/openclaw/plugins/codex",
replace: true,
},
});
@@ -58,13 +58,25 @@ describe("top-level slug route loader", () => {
expect(await runLoader("codex")).toEqual({
redirect: {
to: "/$owner/$slug",
to: "/$owner/skills/$slug",
params: { owner: "ivangdavila", slug: "codex" },
replace: true,
},
});
});
it("returns publisher profile data for canonical publisher paths", async () => {
resolveTopLevelSlugRouteMock.mockResolvedValue({
kind: "publisher",
handle: "steipete",
publisher: { _id: "publishers:steipete", handle: "steipete" },
});
expect(await runLoader("steipete")).toEqual({
publisher: { _id: "publishers:steipete", handle: "steipete" },
});
});
it("returns not found for unknown slugs", async () => {
resolveTopLevelSlugRouteMock.mockResolvedValue(null);
+3 -6
View File
@@ -40,8 +40,7 @@ describe("users route redirect", () => {
expect(() => route.__config.beforeLoad({ params: { handle: "alice" }, search: {} })).toThrow();
expect(redirectMock).toHaveBeenCalledWith({
to: "/user/$handle",
params: { handle: "alice" },
href: "/alice",
replace: true,
});
});
@@ -58,8 +57,7 @@ describe("users route redirect", () => {
expect(() => route.__config.beforeLoad({ params: { handle: "alice" }, search: {} })).toThrow();
expect(redirectMock).toHaveBeenCalledWith({
to: "/user/$handle",
params: { handle: "alice" },
href: "/alice",
replace: true,
});
});
@@ -78,8 +76,7 @@ describe("users route redirect", () => {
route.__config.beforeLoad({ params: { handle: "openclaw" }, search: {} }),
).toThrow();
expect(redirectMock).toHaveBeenCalledWith({
to: "/user/$handle",
params: { handle: "openclaw" },
href: "/openclaw",
replace: true,
});
});
+8 -6
View File
@@ -27,7 +27,8 @@ import {
} from "../lib/authErrorMessage";
import { gravatarUrl } from "../lib/gravatar";
import { PRIMARY_NAV_ITEMS, SECONDARY_NAV_ITEMS } from "../lib/nav-items";
import { displayPluginPackageName } from "../lib/pluginRoutes";
import { buildSkillDetailHref } from "../lib/ownerRoute";
import { buildPluginDetailHref, displayPluginPackageName } from "../lib/pluginRoutes";
import { SITE_NAME } from "../lib/site";
import { applyTheme, useThemeMode } from "../lib/theme";
import { clearAuthError, setAuthError } from "../lib/useAuthError";
@@ -295,12 +296,13 @@ export default function Header() {
return;
}
void navigate({
to: `/${encodeURIComponent(resultOwnerHandle)}/${encodeURIComponent(item.result.skill.slug)}`,
to: buildSkillDetailHref(resultOwnerHandle, item.result.skill.slug),
});
} else if (item.kind === "plugin") {
void navigate({
to: "/plugins/$name",
params: { name: item.result.plugin.name },
to: buildPluginDetailHref(item.result.plugin.name, {
ownerHandle: item.result.plugin.ownerHandle,
}),
});
} else {
void navigate({
@@ -576,8 +578,8 @@ export default function Header() {
{profileHandle ? (
<DropdownMenuItem asChild>
<Link
to="/user/$handle"
params={{ handle: profileHandle }}
to="/$slug"
params={{ slug: profileHandle }}
className="flex items-center gap-2"
>
<UserRound size={14} aria-hidden="true" />
+2 -2
View File
@@ -19,6 +19,7 @@ import {
type HomeSkillApp,
} from "../lib/homeApps";
import { OPENCLAW_LOGO_URL } from "../lib/nav-items";
import { buildPluginDetailHref } from "../lib/pluginRoutes";
function HomeAppsCompactSkill({ app }: { app: HomeSkillApp }) {
return (
@@ -50,8 +51,7 @@ function HomeAppsCompactSkill({ app }: { app: HomeSkillApp }) {
function HomeAppsCompactPlugin({ plugin: shortcut }: { plugin: HomePluginShortcut }) {
return (
<Link
to="/plugins/$name"
params={{ name: shortcut.packageName }}
to={buildPluginDetailHref(shortcut.packageName)}
className="home-v2-apps-tile"
title={shortcut.description}
>
+5 -2
View File
@@ -47,6 +47,7 @@ import {
} from "../lib/homeListingData";
import { formatCompactStat } from "../lib/numberFormat";
import { fetchPluginCatalog, type PackageListItem } from "../lib/packageApi";
import { buildPluginDetailHref } from "../lib/pluginRoutes";
import type { PublicSkill, PublicUser } from "../lib/publicUser";
import { truncateText } from "../lib/truncateText";
import { HomeListingCategorySelect } from "./HomeListingCategorySelect";
@@ -221,9 +222,10 @@ function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; show
function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) {
const name = plugin.displayName || plugin.name;
const pluginHref = buildPluginDetailHref(plugin.name, { ownerHandle: plugin.ownerHandle });
return (
<Link to="/plugins/$name" params={{ name: plugin.name }} className="home-v2-listing-row">
<Link to={pluginHref} className="home-v2-listing-row">
<span className="home-v2-listing-row-icon" aria-hidden="true">
<MarketplaceIcon kind="plugin" label={name} size="sm" />
</span>
@@ -292,9 +294,10 @@ function HomeListingSkillCard({ entry, showStats }: { entry: SkillPageEntry; sho
function HomeListingPluginCard({ plugin }: { plugin: PackageListItem }) {
const name = plugin.displayName || plugin.name;
const pluginHref = buildPluginDetailHref(plugin.name, { ownerHandle: plugin.ownerHandle });
return (
<Link to="/plugins/$name" params={{ name: plugin.name }} className="home-v2-listing-card">
<Link to={pluginHref} className="home-v2-listing-card">
<div className="home-v2-listing-card-head">
<span className="home-v2-listing-card-icon" aria-hidden="true">
<MarketplaceIcon kind="plugin" label={name} size="sm" />
@@ -40,8 +40,8 @@ function PopularPublisherCard({
return (
<Link
to="/user/$handle"
params={{ handle: pinned.handle }}
to="/$slug"
params={{ slug: pinned.handle }}
className="home-v2-popular-publisher-card"
aria-label={`${name}, @${pinned.handle}`}
draggable={false}
+4 -4
View File
@@ -4,6 +4,7 @@ import { Download, Star } from "lucide-react";
import { BrowseCategoryIcon } from "../lib/browseCategoryIcons";
import { formatCompactStat } from "../lib/numberFormat";
import type { PackageListItem } from "../lib/packageApi";
import { buildPluginDetailHref } from "../lib/pluginRoutes";
import { truncateText } from "../lib/truncateText";
import { CatalogTopicList } from "./CatalogTopicList";
import { MarketplaceIcon } from "./MarketplaceIcon";
@@ -48,12 +49,12 @@ export function PluginListItem({ item, variant = "list" }: PluginListItemProps)
.slice(0, 3)
.map((category) => category.label)
.join(", ");
const pluginHref = buildPluginDetailHref(item.name, { ownerHandle: item.ownerHandle });
if (variant === "card") {
return (
<Link
to="/plugins/$name"
params={{ name: item.name }}
to={pluginHref}
className="card skill-card plugin-card"
aria-label={`Plugin: ${item.displayName}`}
>
@@ -103,8 +104,7 @@ export function PluginListItem({ item, variant = "list" }: PluginListItemProps)
return (
<Link
to="/plugins/$name"
params={{ name: item.name }}
to={pluginHref}
className="skill-list-item skill-list-item-with-taxonomy"
aria-label={`Plugin: ${item.displayName}`}
>
+2 -2
View File
@@ -41,8 +41,8 @@ export function PublisherListItem({ publisher, variant = "list" }: PublisherList
return (
<Link
to="/user/$handle"
params={{ handle }}
to="/$slug"
params={{ slug: handle }}
className={`publisher-card publisher-card-${variant}`}
aria-label={`Publisher: ${publisher.displayName}`}
>
+4 -5
View File
@@ -19,6 +19,7 @@ import {
} from "../lib/authErrorMessage";
import { getSkillCategoriesForSkill, getSkillCategoryForSkill } from "../lib/categories";
import { getUserFacingConvexError } from "../lib/convexError";
import { buildSkillSecurityAuditHref } from "../lib/ownerRoute";
import { canManageSkill, isModerator } from "../lib/roles";
import { skillCardLoadKey } from "../lib/skillCards";
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
@@ -517,14 +518,14 @@ export function SkillDetailPage({
const params = { owner: ownerParam, slug: redirectSlug };
if (mode === "settings") {
void navigate({
to: "/$owner/$slug/settings",
to: "/$owner/skills/$slug/settings",
params,
replace: true,
});
return;
}
void navigate({
to: "/$owner/$slug",
to: "/$owner/skills/$slug",
params,
replace: true,
});
@@ -803,9 +804,7 @@ export function SkillDetailPage({
const securitySummary =
latestVersion || githubScanStatus ? (
<DetailSecuritySummary
auditHref={`/${encodeURIComponent(ownerParam ?? ownerHandle ?? "unknown")}/${encodeURIComponent(
skill.slug,
)}/security-audit`}
auditHref={buildSkillSecurityAuditHref(ownerParam ?? ownerHandle ?? "unknown", skill.slug)}
vtAnalysis={latestVersion?.vtAnalysis ?? null}
llmAnalysis={latestVersion?.llmAnalysis ?? null}
githubScanStatus={githubScanStatus}
+2 -2
View File
@@ -159,9 +159,9 @@ describe("SkillHeader", () => {
expect(within(sidebarStatsRoot(container)).getByText("Creator")).toBeTruthy();
expect(within(sidebarStatsRoot(container)).getByText("Downloads")).toBeTruthy();
expect(within(sidebarStatsRoot(container)).getByText("2")).toBeTruthy();
expect(container.querySelector('a[href="/user/local"]')).toBeTruthy();
expect(container.querySelector('a[href="/local"]')).toBeTruthy();
expect(
container.querySelector('nav[aria-label="Skill breadcrumbs"] a[href="/user/local"]'),
container.querySelector('nav[aria-label="Skill breadcrumbs"] a[href="/local"]'),
).toBeTruthy();
});
+2 -1
View File
@@ -9,6 +9,7 @@ import { getSkillBadges } from "../lib/badges";
import { BrowseCategoryIcon } from "../lib/browseCategoryIcons";
import { buildSkillCategoryBrowseHref, type SkillCategory } from "../lib/categories";
import { formatSkillStatsTriplet } from "../lib/numberFormat";
import { buildPublisherProfileHref } from "../lib/ownerRoute";
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
import { timeAgo } from "../lib/timeAgo";
import { ActivityMetricLabel } from "./ActivityMetricLabel";
@@ -345,7 +346,7 @@ export function SkillHeader({
<nav className="skill-hero-breadcrumbs" aria-label="Skill breadcrumbs">
<a href="/skills">skills</a>
<span aria-hidden="true">/</span>
<a href={ownerHandle ? `/user/${encodeURIComponent(ownerHandle)}` : "#"}>
<a href={ownerHandle ? buildPublisherProfileHref(ownerHandle) : "#"}>
{ownerHandle ?? owner?.displayName ?? owner?._id ?? "unknown"}
</a>
<span aria-hidden="true">/</span>
+2 -2
View File
@@ -146,7 +146,7 @@ export function SkillOwnershipPanel({
await renameOwnedSkill({ slug, newSlug: nextSlug, ownerHandle: ownerHandle ?? undefined });
toast.success(`Renamed to ${nextSlug}. Old slug will redirect.`);
await navigate({
to: "/$owner/$slug",
to: "/$owner/skills/$slug",
params: {
owner: ownerHandle ?? String(ownerId ?? ""),
slug: nextSlug,
@@ -174,7 +174,7 @@ export function SkillOwnershipPanel({
});
toast.success(`Merged into ${targetSlug}. This slug will redirect.`);
await navigate({
to: "/$owner/$slug",
to: "/$owner/skills/$slug",
params: {
owner: ownerHandle ?? String(ownerId ?? ""),
slug: targetSlug,
@@ -14,7 +14,7 @@ function renderDialog(overrides: Partial<Parameters<typeof SkillPublishSuccessDi
<SkillPublishSuccessDialog
isOpen
displayName="Agent Helper"
skillPath="/vyctor/agent-helper"
skillPath="/vyctor/skills/agent-helper"
skill={{
slug: "agent-helper",
displayName: "Agent Helper",
@@ -65,7 +65,7 @@ describe("SkillPublishSuccessDialog", () => {
expect(xHref).toContain("https://twitter.com/intent/tweet?");
const xParams = new URL(xHref).searchParams;
expect(xParams.get("text")).toBe(
"Agent Helper is now live on ClawHub 🦞 Check it out: https://clawhub.ai/vyctor/agent-helper",
"Agent Helper is now live on ClawHub 🦞 Check it out: https://clawhub.ai/vyctor/skills/agent-helper",
);
expect(xParams.get("url")).toBeNull();
});
@@ -89,8 +89,8 @@ describe("SkillPublishSuccessDialog", () => {
renderDialog();
const skillLink = screen.getByRole("link", { name: "clawhub.ai/vyctor/agent-helper" });
expect(skillLink.getAttribute("href")).toBe("https://clawhub.ai/vyctor/agent-helper");
const skillLink = screen.getByRole("link", { name: "clawhub.ai/vyctor/skills/agent-helper" });
expect(skillLink.getAttribute("href")).toBe("https://clawhub.ai/vyctor/skills/agent-helper");
},
);
@@ -100,7 +100,9 @@ describe("SkillPublishSuccessDialog", () => {
fireEvent.click(screen.getByRole("button", { name: /Copy skill link/i }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(expect.stringContaining("/vyctor/agent-helper"));
expect(writeTextMock).toHaveBeenCalledWith(
expect.stringContaining("/vyctor/skills/agent-helper"),
);
});
expect(await screen.findByText("Copied")).toBeTruthy();
});
@@ -112,7 +114,7 @@ describe("SkillPublishSuccessDialog", () => {
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith(
"I just published Agent Helper on ClawHub: https://clawhub.ai/vyctor/agent-helper",
"I just published Agent Helper on ClawHub: https://clawhub.ai/vyctor/skills/agent-helper",
);
});
});
+4 -4
View File
@@ -42,7 +42,7 @@ describe("UserBadge", () => {
renderBadge(user);
expect(screen.getByRole("link", { name: "View @steipete profile" }).getAttribute("href")).toBe(
"/user/steipete",
"/steipete",
);
});
@@ -50,7 +50,7 @@ describe("UserBadge", () => {
renderBadge(orgPublisher);
expect(screen.getByRole("link", { name: "View @openclaw profile" }).getAttribute("href")).toBe(
"/user/openclaw",
"/openclaw",
);
});
@@ -79,7 +79,7 @@ describe("UserBadge", () => {
expect(screen.getByText("@acme-corp")).toBeTruthy();
expect(container.querySelector(".user-handle-muted")).toBeTruthy();
expect(screen.getByRole("link", { name: "View Acme profile" }).getAttribute("href")).toBe(
"/user/acme-corp",
"/acme-corp",
);
});
@@ -130,7 +130,7 @@ describe("UserBadge", () => {
);
expect(screen.getByRole("link", { name: "View OpenClaw profile" }).getAttribute("href")).toBe(
"/user/openclaw",
"/openclaw",
);
});
+2 -1
View File
@@ -5,6 +5,7 @@ import type { Id } from "../../convex/_generated/dataModel";
import { convexHttp } from "../convex/client";
import { hasOwnProperty } from "../lib/hasOwnProperty";
import { formatCompactStat } from "../lib/numberFormat";
import { buildPublisherProfileHref } from "../lib/ownerRoute";
import { OfficialBadge } from "./OfficialBadge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
@@ -47,7 +48,7 @@ export function UserBadge({
hasOwnProperty(user, "name") && typeof user.name === "string" ? user.name.trim() : undefined;
const displayName = user?.displayName?.trim() || userName || null;
const handle = user?.handle ?? fallbackHandle ?? null;
const href = handle ? `/user/${encodeURIComponent(handle)}` : null;
const href = handle ? buildPublisherProfileHref(handle) : null;
const label = handle ? `@${handle}` : "user";
const image = user?.image ?? null;
const showInlineMutedHandle = showMutedHandle && Boolean(handle) && Boolean(displayName);
+1 -1
View File
@@ -57,7 +57,7 @@ describe("skill detail install helpers", () => {
});
expect(prompt).toContain("@steipete/weather");
expect(prompt).toContain("https://clawhub.ai/steipete/weather");
expect(prompt).toContain("https://clawhub.ai/steipete/skills/weather");
expect(prompt).toContain("WEATHER_API_KEY");
expect(prompt).toContain("curl");
expect(prompt).toContain("~/.weatherrc");
+3 -2
View File
@@ -1,5 +1,6 @@
import type { ClawdisSkillMetadata, SkillInstallSpec } from "clawhub-schema";
import type { Id } from "../../convex/_generated/dataModel";
import { buildSkillDetailHref } from "../lib/ownerRoute";
import { getClawHubSiteUrl } from "../lib/site";
export type SkillPromptMode = "install-only" | "install-and-setup";
@@ -26,7 +27,7 @@ export function buildSkillHref(
slug: string,
) {
const owner = ownerHandle?.trim() || (ownerId ? String(ownerId) : "unknown");
return `/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`;
return buildSkillDetailHref(owner, slug);
}
export function formatConfigSnippet(raw: string) {
@@ -172,7 +173,7 @@ export function buildSkillPageUrl(
const owner = handle || (ownerId ? String(ownerId) : null);
if (!owner) return null;
const path = `/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`;
const path = buildSkillDetailHref(owner, slug);
return new URL(path, getClawHubSiteUrl()).toString();
}
-1
View File
@@ -62,7 +62,6 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
{
label: "Publishers",
to: PublicRegistryPaths.publishers,
activePathPrefixes: ["/user/"],
},
];
+4 -4
View File
@@ -16,7 +16,7 @@ describe("og helpers", () => {
});
expect(meta.title).toBe("Weather — ClawHub");
expect(meta.description).toBe("Forecasts for your area.");
expect(meta.url).toContain("/steipete/weather");
expect(meta.url).toContain("/steipete/skills/weather");
expect(meta.owner).toBe("steipete");
expect(meta.image).toContain("/og/skill?");
expect(meta.image).toContain("v=8");
@@ -37,7 +37,7 @@ describe("og helpers", () => {
});
expect(meta.title).toBe("Codex — ClawHub Plugins");
expect(meta.description).toBe("OpenClaw Codex harness.");
expect(meta.url).toBe("https://clawhub.ai/plugins/@openclaw/codex");
expect(meta.url).toBe("https://clawhub.ai/openclaw/plugins/codex");
expect(meta.image).toContain("/og/plugin?");
expect(meta.image).toContain("v=3");
expect(meta.image).toContain("name=%40openclaw%2Fcodex");
@@ -52,7 +52,7 @@ describe("og helpers", () => {
});
expect(meta.title).toBe("byungkyu — ClawHub");
expect(meta.description).toBe("maton.ai");
expect(meta.url).toBe("https://clawhub.ai/user/byungkyu");
expect(meta.url).toBe("https://clawhub.ai/byungkyu");
expect(meta.image).toContain("/og/profile?");
expect(meta.image).toContain("v=3");
expect(meta.image).toContain("handle=byungkyu");
@@ -62,7 +62,7 @@ describe("og helpers", () => {
const meta = buildSkillMeta({ slug: "parser" });
expect(meta.title).toBe("parser — ClawHub");
expect(meta.description).toMatch(/ClawHub — a fast skill registry/i);
expect(meta.url).toContain("/unknown/parser");
expect(meta.url).toContain("/unknown/skills/parser");
expect(meta.owner).toBeNull();
expect(meta.image).toContain("slug=parser");
});
+5 -3
View File
@@ -1,3 +1,5 @@
import { buildSkillDetailHref, buildPublisherProfileHref } from "./ownerRoute";
import { buildPluginDetailHref } from "./pluginRoutes";
import { getRuntimeEnv } from "./runtimeEnv";
import { getClawHubSiteUrl, SITE_DESCRIPTION } from "./site";
@@ -86,7 +88,7 @@ export function buildSkillMeta(source: SkillMetaSource): SkillMeta {
const description =
summary || (owner ? `Agent skill by @${owner} on ClawHub.` : SITE_DESCRIPTION);
const ownerPath = owner || ownerId || "unknown";
const url = `${siteUrl}/${ownerPath}/${source.slug}`;
const url = `${siteUrl}${buildSkillDetailHref(ownerPath, source.slug)}`;
const imageParams = new URLSearchParams();
imageParams.set("v", OG_SKILL_IMAGE_LAYOUT_VERSION);
imageParams.set("slug", source.slug);
@@ -109,7 +111,7 @@ export function buildPluginMeta(source: PluginMetaSource): BasicMeta {
const latestVersion = clean(source.latestVersion);
const title = `${displayName} — ClawHub Plugins`;
const description = summary || (owner ? `Plugin by @${owner} on ClawHub.` : SITE_DESCRIPTION);
const url = `${siteUrl}/plugins/${source.name.startsWith("@") ? source.name : encodeURIComponent(source.name)}`;
const url = `${siteUrl}${buildPluginDetailHref(source.name, { ownerHandle: owner })}`;
const imageParams = new URLSearchParams();
imageParams.set("v", OG_PLUGIN_IMAGE_LAYOUT_VERSION);
imageParams.set("name", source.name);
@@ -136,7 +138,7 @@ export function buildPublisherMeta(source: PublisherMetaSource): BasicMeta {
title,
description: truncate(description, 200),
image: `${siteUrl}/og/profile?${imageParams.toString()}`,
url: `${siteUrl}/user/${handle}`,
url: `${siteUrl}${buildPublisherProfileHref(handle)}`,
};
}
+20
View File
@@ -16,3 +16,23 @@ export function isOwnerRouteScopeSegment(owner: string) {
export function isOwnerRouteHandleOrIdSegment(owner: string) {
return isOwnerRouteHandleSegment(owner) || isOwnerRouteIdSegment(owner);
}
function routeSegment(value: string) {
return encodeURIComponent(value.trim().replace(/^@+/, ""));
}
export function buildPublisherProfileHref(handle: string) {
return `/${routeSegment(handle)}`;
}
export function buildSkillDetailHref(owner: string, slug: string) {
return `/${routeSegment(owner)}/skills/${routeSegment(slug)}`;
}
export function buildSkillSecurityAuditHref(owner: string, slug: string) {
return `${buildSkillDetailHref(owner, slug)}/security-audit`;
}
export function buildSkillSettingsHref(owner: string, slug: string) {
return `${buildSkillDetailHref(owner, slug)}/settings`;
}
+11 -2
View File
@@ -9,9 +9,9 @@ import {
describe("plugin routes", () => {
it("keeps scoped package routes readable", () => {
expect(buildPluginDetailHref("@openclaw/codex")).toBe("/plugins/@openclaw/codex");
expect(buildPluginDetailHref("@openclaw/codex")).toBe("/openclaw/plugins/codex");
expect(buildPluginSecurityAuditHref("@openclaw/codex")).toBe(
"/plugins/@openclaw/codex/security-audit",
"/openclaw/plugins/codex/security-audit",
);
});
@@ -19,6 +19,15 @@ describe("plugin routes", () => {
expect(buildPluginDetailHref("demo plugin")).toBe("/plugins/demo%20plugin");
});
it("uses explicit owner handles for unscoped package detail routes", () => {
expect(buildPluginDetailHref("demo-plugin", { ownerHandle: "acme" })).toBe(
"/acme/plugins/demo-plugin",
);
expect(buildPluginSecurityAuditHref("demo-plugin", { ownerHandle: "@acme" })).toBe(
"/acme/plugins/demo-plugin/security-audit",
);
});
it("parses scoped package names and scoped routes", () => {
expect(parseScopedPackageName("@openclaw/codex")).toEqual({
scope: "@openclaw",
+27 -3
View File
@@ -16,8 +16,26 @@ export function displayPluginPackageName(name: string) {
return parseScopedPackageName(name)?.name ?? name;
}
export function buildPluginDetailHref(name: string) {
type PluginRouteOptions = {
ownerHandle?: string | null;
};
function cleanOwnerHandle(ownerHandle: string | null | undefined) {
return ownerHandle?.trim().replace(/^@+/, "") || null;
}
function routeSegment(value: string) {
return encodeURIComponent(value.trim().replace(/^@+/, ""));
}
export function buildPluginDetailHref(name: string, options: PluginRouteOptions = {}) {
const scoped = parseScopedPackageName(name);
const ownerHandle = cleanOwnerHandle(options.ownerHandle) ?? cleanOwnerHandle(scoped?.scope);
if (ownerHandle) {
return `/${routeSegment(ownerHandle)}/plugins/${routeSegment(scoped?.name ?? name)}`;
}
if (!scoped) return `/plugins/${encodeURIComponent(name)}`;
return `/plugins/@${encodeURIComponent(scoped.scope.slice(1))}/${encodeURIComponent(
@@ -25,8 +43,8 @@ export function buildPluginDetailHref(name: string) {
)}`;
}
export function buildPluginSecurityAuditHref(name: string) {
return `${buildPluginDetailHref(name)}/security-audit`;
export function buildPluginSecurityAuditHref(name: string, options: PluginRouteOptions = {}) {
return `${buildPluginDetailHref(name, options)}/security-audit`;
}
export function buildPluginValidationHref(name: string) {
@@ -37,3 +55,9 @@ export function packageNameFromScopedRoute(scope: string, name: string) {
if (!scope.startsWith("@") || !name || name.includes("/")) return null;
return `${scope}/${name}`;
}
export function packageNameFromPublisherPluginRoute(owner: string, name: string) {
const ownerHandle = cleanOwnerHandle(owner);
if (!ownerHandle || !name || name.includes("/")) return null;
return `@${ownerHandle}/${name}`;
}
+23 -5
View File
@@ -1,23 +1,29 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchSkillPageDataMock = vi.fn();
const queryMock = vi.fn();
vi.mock("./skillPage", () => ({
fetchSkillPageData: (...args: unknown[]) => fetchSkillPageDataMock(...args),
}));
vi.mock("../convex/client", () => ({
convexHttp: { query: (...args: unknown[]) => queryMock(...args) },
}));
import { resolveOpenClawPluginSlug, resolveTopLevelSlugRoute } from "./slugRoute";
describe("slug route resolution", () => {
beforeEach(() => {
fetchSkillPageDataMock.mockReset();
queryMock.mockReset();
});
it("resolves Codex to the official OpenClaw plugin", async () => {
await expect(resolveTopLevelSlugRoute("codex")).resolves.toEqual({
kind: "plugin",
name: "@openclaw/codex",
href: "/plugins/@openclaw/codex",
href: "/openclaw/plugins/codex",
});
expect(fetchSkillPageDataMock).not.toHaveBeenCalled();
});
@@ -26,19 +32,19 @@ describe("slug route resolution", () => {
await expect(resolveTopLevelSlugRoute("anthropic")).resolves.toEqual({
kind: "plugin",
name: "@openclaw/anthropic-provider",
href: "/plugins/@openclaw/anthropic-provider",
href: "/openclaw/plugins/anthropic-provider",
});
await expect(resolveOpenClawPluginSlug("kimi-coding", "openclaw")).resolves.toEqual({
kind: "plugin",
name: "@openclaw/kimi-provider",
href: "/plugins/@openclaw/kimi-provider",
href: "/openclaw/plugins/kimi-provider",
});
await expect(resolveTopLevelSlugRoute("diffs-language-pack")).resolves.toEqual({
kind: "plugin",
name: "@openclaw/diffs-language-pack",
href: "/plugins/@openclaw/diffs-language-pack",
href: "/openclaw/plugins/diffs-language-pack",
});
expect(fetchSkillPageDataMock).not.toHaveBeenCalled();
@@ -48,7 +54,7 @@ describe("slug route resolution", () => {
await expect(resolveOpenClawPluginSlug("codex", "@openclaw")).resolves.toEqual({
kind: "plugin",
name: "@openclaw/codex",
href: "/plugins/@openclaw/codex",
href: "/openclaw/plugins/codex",
});
});
@@ -57,6 +63,7 @@ describe("slug route resolution", () => {
});
it("falls back to skill slug resolution when no official plugin exists", async () => {
queryMock.mockResolvedValue(null);
fetchSkillPageDataMock.mockResolvedValue({
owner: "steipete",
initialData: {
@@ -74,4 +81,15 @@ describe("slug route resolution", () => {
slug: "weather",
});
});
it("resolves publisher handles before legacy bare skill slugs", async () => {
queryMock.mockResolvedValue({ _id: "publishers:steipete", handle: "steipete" });
await expect(resolveTopLevelSlugRoute("steipete")).resolves.toEqual({
kind: "publisher",
handle: "steipete",
publisher: { _id: "publishers:steipete", handle: "steipete" },
});
expect(fetchSkillPageDataMock).not.toHaveBeenCalled();
});
});
+30
View File
@@ -1,5 +1,8 @@
import { api } from "../../convex/_generated/api";
import { convexHttp } from "../convex/client";
import { getOpenClawExtensionPackageName } from "./openClawExtensionSlugs";
import { buildPluginDetailHref } from "./pluginRoutes";
import type { PublicPublisherListItem } from "./publicUser";
import { fetchSkillPageData } from "./skillPage";
const OPENCLAW_HANDLE = "openclaw";
@@ -14,6 +17,11 @@ type SlugRouteTarget =
kind: "skill";
owner: string;
slug: string;
}
| {
kind: "publisher";
handle: string;
publisher: PublicPublisherListItem;
};
type PluginSlugRouteTarget = Extract<SlugRouteTarget, { kind: "plugin" }>;
@@ -45,6 +53,15 @@ export async function resolveTopLevelSlugRoute(slug: string): Promise<SlugRouteT
const plugin = await resolveOpenClawPluginSlug(slug);
if (plugin) return plugin;
const publisher = await resolvePublisherHandle(slug);
if (publisher) {
return {
kind: "publisher",
handle: publisher.handle,
publisher,
};
}
const data = await fetchSkillPageData(slug);
const owner = data.initialData?.result?.owner?.handle ?? data.owner;
const resolvedSlug = data.initialData?.result?.resolvedSlug ?? slug;
@@ -56,3 +73,16 @@ export async function resolveTopLevelSlugRoute(slug: string): Promise<SlugRouteT
slug: resolvedSlug,
};
}
async function resolvePublisherHandle(handle: string) {
const normalized = normalizeOwner(handle);
if (!normalized) return null;
try {
return (await convexHttp.query(api.publishers.getProfileByHandle, {
handle: normalized,
})) as PublicPublisherListItem | null;
} catch {
return null;
}
}
+170
View File
@@ -45,12 +45,19 @@ import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
import { Route as PluginsScopeNameRouteImport } from './routes/plugins/$scope/$name'
import { Route as PluginsNameSecurityAuditRouteImport } from './routes/plugins/$name/security-audit'
import { Route as PackagesScopeNameRouteImport } from './routes/packages/$scope/$name'
import { Route as OwnerSkillsSlugRouteImport } from './routes/$owner/skills/$slug'
import { Route as OwnerPluginsSlugRouteImport } from './routes/$owner/plugins/$slug'
import { Route as OwnerSlugSettingsRouteImport } from './routes/$owner/$slug/settings'
import { Route as OwnerSlugSecurityAuditRouteImport } from './routes/$owner/$slug/security-audit'
import { Route as PluginsScopeNameSecurityAuditRouteImport } from './routes/plugins/$scope/$name/security-audit'
import { Route as PluginsNameSecurityScannerRouteImport } from './routes/plugins/$name/security/$scanner'
import { Route as OwnerSkillsSlugSettingsRouteImport } from './routes/$owner/skills/$slug/settings'
import { Route as OwnerSkillsSlugSecurityAuditRouteImport } from './routes/$owner/skills/$slug/security-audit'
import { Route as OwnerPluginsSlugSecurityAuditRouteImport } from './routes/$owner/plugins/$slug/security-audit'
import { Route as OwnerSlugSecurityScannerRouteImport } from './routes/$owner/$slug/security/$scanner'
import { Route as PluginsScopeNameSecurityScannerRouteImport } from './routes/plugins/$scope/$name/security/$scanner'
import { Route as OwnerSkillsSlugSecurityScannerRouteImport } from './routes/$owner/skills/$slug/security/$scanner'
import { Route as OwnerPluginsSlugSecurityScannerRouteImport } from './routes/$owner/plugins/$slug/security/$scanner'
const UploadRoute = UploadRouteImport.update({
id: '/upload',
@@ -233,6 +240,16 @@ const PackagesScopeNameRoute = PackagesScopeNameRouteImport.update({
path: '/packages/$scope/$name',
getParentRoute: () => rootRouteImport,
} as any)
const OwnerSkillsSlugRoute = OwnerSkillsSlugRouteImport.update({
id: '/$owner/skills/$slug',
path: '/$owner/skills/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const OwnerPluginsSlugRoute = OwnerPluginsSlugRouteImport.update({
id: '/$owner/plugins/$slug',
path: '/$owner/plugins/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const OwnerSlugSettingsRoute = OwnerSlugSettingsRouteImport.update({
id: '/settings',
path: '/settings',
@@ -255,6 +272,23 @@ const PluginsNameSecurityScannerRoute =
path: '/security/$scanner',
getParentRoute: () => PluginsNameRoute,
} as any)
const OwnerSkillsSlugSettingsRoute = OwnerSkillsSlugSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => OwnerSkillsSlugRoute,
} as any)
const OwnerSkillsSlugSecurityAuditRoute =
OwnerSkillsSlugSecurityAuditRouteImport.update({
id: '/security-audit',
path: '/security-audit',
getParentRoute: () => OwnerSkillsSlugRoute,
} as any)
const OwnerPluginsSlugSecurityAuditRoute =
OwnerPluginsSlugSecurityAuditRouteImport.update({
id: '/security-audit',
path: '/security-audit',
getParentRoute: () => OwnerPluginsSlugRoute,
} as any)
const OwnerSlugSecurityScannerRoute =
OwnerSlugSecurityScannerRouteImport.update({
id: '/security/$scanner',
@@ -267,6 +301,18 @@ const PluginsScopeNameSecurityScannerRoute =
path: '/security/$scanner',
getParentRoute: () => PluginsScopeNameRoute,
} as any)
const OwnerSkillsSlugSecurityScannerRoute =
OwnerSkillsSlugSecurityScannerRouteImport.update({
id: '/security/$scanner',
path: '/security/$scanner',
getParentRoute: () => OwnerSkillsSlugRoute,
} as any)
const OwnerPluginsSlugSecurityScannerRoute =
OwnerPluginsSlugSecurityScannerRouteImport.update({
id: '/security/$scanner',
path: '/security/$scanner',
getParentRoute: () => OwnerPluginsSlugRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -304,12 +350,19 @@ export interface FileRoutesByFullPath {
'/users/': typeof UsersIndexRoute
'/$owner/$slug/security-audit': typeof OwnerSlugSecurityAuditRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/$owner/plugins/$slug': typeof OwnerPluginsSlugRouteWithChildren
'/$owner/skills/$slug': typeof OwnerSkillsSlugRouteWithChildren
'/packages/$scope/$name': typeof PackagesScopeNameRoute
'/plugins/$name/security-audit': typeof PluginsNameSecurityAuditRoute
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/$owner/plugins/$slug/security-audit': typeof OwnerPluginsSlugSecurityAuditRoute
'/$owner/skills/$slug/security-audit': typeof OwnerSkillsSlugSecurityAuditRoute
'/$owner/skills/$slug/settings': typeof OwnerSkillsSlugSettingsRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
'/plugins/$scope/$name/security-audit': typeof PluginsScopeNameSecurityAuditRoute
'/$owner/plugins/$slug/security/$scanner': typeof OwnerPluginsSlugSecurityScannerRoute
'/$owner/skills/$slug/security/$scanner': typeof OwnerSkillsSlugSecurityScannerRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
}
export interface FileRoutesByTo {
@@ -348,12 +401,19 @@ export interface FileRoutesByTo {
'/users': typeof UsersIndexRoute
'/$owner/$slug/security-audit': typeof OwnerSlugSecurityAuditRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/$owner/plugins/$slug': typeof OwnerPluginsSlugRouteWithChildren
'/$owner/skills/$slug': typeof OwnerSkillsSlugRouteWithChildren
'/packages/$scope/$name': typeof PackagesScopeNameRoute
'/plugins/$name/security-audit': typeof PluginsNameSecurityAuditRoute
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/$owner/plugins/$slug/security-audit': typeof OwnerPluginsSlugSecurityAuditRoute
'/$owner/skills/$slug/security-audit': typeof OwnerSkillsSlugSecurityAuditRoute
'/$owner/skills/$slug/settings': typeof OwnerSkillsSlugSettingsRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
'/plugins/$scope/$name/security-audit': typeof PluginsScopeNameSecurityAuditRoute
'/$owner/plugins/$slug/security/$scanner': typeof OwnerPluginsSlugSecurityScannerRoute
'/$owner/skills/$slug/security/$scanner': typeof OwnerSkillsSlugSecurityScannerRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
}
export interface FileRoutesById {
@@ -393,12 +453,19 @@ export interface FileRoutesById {
'/users/': typeof UsersIndexRoute
'/$owner/$slug/security-audit': typeof OwnerSlugSecurityAuditRoute
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
'/$owner/plugins/$slug': typeof OwnerPluginsSlugRouteWithChildren
'/$owner/skills/$slug': typeof OwnerSkillsSlugRouteWithChildren
'/packages/$scope/$name': typeof PackagesScopeNameRoute
'/plugins/$name/security-audit': typeof PluginsNameSecurityAuditRoute
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
'/$owner/plugins/$slug/security-audit': typeof OwnerPluginsSlugSecurityAuditRoute
'/$owner/skills/$slug/security-audit': typeof OwnerSkillsSlugSecurityAuditRoute
'/$owner/skills/$slug/settings': typeof OwnerSkillsSlugSettingsRoute
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
'/plugins/$scope/$name/security-audit': typeof PluginsScopeNameSecurityAuditRoute
'/$owner/plugins/$slug/security/$scanner': typeof OwnerPluginsSlugSecurityScannerRoute
'/$owner/skills/$slug/security/$scanner': typeof OwnerSkillsSlugSecurityScannerRoute
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
}
export interface FileRouteTypes {
@@ -439,12 +506,19 @@ export interface FileRouteTypes {
| '/users/'
| '/$owner/$slug/security-audit'
| '/$owner/$slug/settings'
| '/$owner/plugins/$slug'
| '/$owner/skills/$slug'
| '/packages/$scope/$name'
| '/plugins/$name/security-audit'
| '/plugins/$scope/$name'
| '/$owner/$slug/security/$scanner'
| '/$owner/plugins/$slug/security-audit'
| '/$owner/skills/$slug/security-audit'
| '/$owner/skills/$slug/settings'
| '/plugins/$name/security/$scanner'
| '/plugins/$scope/$name/security-audit'
| '/$owner/plugins/$slug/security/$scanner'
| '/$owner/skills/$slug/security/$scanner'
| '/plugins/$scope/$name/security/$scanner'
fileRoutesByTo: FileRoutesByTo
to:
@@ -483,12 +557,19 @@ export interface FileRouteTypes {
| '/users'
| '/$owner/$slug/security-audit'
| '/$owner/$slug/settings'
| '/$owner/plugins/$slug'
| '/$owner/skills/$slug'
| '/packages/$scope/$name'
| '/plugins/$name/security-audit'
| '/plugins/$scope/$name'
| '/$owner/$slug/security/$scanner'
| '/$owner/plugins/$slug/security-audit'
| '/$owner/skills/$slug/security-audit'
| '/$owner/skills/$slug/settings'
| '/plugins/$name/security/$scanner'
| '/plugins/$scope/$name/security-audit'
| '/$owner/plugins/$slug/security/$scanner'
| '/$owner/skills/$slug/security/$scanner'
| '/plugins/$scope/$name/security/$scanner'
id:
| '__root__'
@@ -527,12 +608,19 @@ export interface FileRouteTypes {
| '/users/'
| '/$owner/$slug/security-audit'
| '/$owner/$slug/settings'
| '/$owner/plugins/$slug'
| '/$owner/skills/$slug'
| '/packages/$scope/$name'
| '/plugins/$name/security-audit'
| '/plugins/$scope/$name'
| '/$owner/$slug/security/$scanner'
| '/$owner/plugins/$slug/security-audit'
| '/$owner/skills/$slug/security-audit'
| '/$owner/skills/$slug/settings'
| '/plugins/$name/security/$scanner'
| '/plugins/$scope/$name/security-audit'
| '/$owner/plugins/$slug/security/$scanner'
| '/$owner/skills/$slug/security/$scanner'
| '/plugins/$scope/$name/security/$scanner'
fileRoutesById: FileRoutesById
}
@@ -570,6 +658,8 @@ export interface RootRouteChildren {
PublishersIndexRoute: typeof PublishersIndexRoute
SkillsIndexRoute: typeof SkillsIndexRoute
UsersIndexRoute: typeof UsersIndexRoute
OwnerPluginsSlugRoute: typeof OwnerPluginsSlugRouteWithChildren
OwnerSkillsSlugRoute: typeof OwnerSkillsSlugRouteWithChildren
PackagesScopeNameRoute: typeof PackagesScopeNameRoute
PluginsScopeNameRoute: typeof PluginsScopeNameRouteWithChildren
}
@@ -828,6 +918,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PackagesScopeNameRouteImport
parentRoute: typeof rootRouteImport
}
'/$owner/skills/$slug': {
id: '/$owner/skills/$slug'
path: '/$owner/skills/$slug'
fullPath: '/$owner/skills/$slug'
preLoaderRoute: typeof OwnerSkillsSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/$owner/plugins/$slug': {
id: '/$owner/plugins/$slug'
path: '/$owner/plugins/$slug'
fullPath: '/$owner/plugins/$slug'
preLoaderRoute: typeof OwnerPluginsSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/$owner/$slug/settings': {
id: '/$owner/$slug/settings'
path: '/settings'
@@ -856,6 +960,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PluginsNameSecurityScannerRouteImport
parentRoute: typeof PluginsNameRoute
}
'/$owner/skills/$slug/settings': {
id: '/$owner/skills/$slug/settings'
path: '/settings'
fullPath: '/$owner/skills/$slug/settings'
preLoaderRoute: typeof OwnerSkillsSlugSettingsRouteImport
parentRoute: typeof OwnerSkillsSlugRoute
}
'/$owner/skills/$slug/security-audit': {
id: '/$owner/skills/$slug/security-audit'
path: '/security-audit'
fullPath: '/$owner/skills/$slug/security-audit'
preLoaderRoute: typeof OwnerSkillsSlugSecurityAuditRouteImport
parentRoute: typeof OwnerSkillsSlugRoute
}
'/$owner/plugins/$slug/security-audit': {
id: '/$owner/plugins/$slug/security-audit'
path: '/security-audit'
fullPath: '/$owner/plugins/$slug/security-audit'
preLoaderRoute: typeof OwnerPluginsSlugSecurityAuditRouteImport
parentRoute: typeof OwnerPluginsSlugRoute
}
'/$owner/$slug/security/$scanner': {
id: '/$owner/$slug/security/$scanner'
path: '/security/$scanner'
@@ -870,6 +995,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PluginsScopeNameSecurityScannerRouteImport
parentRoute: typeof PluginsScopeNameRoute
}
'/$owner/skills/$slug/security/$scanner': {
id: '/$owner/skills/$slug/security/$scanner'
path: '/security/$scanner'
fullPath: '/$owner/skills/$slug/security/$scanner'
preLoaderRoute: typeof OwnerSkillsSlugSecurityScannerRouteImport
parentRoute: typeof OwnerSkillsSlugRoute
}
'/$owner/plugins/$slug/security/$scanner': {
id: '/$owner/plugins/$slug/security/$scanner'
path: '/security/$scanner'
fullPath: '/$owner/plugins/$slug/security/$scanner'
preLoaderRoute: typeof OwnerPluginsSlugSecurityScannerRouteImport
parentRoute: typeof OwnerPluginsSlugRoute
}
}
}
@@ -903,6 +1042,35 @@ const PluginsNameRouteWithChildren = PluginsNameRoute._addFileChildren(
PluginsNameRouteChildren,
)
interface OwnerPluginsSlugRouteChildren {
OwnerPluginsSlugSecurityAuditRoute: typeof OwnerPluginsSlugSecurityAuditRoute
OwnerPluginsSlugSecurityScannerRoute: typeof OwnerPluginsSlugSecurityScannerRoute
}
const OwnerPluginsSlugRouteChildren: OwnerPluginsSlugRouteChildren = {
OwnerPluginsSlugSecurityAuditRoute: OwnerPluginsSlugSecurityAuditRoute,
OwnerPluginsSlugSecurityScannerRoute: OwnerPluginsSlugSecurityScannerRoute,
}
const OwnerPluginsSlugRouteWithChildren =
OwnerPluginsSlugRoute._addFileChildren(OwnerPluginsSlugRouteChildren)
interface OwnerSkillsSlugRouteChildren {
OwnerSkillsSlugSecurityAuditRoute: typeof OwnerSkillsSlugSecurityAuditRoute
OwnerSkillsSlugSettingsRoute: typeof OwnerSkillsSlugSettingsRoute
OwnerSkillsSlugSecurityScannerRoute: typeof OwnerSkillsSlugSecurityScannerRoute
}
const OwnerSkillsSlugRouteChildren: OwnerSkillsSlugRouteChildren = {
OwnerSkillsSlugSecurityAuditRoute: OwnerSkillsSlugSecurityAuditRoute,
OwnerSkillsSlugSettingsRoute: OwnerSkillsSlugSettingsRoute,
OwnerSkillsSlugSecurityScannerRoute: OwnerSkillsSlugSecurityScannerRoute,
}
const OwnerSkillsSlugRouteWithChildren = OwnerSkillsSlugRoute._addFileChildren(
OwnerSkillsSlugRouteChildren,
)
interface PluginsScopeNameRouteChildren {
PluginsScopeNameSecurityAuditRoute: typeof PluginsScopeNameSecurityAuditRoute
PluginsScopeNameSecurityScannerRoute: typeof PluginsScopeNameSecurityScannerRoute
@@ -950,6 +1118,8 @@ const rootRouteChildren: RootRouteChildren = {
PublishersIndexRoute: PublishersIndexRoute,
SkillsIndexRoute: SkillsIndexRoute,
UsersIndexRoute: UsersIndexRoute,
OwnerPluginsSlugRoute: OwnerPluginsSlugRouteWithChildren,
OwnerSkillsSlugRoute: OwnerSkillsSlugRouteWithChildren,
PackagesScopeNameRoute: PackagesScopeNameRoute,
PluginsScopeNameRoute: PluginsScopeNameRouteWithChildren,
}
+91 -62
View File
@@ -9,7 +9,11 @@ import {
import { useEffect, useState } from "react";
import { SkillDetailPage } from "../../components/SkillDetailPage";
import { buildSkillMeta } from "../../lib/og";
import { isOwnerRouteHandleOrIdSegment, isOwnerRouteScopeSegment } from "../../lib/ownerRoute";
import {
buildSkillDetailHref,
isOwnerRouteHandleOrIdSegment,
isOwnerRouteScopeSegment,
} from "../../lib/ownerRoute";
import { consumePostPublishFlash } from "../../lib/postPublishFlash";
import { fetchSkillPageData } from "../../lib/skillPage";
import { resolveOpenClawPluginSlug } from "../../lib/slugRoute";
@@ -47,71 +51,96 @@ export const Route = createFileRoute("/$owner/$slug")({
if (params.owner.startsWith("@")) throw notFound();
const data = await fetchSkillPageData(params.slug, params.owner);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
throw redirect({
to: "/$owner/$slug",
params: { owner: canonicalOwner, slug: canonicalSlug },
replace: true,
});
}
return {
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
initialData: data.initialData,
};
},
head: ({ params, loaderData }) => {
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
throw redirect({
href: buildSkillDetailHref(params.owner, params.slug),
replace: true,
});
return {
links: [
{
rel: "canonical",
href: meta.url,
},
],
meta: [
{ title: meta.title },
{ name: "description", content: meta.description },
{ property: "og:title", content: meta.title },
{ property: "og:description", content: meta.description },
{ property: "og:type", content: "website" },
{ property: "og:url", content: meta.url },
{ property: "og:image", content: meta.image },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ property: "og:image:alt", content: meta.title },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: meta.title },
{ name: "twitter:description", content: meta.description },
{ name: "twitter:image", content: meta.image },
{ name: "twitter:image:alt", content: meta.title },
],
};
},
component: OwnerSkill,
});
function OwnerSkill() {
const { owner, slug } = Route.useParams();
const search = Route.useSearch();
const { initialData } = Route.useLoaderData();
const navigate = useNavigate({ from: "/$owner/$slug" });
export async function loadSkillDetailRouteData(params: { owner: string; slug: string }) {
const data = await fetchSkillPageData(params.slug, params.owner);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
throw redirect({
to: "/$owner/skills/$slug",
params: { owner: canonicalOwner, slug: canonicalSlug },
replace: true,
});
}
return {
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
initialData: data.initialData,
};
}
export function skillDetailRouteHead({
params,
loaderData,
}: {
params: { owner: string; slug: string };
loaderData?: {
owner?: string | null;
displayName?: string | null;
summary?: string | null;
version?: string | null;
};
}) {
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
});
return {
links: [
{
rel: "canonical",
href: meta.url,
},
],
meta: [
{ title: meta.title },
{ name: "description", content: meta.description },
{ property: "og:title", content: meta.title },
{ property: "og:description", content: meta.description },
{ property: "og:type", content: "website" },
{ property: "og:url", content: meta.url },
{ property: "og:image", content: meta.image },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ property: "og:image:alt", content: meta.title },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: meta.title },
{ name: "twitter:description", content: meta.description },
{ name: "twitter:image", content: meta.image },
{ name: "twitter:image:alt", content: meta.title },
],
};
}
export function SkillDetailRoutePage({
owner,
slug,
published,
initialData,
}: {
owner: string;
slug: string;
published?: true;
initialData: Awaited<ReturnType<typeof loadSkillDetailRouteData>>["initialData"];
}) {
const navigate = useNavigate();
const pathname = useRouterState({ select: (state) => state.location.pathname });
const searchStr = useRouterState({ select: (state) => state.location.searchStr });
const hasPublishedSearch = isPostPublishFlag(search.published) || hasPostPublishSearch(searchStr);
const hasPublishedSearch = isPostPublishFlag(published) || hasPostPublishSearch(searchStr);
const [showPostPublishSuccess, setShowPostPublishSuccess] = useState(() =>
hasPublishedSearch ? true : consumePostPublishFlash(owner, slug),
);
@@ -123,7 +152,7 @@ function OwnerSkill() {
}
if (hasPublishedSearch) {
void navigate({
to: "/$owner/$slug",
to: "/$owner/skills/$slug",
params: { owner, slug },
search: {},
replace: true,
@@ -147,7 +176,7 @@ function OwnerSkill() {
onDismissPostPublish={() => {
setShowPostPublishSuccess(false);
void navigate({
to: "/$owner/$slug",
to: "/$owner/skills/$slug",
params: { owner, slug },
search: {},
replace: true,
+73 -47
View File
@@ -6,7 +6,11 @@ import {
SecurityAuditPageSkeleton,
} from "../../../components/SecurityAuditPage";
import { buildSkillMeta } from "../../../lib/og";
import { isOwnerRouteHandleOrIdSegment } from "../../../lib/ownerRoute";
import {
buildSkillDetailHref,
buildSkillSecurityAuditHref,
isOwnerRouteHandleOrIdSegment,
} from "../../../lib/ownerRoute";
import { isModerator } from "../../../lib/roles";
import { fetchSkillPageData } from "../../../lib/skillPage";
import { useAuthStatus } from "../../../lib/useAuthStatus";
@@ -14,55 +18,77 @@ import { useAuthStatus } from "../../../lib/useAuthStatus";
export const Route = createFileRoute("/$owner/$slug/security-audit")({
beforeLoad: ({ params }) => {
if (!isOwnerRouteHandleOrIdSegment(params.owner)) throw notFound();
},
loader: async ({ params }) => {
const data = await fetchSkillPageData(params.slug, params.owner);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
throw redirect({
to: "/$owner/$slug/security-audit",
params: {
owner: canonicalOwner,
slug: canonicalSlug,
},
replace: true,
});
}
return {
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
initialData: data.initialData,
};
},
head: ({ params, loaderData }) => {
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
throw redirect({
href: buildSkillSecurityAuditHref(params.owner, params.slug),
replace: true,
});
return {
meta: [
{ title: `Security audit · ${meta.title}` },
{
name: "description",
content: `Security audit details for ${loaderData?.displayName ?? params.slug}.`,
},
],
};
},
component: SkillSecurityAuditRoute,
});
function SkillSecurityAuditRoute() {
const { owner, slug } = Route.useParams();
const { initialData } = Route.useLoaderData();
export async function loadSkillSecurityAuditRouteData(params: { owner: string; slug: string }) {
const data = await fetchSkillPageData(params.slug, params.owner);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
throw redirect({
to: "/$owner/skills/$slug/security-audit",
params: {
owner: canonicalOwner,
slug: canonicalSlug,
},
replace: true,
});
}
return {
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
initialData: data.initialData,
};
}
export function skillSecurityAuditRouteHead({
params,
loaderData,
}: {
params: { owner: string; slug: string };
loaderData?: {
owner?: string | null;
displayName?: string | null;
summary?: string | null;
version?: string | null;
};
}) {
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
});
return {
meta: [
{ title: `Security audit · ${meta.title}` },
{
name: "description",
content: `Security audit details for ${loaderData?.displayName ?? params.slug}.`,
},
],
};
}
export function SkillSecurityAuditRoutePage({
owner,
slug,
initialData,
}: {
owner: string;
slug: string;
initialData: Awaited<ReturnType<typeof loadSkillSecurityAuditRouteData>>["initialData"];
}) {
const liveLookupOwnerHandle =
initialData && "lookupOwnerHandle" in initialData ? initialData.lookupOwnerHandle : owner;
const liveResult = useQuery(
@@ -116,7 +142,7 @@ function SkillSecurityAuditRoute() {
owner: result?.owner ?? null,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId ?? null,
detailPath: `/${encodeURIComponent(ownerSegment)}/${encodeURIComponent(slug)}`,
detailPath: buildSkillDetailHref(ownerSegment, slug),
}}
sha256hash={latestVersion?.sha256hash ?? null}
vtAnalysis={latestVersion?.vtAnalysis ?? null}
@@ -1,15 +1,14 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { isOwnerRouteHandleOrIdSegment } from "../../../../lib/ownerRoute";
import {
buildSkillSecurityAuditHref,
isOwnerRouteHandleOrIdSegment,
} from "../../../../lib/ownerRoute";
export const Route = createFileRoute("/$owner/$slug/security/$scanner")({
beforeLoad: ({ params }) => {
if (!isOwnerRouteHandleOrIdSegment(params.owner)) throw notFound();
throw redirect({
to: "/$owner/$slug/security-audit",
params: {
owner: params.owner,
slug: params.slug,
},
href: buildSkillSecurityAuditHref(params.owner, params.slug),
replace: true,
});
},
+64 -43
View File
@@ -1,7 +1,7 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { SkillDetailPage } from "../../../components/SkillDetailPage";
import { buildSkillMeta } from "../../../lib/og";
import { isOwnerRouteHandleOrIdSegment } from "../../../lib/ownerRoute";
import { buildSkillSettingsHref, isOwnerRouteHandleOrIdSegment } from "../../../lib/ownerRoute";
import { fetchSkillPageData } from "../../../lib/skillPage";
export const Route = createFileRoute("/$owner/$slug/settings")({
@@ -9,53 +9,74 @@ export const Route = createFileRoute("/$owner/$slug/settings")({
if (!isOwnerRouteHandleOrIdSegment(params.owner)) {
throw notFound();
}
},
loader: async ({ params }) => {
const data = await fetchSkillPageData(params.slug, params.owner);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
throw redirect({
to: "/$owner/$slug/settings",
params: { owner: canonicalOwner, slug: canonicalSlug },
replace: true,
});
}
return {
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
initialData: data.initialData,
};
},
head: ({ params, loaderData }) => {
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
throw redirect({
href: buildSkillSettingsHref(params.owner, params.slug),
replace: true,
});
return {
meta: [
{ title: `Settings · ${meta.title}` },
{
name: "description",
content: `Owner settings for ${loaderData?.displayName ?? params.slug}.`,
},
],
};
},
component: SkillSettingsRoute,
});
function SkillSettingsRoute() {
const { owner, slug } = Route.useParams();
const { initialData } = Route.useLoaderData();
export async function loadSkillSettingsRouteData(params: { owner: string; slug: string }) {
const data = await fetchSkillPageData(params.slug, params.owner);
const canonicalOwner = data.initialData?.result?.owner?.handle ?? null;
const canonicalSlug = data.initialData?.result?.resolvedSlug ?? params.slug;
if (canonicalOwner && (canonicalOwner !== params.owner || canonicalSlug !== params.slug)) {
throw redirect({
to: "/$owner/skills/$slug/settings",
params: { owner: canonicalOwner, slug: canonicalSlug },
replace: true,
});
}
return {
owner: data?.owner ?? params.owner,
displayName: data?.displayName ?? null,
summary: data?.summary ?? null,
version: data?.version ?? null,
initialData: data.initialData,
};
}
export function skillSettingsRouteHead({
params,
loaderData,
}: {
params: { owner: string; slug: string };
loaderData?: {
owner?: string | null;
displayName?: string | null;
summary?: string | null;
version?: string | null;
};
}) {
const meta = buildSkillMeta({
slug: params.slug,
owner: loaderData?.owner ?? params.owner,
displayName: loaderData?.displayName,
summary: loaderData?.summary,
version: loaderData?.version ?? null,
});
return {
meta: [
{ title: `Settings · ${meta.title}` },
{
name: "description",
content: `Owner settings for ${loaderData?.displayName ?? params.slug}.`,
},
],
};
}
export function SkillSettingsRoutePage({
owner,
slug,
initialData,
}: {
owner: string;
slug: string;
initialData: Awaited<ReturnType<typeof loadSkillSettingsRouteData>>["initialData"];
}) {
return (
<SkillDetailPage slug={slug} canonicalOwner={owner} initialData={initialData} mode="settings" />
);
+67
View File
@@ -0,0 +1,67 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import {
buildPluginDetailHref,
packageNameFromPublisherPluginRoute,
} from "../../../lib/pluginRoutes";
import {
loadPluginDetail,
PluginDetailPage,
PluginDetailPending,
pluginDetailHead,
type PluginDetailLoaderData,
} from "../../plugins/$name";
function packageNameFromParams(params: { owner: string; slug: string }) {
const packageName = packageNameFromPublisherPluginRoute(params.owner, params.slug);
if (!packageName) throw notFound();
return packageName;
}
async function loadPublisherPluginDetail(params: {
owner: string;
slug: string;
}): Promise<PluginDetailLoaderData> {
const scopedName = packageNameFromParams(params);
const scopedData = await loadPluginDetail(scopedName);
if (scopedData.detail.package) return scopedData;
const unscopedData = await loadPluginDetail(params.slug);
if (unscopedData.detail.package?.name && unscopedData.detail.owner?.handle === params.owner) {
return unscopedData;
}
return scopedData;
}
export const Route = createFileRoute("/$owner/plugins/$slug")({
beforeLoad: ({ params }) => {
packageNameFromParams(params);
},
loader: async ({ params }) => {
const data = await loadPublisherPluginDetail(params);
const ownerHandle = data.detail.owner?.handle ?? params.owner;
const packageName = data.detail.package?.name ?? packageNameFromParams(params);
const canonicalHref = buildPluginDetailHref(packageName, { ownerHandle });
if (canonicalHref !== buildPluginDetailHref(packageNameFromParams(params))) {
throw redirect({
href: canonicalHref,
replace: true,
});
}
return data;
},
head: ({ params, loaderData }) =>
pluginDetailHead(loaderData?.detail.package?.name ?? packageNameFromParams(params), loaderData),
pendingComponent: PluginDetailPending,
component: PublisherPluginDetailRoute,
});
function PublisherPluginDetailRoute() {
const params = Route.useParams();
const loaderData = Route.useLoaderData() as PluginDetailLoaderData;
const packageName = loaderData.detail.package?.name ?? packageNameFromParams(params);
return <PluginDetailPage name={packageName} loaderData={loaderData} />;
}
@@ -0,0 +1,68 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import {
buildPluginSecurityAuditHref,
packageNameFromPublisherPluginRoute,
} from "../../../../lib/pluginRoutes";
import {
loadPluginSecurityAudit,
PluginSecurityAuditPage,
pluginSecurityAuditHead,
type PluginSecurityAuditLoaderData,
} from "../../../plugins/$name/security-audit";
function packageNameFromParams(params: { owner: string; slug: string }) {
const packageName = packageNameFromPublisherPluginRoute(params.owner, params.slug);
if (!packageName) throw notFound();
return packageName;
}
async function loadPublisherPluginSecurityAudit(params: {
owner: string;
slug: string;
}): Promise<PluginSecurityAuditLoaderData> {
const scopedName = packageNameFromParams(params);
const scopedData = await loadPluginSecurityAudit(scopedName);
if (scopedData.detail.package) return scopedData;
const unscopedData = await loadPluginSecurityAudit(params.slug);
if (unscopedData.detail.package?.name && unscopedData.detail.owner?.handle === params.owner) {
return unscopedData;
}
return scopedData;
}
export const Route = createFileRoute("/$owner/plugins/$slug/security-audit")({
beforeLoad: ({ params }) => {
packageNameFromParams(params);
},
loader: async ({ params }) => {
const data = await loadPublisherPluginSecurityAudit(params);
const ownerHandle = data.detail.owner?.handle ?? params.owner;
const packageName = data.detail.package?.name ?? packageNameFromParams(params);
const canonicalHref = buildPluginSecurityAuditHref(packageName, { ownerHandle });
if (canonicalHref !== buildPluginSecurityAuditHref(packageNameFromParams(params))) {
throw redirect({
href: canonicalHref,
replace: true,
});
}
return data;
},
head: ({ params, loaderData }) =>
pluginSecurityAuditHead(
loaderData?.detail.package?.name ?? packageNameFromParams(params),
loaderData,
),
component: PublisherPluginSecurityAuditRoute,
});
function PublisherPluginSecurityAuditRoute() {
const params = Route.useParams();
const loaderData = Route.useLoaderData() as PluginSecurityAuditLoaderData;
const packageName = loaderData.detail.package?.name ?? packageNameFromParams(params);
return <PluginSecurityAuditPage name={packageName} loaderData={loaderData} />;
}
@@ -0,0 +1,16 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import {
buildPluginSecurityAuditHref,
packageNameFromPublisherPluginRoute,
} from "../../../../../lib/pluginRoutes";
export const Route = createFileRoute("/$owner/plugins/$slug/security/$scanner")({
beforeLoad: ({ params }) => {
const packageName = packageNameFromPublisherPluginRoute(params.owner, params.slug);
if (!packageName) throw notFound();
throw redirect({
href: buildPluginSecurityAuditHref(packageName),
statusCode: 308,
});
},
});
+43
View File
@@ -0,0 +1,43 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
loadSkillDetailRouteData,
SkillDetailRoutePage,
skillDetailRouteHead,
} from "../../$owner/$slug";
import { isOwnerRouteHandleOrIdSegment } from "../../../lib/ownerRoute";
function isPostPublishFlag(value: unknown) {
const normalized = typeof value === "string" ? value.trim().replace(/^"|"$/g, "") : value;
return normalized === "1" || normalized === "true" || normalized === 1 || normalized === true;
}
export const Route = createFileRoute("/$owner/skills/$slug")({
validateSearch: (search) => {
const parsed: { published?: true } = {};
if (isPostPublishFlag(search.published)) {
parsed.published = true;
}
return parsed;
},
beforeLoad: ({ params }) => {
if (!isOwnerRouteHandleOrIdSegment(params.owner)) throw notFound();
},
loader: async ({ params }) => loadSkillDetailRouteData(params),
head: ({ params, loaderData }) => skillDetailRouteHead({ params, loaderData }),
component: OwnerSkill,
});
function OwnerSkill() {
const { owner, slug } = Route.useParams();
const search = Route.useSearch();
const { initialData } = Route.useLoaderData();
return (
<SkillDetailRoutePage
owner={owner}
slug={slug}
published={search.published}
initialData={initialData}
/>
);
}
@@ -0,0 +1,23 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
loadSkillSecurityAuditRouteData,
SkillSecurityAuditRoutePage,
skillSecurityAuditRouteHead,
} from "../../../$owner/$slug/security-audit";
import { isOwnerRouteHandleOrIdSegment } from "../../../../lib/ownerRoute";
export const Route = createFileRoute("/$owner/skills/$slug/security-audit")({
beforeLoad: ({ params }) => {
if (!isOwnerRouteHandleOrIdSegment(params.owner)) throw notFound();
},
loader: async ({ params }) => loadSkillSecurityAuditRouteData(params),
head: ({ params, loaderData }) => skillSecurityAuditRouteHead({ params, loaderData }),
component: SkillSecurityAuditRoute,
});
function SkillSecurityAuditRoute() {
const { owner, slug } = Route.useParams();
const { initialData } = Route.useLoaderData();
return <SkillSecurityAuditRoutePage owner={owner} slug={slug} initialData={initialData} />;
}
@@ -0,0 +1,15 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import {
buildSkillSecurityAuditHref,
isOwnerRouteHandleOrIdSegment,
} from "../../../../../lib/ownerRoute";
export const Route = createFileRoute("/$owner/skills/$slug/security/$scanner")({
beforeLoad: ({ params }) => {
if (!isOwnerRouteHandleOrIdSegment(params.owner)) throw notFound();
throw redirect({
href: buildSkillSecurityAuditHref(params.owner, params.slug),
replace: true,
});
},
});
@@ -0,0 +1,23 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
loadSkillSettingsRouteData,
SkillSettingsRoutePage,
skillSettingsRouteHead,
} from "../../../$owner/$slug/settings";
import { isOwnerRouteHandleOrIdSegment } from "../../../../lib/ownerRoute";
export const Route = createFileRoute("/$owner/skills/$slug/settings")({
beforeLoad: ({ params }) => {
if (!isOwnerRouteHandleOrIdSegment(params.owner)) throw notFound();
},
loader: async ({ params }) => loadSkillSettingsRouteData(params),
head: ({ params, loaderData }) => skillSettingsRouteHead({ params, loaderData }),
component: SkillSettingsRoute,
});
function SkillSettingsRoute() {
const { owner, slug } = Route.useParams();
const { initialData } = Route.useLoaderData();
return <SkillSettingsRoutePage owner={owner} slug={slug} initialData={initialData} />;
}
+50 -1
View File
@@ -1,5 +1,7 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { buildPublisherMeta } from "../lib/og";
import { resolveTopLevelSlugRoute } from "../lib/slugRoute";
import { PublisherProfilePage } from "./user/$handle";
export const Route = createFileRoute("/$slug")({
loader: async ({ params }) => {
@@ -13,10 +15,57 @@ export const Route = createFileRoute("/$slug")({
});
}
if (target.kind === "publisher") {
return {
publisher: target.publisher,
};
}
throw redirect({
to: "/$owner/$slug",
to: "/$owner/skills/$slug",
params: { owner: target.owner, slug: target.slug },
replace: true,
});
},
head: ({ params, loaderData }) => {
if (!loaderData || !("publisher" in loaderData)) return {};
const publisher = loaderData.publisher;
const meta = buildPublisherMeta({
handle: publisher.handle ?? params.slug,
displayName: publisher.displayName,
bio: publisher.bio,
});
return {
meta: [
{ title: meta.title },
{ name: "description", content: meta.description },
{ property: "og:title", content: meta.title },
{ property: "og:description", content: meta.description },
{ property: "og:url", content: meta.url },
{ property: "og:image", content: meta.image },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ property: "og:image:alt", content: meta.title },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: meta.title },
{ name: "twitter:description", content: meta.description },
{ name: "twitter:image", content: meta.image },
],
links: [{ rel: "canonical", href: meta.url }],
};
},
component: TopLevelPublisherProfile,
});
function TopLevelPublisherProfile() {
const { slug } = Route.useParams();
const { publisher } = Route.useLoaderData() as {
publisher: NonNullable<Awaited<ReturnType<typeof resolveTopLevelSlugRoute>>> extends infer T
? T extends { kind: "publisher"; publisher: infer P }
? P
: never
: never;
};
return <PublisherProfilePage handle={publisher.handle ?? slug} loaderPublisher={publisher} />;
}
+2 -2
View File
@@ -11,6 +11,7 @@ import {
SheetHeader,
SheetTitle,
} from "../../components/ui/sheet";
import { buildPublisherProfileHref } from "../../lib/ownerRoute";
import {
formatRatio,
formatScore,
@@ -287,8 +288,7 @@ export function AbusePage({
{selectedPublisher ? (
<Link
className="pa-profile-link"
to="/p/$handle"
params={{ handle: selectedPublisher.handle }}
to={buildPublisherProfileHref(selectedPublisher.handle)}
>
<ExternalLink size={12} />
Profile
+3 -3
View File
@@ -29,7 +29,7 @@ export function DuplicatesPage({
<div className="management-dupe-head">
<div className="management-item-main">
<Link
to="/$owner/$slug"
to="/$owner/skills/$slug"
params={{
owner: resolveOwnerParam(
entry.owner?.handle ?? null,
@@ -51,7 +51,7 @@ export function DuplicatesPage({
<div className="management-actions">
<Button asChild>
<Link
to="/$owner/$slug"
to="/$owner/skills/$slug"
params={{
owner: resolveOwnerParam(
entry.owner?.handle ?? null,
@@ -80,7 +80,7 @@ export function DuplicatesPage({
<div className="management-actions">
<Button asChild>
<Link
to="/$owner/$slug"
to="/$owner/skills/$slug"
params={{
owner: resolveOwnerParam(
match.owner?.handle ?? null,
+4 -6
View File
@@ -2,6 +2,7 @@ import { Link } from "@tanstack/react-router";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { familyLabel } from "../../lib/packageLabels";
import { buildPluginDetailHref } from "../../lib/pluginRoutes";
import { formatTimestamp, type PluginByNameResult } from "./managementShared";
type PluginPackageId = NonNullable<NonNullable<PluginByNameResult>["package"]>["_id"];
@@ -75,13 +76,12 @@ export function PluginsPage({
const owner = selectedPlugin.owner;
const latestRelease = selectedPlugin.latestRelease;
const isHighlighted = Boolean(selectedPlugin.highlighted);
const pluginHref = buildPluginDetailHref(plugin.name, { ownerHandle: owner?.handle });
return (
<div key={plugin._id} className="management-item management-item-detail">
<div className="management-item-main">
<Link to="/plugins/$name" params={{ name: plugin.name }}>
{plugin.displayName}
</Link>
<Link to={pluginHref}>{plugin.displayName}</Link>
<div className="section-subtitle m-0">
{owner?.handle ? `@${owner.handle}` : "unknown owner"} ·{" "}
{familyLabel(plugin.family)} · v{latestRelease?.version ?? "—"} · updated{" "}
@@ -115,9 +115,7 @@ export function PluginsPage({
</div>
<div className="management-actions management-action-grid">
<Button asChild className="management-action-btn">
<Link to="/plugins/$name" params={{ name: plugin.name }}>
View
</Link>
<Link to={pluginHref}>View</Link>
</Button>
<Button
className="management-action-btn"
+1 -1
View File
@@ -50,7 +50,7 @@ export function RecentPushesPage({
{entry.skill ? (
<Button asChild>
<Link
to="/$owner/$slug"
to="/$owner/skills/$slug"
params={{
owner: resolveOwnerParam(
entry.owner?.handle ?? null,
+1 -1
View File
@@ -56,7 +56,7 @@ export function ReportsPage({
return (
<div key={skill._id} className="management-item">
<div className="management-item-main">
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
<Link to="/$owner/skills/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
{skill.displayName}
</Link>
<div className="section-subtitle m-0">
+5 -2
View File
@@ -163,7 +163,7 @@ export function SkillsPage({
return (
<div key={skill._id} className="management-item management-item-detail">
<div className="management-item-main">
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
<Link to="/$owner/skills/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
{skill.displayName}
</Link>
<div className="section-subtitle m-0">
@@ -355,7 +355,10 @@ export function SkillsPage({
</div>
<div className="management-actions management-action-grid">
<Button asChild className="management-action-btn">
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
<Link
to="/$owner/skills/$slug"
params={{ owner: ownerParam, slug: skill.slug }}
>
View
</Link>
</Button>
+2 -2
View File
@@ -1,10 +1,10 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { buildPublisherProfileHref } from "../../lib/ownerRoute";
export const Route = createFileRoute("/orgs/$handle")({
beforeLoad: ({ params }) => {
throw redirect({
to: "/user/$handle",
params: { handle: params.handle },
href: buildPublisherProfileHref(params.handle),
replace: true,
});
},
+2 -2
View File
@@ -1,10 +1,10 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { buildPublisherProfileHref } from "../../lib/ownerRoute";
export const Route = createFileRoute("/p/$handle")({
beforeLoad: ({ params }) => {
throw redirect({
to: "/user/$handle",
params: { handle: params.handle },
href: buildPublisherProfileHref(params.handle),
replace: true,
});
},
+23 -5
View File
@@ -65,6 +65,7 @@ import {
import { formatRetryDelay } from "../../lib/formatRetryDelay";
import { buildPluginMeta } from "../../lib/og";
import { getOpenClawPackageCandidateNames } from "../../lib/openClawExtensionSlugs";
import { buildPublisherProfileHref } from "../../lib/ownerRoute";
import {
fetchPackageDetail,
fetchPackageFile,
@@ -255,7 +256,20 @@ export const Route = createFileRoute("/plugins/$name")({
});
}
},
loader: async ({ params }) => loadPluginDetail(params.name),
loader: async ({ params }) => {
const data = await loadPluginDetail(params.name);
const ownerHandle = data.detail.owner?.handle ?? null;
const packageName = data.detail.package?.name ?? null;
if (packageName && ownerHandle) {
throw redirect({
href: buildPluginDetailHref(packageName, { ownerHandle }),
replace: true,
});
}
return data;
},
head: ({ params, loaderData }) => pluginDetailHead(params.name, loaderData),
pendingComponent: PluginDetailPending,
component: PluginDetailRoute,
@@ -810,12 +824,13 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
const latestRelease = version?.version ?? null;
const isDownloadBlocked =
pkg.scanStatus === "malicious" || latestRelease?.verification?.scanStatus === "malicious";
const skillInstallOwner = owner?.handle ?? pkg.ownerHandle ?? "owner";
const installSnippet =
pkg.family === "code-plugin"
? `openclaw plugins install clawhub:${pkg.name}`
: pkg.family === "bundle-plugin"
? `openclaw plugins install clawhub:${pkg.name}`
: `openclaw skills install ${pkg.name}`;
: `openclaw skills install @${skillInstallOwner.replace(/^@+/, "")}/${pkg.name}`;
const compatibility = latestRelease?.compatibility ?? pkg.compatibility;
const pluginManifestSummary = latestRelease?.pluginManifestSummary ?? null;
@@ -1044,7 +1059,7 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
);
const securitySummary = latestRelease ? (
<DetailSecuritySummary
auditHref={buildPluginSecurityAuditHref(name)}
auditHref={buildPluginSecurityAuditHref(name, { ownerHandle: owner?.handle })}
vtAnalysis={latestRelease.vtAnalysis ?? null}
llmAnalysis={latestRelease.llmAnalysis ?? null}
/>
@@ -1148,11 +1163,14 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
<nav className="skill-hero-breadcrumbs" aria-label="Plugin breadcrumbs">
<a href="/plugins">plugins</a>
<span aria-hidden="true">/</span>
<a href={owner?.handle ? `/user/${encodeURIComponent(owner.handle)}` : "#"}>
<a href={owner?.handle ? buildPublisherProfileHref(owner.handle) : "#"}>
{owner?.handle ?? owner?.displayName ?? "unknown"}
</a>
<span aria-hidden="true">/</span>
<a href={buildPluginDetailHref(pkg.name)} aria-current="page">
<a
href={buildPluginDetailHref(pkg.name, { ownerHandle: owner?.handle })}
aria-current="page"
>
{displayPluginPackageName(pkg.name)}
</a>
</nav>
+15 -2
View File
@@ -86,7 +86,20 @@ export const Route = createFileRoute("/plugins/$name/security-audit")({
});
}
},
loader: async ({ params }) => loadPluginSecurityAudit(params.name),
loader: async ({ params }) => {
const data = await loadPluginSecurityAudit(params.name);
const ownerHandle = data.detail.owner?.handle ?? null;
const packageName = data.detail.package?.name ?? null;
if (packageName && ownerHandle) {
throw redirect({
href: buildPluginSecurityAuditHref(packageName, { ownerHandle }),
replace: true,
});
}
return data;
},
head: ({ params, loaderData }) => pluginSecurityAuditHead(params.name, loaderData),
component: PluginSecurityAuditRoute,
});
@@ -143,7 +156,7 @@ export function PluginSecurityAuditPage({
owner: detail.owner ?? null,
ownerUserId: null,
ownerPublisherId: null,
detailPath: buildPluginDetailHref(name),
detailPath: buildPluginDetailHref(name, { ownerHandle: detail.owner?.handle }),
}}
sha256hash={release.artifact?.sha256 ?? null}
vtAnalysis={release.vtAnalysis ?? null}
+17 -4
View File
@@ -1,4 +1,4 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import {
loadPluginDetail,
PluginDetailPage,
@@ -6,7 +6,11 @@ import {
pluginDetailHead,
type PluginDetailLoaderData,
} from "../$name";
import { packageNameFromScopedRoute } from "../../../lib/pluginRoutes";
import {
buildPluginDetailHref,
buildPluginSecurityAuditHref,
packageNameFromScopedRoute,
} from "../../../lib/pluginRoutes";
function packageNameFromParams(params: { scope: string; name: string }) {
const packageName = packageNameFromScopedRoute(params.scope, params.name);
@@ -15,8 +19,17 @@ function packageNameFromParams(params: { scope: string; name: string }) {
}
export const Route = createFileRoute("/plugins/$scope/$name")({
beforeLoad: ({ params }) => {
packageNameFromParams(params);
beforeLoad: ({ location, params }) => {
const packageName = packageNameFromParams(params);
const legacySecurityPrefix = `/plugins/${params.scope}/${params.name}/security`;
const href = location.pathname.startsWith(legacySecurityPrefix)
? buildPluginSecurityAuditHref(packageName)
: buildPluginDetailHref(packageName);
throw redirect({
href,
statusCode: 308,
});
},
loader: async ({ params }) => loadPluginDetail(packageNameFromParams(params)),
head: ({ params, loaderData }) => pluginDetailHead(packageNameFromParams(params), loaderData),
@@ -1,11 +1,14 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import {
loadPluginSecurityAudit,
PluginSecurityAuditPage,
pluginSecurityAuditHead,
type PluginSecurityAuditLoaderData,
} from "../../$name/security-audit";
import { packageNameFromScopedRoute } from "../../../../lib/pluginRoutes";
import {
buildPluginSecurityAuditHref,
packageNameFromScopedRoute,
} from "../../../../lib/pluginRoutes";
function packageNameFromParams(params: { scope: string; name: string }) {
const packageName = packageNameFromScopedRoute(params.scope, params.name);
@@ -15,7 +18,10 @@ function packageNameFromParams(params: { scope: string; name: string }) {
export const Route = createFileRoute("/plugins/$scope/$name/security-audit")({
beforeLoad: ({ params }) => {
packageNameFromParams(params);
throw redirect({
href: buildPluginSecurityAuditHref(packageNameFromParams(params)),
statusCode: 308,
});
},
loader: async ({ params }) => loadPluginSecurityAudit(packageNameFromParams(params)),
head: ({ params, loaderData }) =>
+1 -1
View File
@@ -1803,7 +1803,7 @@ function GitHubSourceList({
>
<div className="min-w-0">
<Link
to="/$owner/$slug"
to="/$owner/skills/$slug"
params={{
owner: source.ownerPublisher?.handle ?? "",
slug: skill.slug,
+1 -1
View File
@@ -751,7 +751,7 @@ export function Upload() {
toast.success(`Published ${trimmedSlug}@${trimmedVersion}`);
}
void navigate({
to: "/$owner/$slug",
to: "/$owner/skills/$slug",
params: { owner: ownerParam, slug: trimmedSlug },
search: {},
});
+2 -2
View File
@@ -1,10 +1,10 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { buildPublisherProfileHref } from "../../lib/ownerRoute";
export const Route = createFileRoute("/u/$handle")({
beforeLoad: ({ params }) => {
throw redirect({
to: "/user/$handle",
params: { handle: params.handle },
href: buildPublisherProfileHref(params.handle),
replace: true,
});
},
+32 -11
View File
@@ -1,4 +1,4 @@
import { createFileRoute, Link, notFound } from "@tanstack/react-router";
import { createFileRoute, Link, notFound, redirect } from "@tanstack/react-router";
import { normalizeCatalogTopic } from "clawhub-schema";
import { usePaginatedQuery, useQuery } from "convex/react";
import { Building2, Download, Package, Star, Users, Wrench, type LucideIcon } from "lucide-react";
@@ -14,6 +14,7 @@ import { Card, CardContent } from "../../components/ui/card";
import { Skeleton } from "../../components/ui/skeleton";
import { formatCompactStat } from "../../lib/numberFormat";
import { buildPublisherMeta } from "../../lib/og";
import { buildPublisherProfileHref } from "../../lib/ownerRoute";
import type {
PublicPublisher,
PublicPublisherCatalogDisplay,
@@ -23,11 +24,14 @@ import type {
import { readPublicDownloadCount } from "../../lib/publicUser";
export const Route = createFileRoute("/user/$handle")({
beforeLoad: ({ params }) => {
throw redirect({
href: buildPublisherProfileHref(params.handle),
replace: true,
});
},
loader: async ({ params }) => {
const { convexHttp } = await import("../../convex/client");
const publisher = (await convexHttp.query(api.publishers.getProfileByHandle, {
handle: params.handle,
})) as PublicPublisherListItem | null;
const publisher = await loadPublisherProfile(params.handle);
if (!publisher) throw notFound();
return { publisher };
},
@@ -55,6 +59,13 @@ export const Route = createFileRoute("/user/$handle")({
component: PublisherProfile,
});
export async function loadPublisherProfile(handle: string) {
const { convexHttp } = await import("../../convex/client");
return (await convexHttp.query(api.publishers.getProfileByHandle, {
handle,
})) as PublicPublisherListItem | null;
}
type PublisherMemberResult = {
publisher: PublicPublisher | null;
members: Array<{
@@ -91,6 +102,16 @@ function PublisherProfile() {
const { publisher: loaderPublisher } = Route.useLoaderData() as {
publisher: PublicPublisherListItem;
};
return <PublisherProfilePage handle={handle} loaderPublisher={loaderPublisher} />;
}
export function PublisherProfilePage({
handle,
loaderPublisher,
}: {
handle: string;
loaderPublisher: PublicPublisherListItem;
}) {
const [catalogTab, setCatalogTab] = useState<ProfileCatalogTab>("skills");
const publishedKind: "skill" | "plugin" = catalogTab === "plugins" ? "plugin" : "skill";
const queriedPublisher = useQuery(api.publishers.getProfileByHandle, { handle }) as
@@ -208,8 +229,8 @@ function PublisherProfile() {
? visibleAffiliations.map((entry) => (
<Link
key={entry.publisher._id}
to="/user/$handle"
params={{ handle: entry.publisher.handle }}
to="/$slug"
params={{ slug: entry.publisher.handle }}
className="publisher-profile-affiliation-badge"
>
<MarketplaceIcon
@@ -283,8 +304,8 @@ function PublisherProfile() {
{affiliations.map((entry) => (
<Link
key={entry.publisher._id}
to="/user/$handle"
params={{ handle: entry.publisher.handle }}
to="/$slug"
params={{ slug: entry.publisher.handle }}
className="publisher-profile-org"
>
<MarketplaceIcon
@@ -317,8 +338,8 @@ function PublisherProfile() {
{members?.members.map((entry) => (
<Link
key={`${entry.user._id}:${entry.role}`}
to="/user/$handle"
params={{ handle: entry.user.handle ?? publisher.handle }}
to="/$slug"
params={{ slug: entry.user.handle ?? publisher.handle }}
className="publisher-profile-member"
>
<MarketplaceIcon