mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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,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}`}
|
||||
>
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
label: "Publishers",
|
||||
to: PublicRegistryPaths.publishers,
|
||||
activePathPrefixes: ["/user/"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+4
-4
@@ -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
@@ -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)}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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" />
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user