mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: align skills.sh catalog presentation (#3370)
* feat: align skills.sh catalog presentation * refactor: share skill detail shell with skills.sh * test: seed skills.sh route fixtures locally * refactor: share full skill detail page view * feat: refine skills.sh detail presentation * feat: refine skills.sh detail metadata * fix: refine skills.sh detail spacing
This commit is contained in:
@@ -176,7 +176,7 @@ function seedSkillArgs(storageId: string) {
|
||||
}
|
||||
|
||||
describe("devSeed local fixtures", () => {
|
||||
it("idempotently seeds an activated external row for local canonical search proof", async () => {
|
||||
it("idempotently seeds an installable skills.sh route for local browser proof", async () => {
|
||||
const { db, tables } = createDb();
|
||||
|
||||
await seedCanonicalSearchFixtureHandler(createMutationCtx(db) as never, {});
|
||||
@@ -184,14 +184,50 @@ describe("devSeed local fixtures", () => {
|
||||
|
||||
expect(tables.skillsShMirrorRuns).toHaveLength(1);
|
||||
expect(tables.skillsShMirrorDigests).toHaveLength(1);
|
||||
expect(tables.skillsShMirrorDetails).toHaveLength(1);
|
||||
expect(tables.skillsShCatalogControls).toHaveLength(1);
|
||||
expect(tables.skillsShCatalogControls?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
key: "global",
|
||||
mode: "fixture",
|
||||
mirrorPublicVisibilityEnabled: true,
|
||||
writesEnabled: false,
|
||||
scanPlanningEnabled: false,
|
||||
scanAdmissionEnabled: false,
|
||||
}),
|
||||
);
|
||||
expect(tables.skillsShMirrorDigests?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
externalId: "acme/skills/risk-auditor",
|
||||
searchSummary: "Audit agent workflows for security and operational risk.",
|
||||
externalId: "doany-skills/skills/reddit-automation",
|
||||
owner: "doany-skills",
|
||||
repo: "skills",
|
||||
slug: "reddit-automation",
|
||||
displayName: "Reddit Automation",
|
||||
upstreamInstalls: 202_996,
|
||||
active: true,
|
||||
publicVisible: true,
|
||||
installable: true,
|
||||
sourceFreshnessStatus: "observed-only",
|
||||
detailStatus: "available",
|
||||
githubPath: "reddit-automation",
|
||||
githubCommit: "6875ced8582825395c976099fcc6a00734bb09b1",
|
||||
sourceContentHash: "278abced163b5721c6fec6996f73d521c8901b905b4b2fca45757d1ff0ebbfc6",
|
||||
}),
|
||||
);
|
||||
expect(tables.skillsShMirrorDetails?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
externalId: "doany-skills/skills/reddit-automation",
|
||||
contentKind: "skill-md",
|
||||
path: "SKILL.md",
|
||||
truncated: false,
|
||||
sourceContentHash: "278abced163b5721c6fec6996f73d521c8901b905b4b2fca45757d1ff0ebbfc6",
|
||||
}),
|
||||
);
|
||||
expect(tables.skillsShMirrorDetails?.[0]?.content).toContain("# Reddit Automation");
|
||||
expect(tables.skillsShMirrorRuns?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "completed",
|
||||
counts: expect.objectContaining({ scansPlanned: 0, scansAdmitted: 0 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { convexTest } from "convex-test";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { api, internal } from "./_generated/api";
|
||||
import schema from "./schema";
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CLAWHUB_ENV", "local");
|
||||
vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "test");
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it("serves the seeded skills.sh detail through the public local route", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
await t.mutation(internal.devSeed.seedCanonicalSearchFixture, {});
|
||||
|
||||
await expect(
|
||||
t.query(api.skillsShMirrorPublic.getByRoute, {
|
||||
owner: "doany-skills",
|
||||
repo: "skills",
|
||||
slug: "reddit-automation",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
kind: "external",
|
||||
entry: {
|
||||
externalId: "doany-skills/skills/reddit-automation",
|
||||
displayName: "Reddit Automation",
|
||||
upstreamInstalls: 202_996,
|
||||
githubPath: "reddit-automation",
|
||||
githubCommit: "6875ced8582825395c976099fcc6a00734bb09b1",
|
||||
content: {
|
||||
kind: "skill-md",
|
||||
path: "SKILL.md",
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
+139
-34
@@ -929,21 +929,85 @@ export const seedTestFixtures: ReturnType<typeof internalAction> = internalActio
|
||||
},
|
||||
});
|
||||
|
||||
const LOCAL_CANONICAL_SEARCH_EXTERNAL_ID = "acme/skills/risk-auditor";
|
||||
const LOCAL_SKILLS_SH_EXTERNAL_ID = "doany-skills/skills/reddit-automation";
|
||||
const LOCAL_SKILLS_SH_SNAPSHOT_ID = "local-skills-sh-route-v1";
|
||||
const LOCAL_SKILLS_SH_COMMIT = "6875ced8582825395c976099fcc6a00734bb09b1";
|
||||
const LOCAL_SKILLS_SH_CONTENT_HASH =
|
||||
"278abced163b5721c6fec6996f73d521c8901b905b4b2fca45757d1ff0ebbfc6";
|
||||
const LOCAL_SKILLS_SH_CONTENT = `---
|
||||
name: reddit-automation
|
||||
displayName: Reddit Automation
|
||||
description: Find relevant Reddit conversations and draft genuinely useful, disclosed replies.
|
||||
---
|
||||
|
||||
/** Explicit local proof fixture; intentionally not part of shared Test seeding. */
|
||||
# Reddit Automation
|
||||
|
||||
Find people on Reddit who genuinely need what you make, then draft a useful response that
|
||||
honestly discloses who you are. Keep a human in the loop to review and post every reply.
|
||||
|
||||
## When to use
|
||||
|
||||
- Find Reddit conversations relevant to a product.
|
||||
- Draft a helpful response for a thread the user provides.
|
||||
- Turn a set of research notes into replies for human review.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never auto-post or pretend a draft was published.
|
||||
- Never invent posts, quotes, or product facts.
|
||||
- Respect each community's self-promotion rules.
|
||||
`;
|
||||
|
||||
/** Explicit local/PR-preview fixture; intentionally not part of shared Test seeding. */
|
||||
export const seedCanonicalSearchFixture = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const now = Date.now();
|
||||
const existingControl = await ctx.db
|
||||
.query("skillsShCatalogControls")
|
||||
.withIndex("by_key", (q) => q.eq("key", "global"))
|
||||
.unique();
|
||||
const control = {
|
||||
key: "global" as const,
|
||||
mode: "fixture" as const,
|
||||
discoveryEnabled: false,
|
||||
writesEnabled: false,
|
||||
scanPlanningEnabled: false,
|
||||
scanAdmissionEnabled: false,
|
||||
publicVisibilityEnabled: false,
|
||||
mirrorPublicVisibilityEnabled: true,
|
||||
paused: false,
|
||||
maxEntriesPerRun: 1,
|
||||
maxEntriesPerBatch: 1,
|
||||
maxWritesPerBatch: 1,
|
||||
maxPlannedScans: 0,
|
||||
maxScanAdmissionsPerBatch: 0,
|
||||
maxScanAdmissionsPerRun: 0,
|
||||
maxScanAdmissionsPerDay: 0,
|
||||
maxCatalogQueued: 0,
|
||||
maxCatalogInFlight: 0,
|
||||
maxNativeQueued: 0,
|
||||
maxNativeInFlight: 0,
|
||||
realScanAllowlist: [],
|
||||
updatedBy: "local-dev-seed",
|
||||
reason: "Expose the local skills.sh browser fixture without enabling imports or scans.",
|
||||
updatedAt: now,
|
||||
};
|
||||
if (existingControl) {
|
||||
await ctx.db.patch(existingControl._id, control);
|
||||
} else {
|
||||
await ctx.db.insert("skillsShCatalogControls", control);
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("skillsShMirrorDigests")
|
||||
.withIndex("by_external_id", (q) => q.eq("externalId", LOCAL_CANONICAL_SEARCH_EXTERNAL_ID))
|
||||
.withIndex("by_external_id", (q) => q.eq("externalId", LOCAL_SKILLS_SH_EXTERNAL_ID))
|
||||
.unique();
|
||||
const runId =
|
||||
existing?.lastObservedRunId ??
|
||||
(await ctx.db.insert("skillsShMirrorRuns", {
|
||||
snapshotId: "local-canonical-search-v1",
|
||||
snapshotId: LOCAL_SKILLS_SH_SNAPSHOT_ID,
|
||||
sourceView: "leaderboard",
|
||||
status: "completed",
|
||||
sourceTotal: 1,
|
||||
sourcePageSize: 1,
|
||||
@@ -959,10 +1023,10 @@ export const seedCanonicalSearchFixture = internalMutation({
|
||||
quarantined: 0,
|
||||
quarantinedPreserved: 0,
|
||||
conflicts: 0,
|
||||
detailsInserted: 0,
|
||||
detailsInserted: 1,
|
||||
detailsUpdated: 0,
|
||||
detailsUnchanged: 0,
|
||||
detailsMissing: 1,
|
||||
detailsMissing: 0,
|
||||
detailsTruncated: 0,
|
||||
tombstoned: 0,
|
||||
reactivated: 0,
|
||||
@@ -977,42 +1041,53 @@ export const seedCanonicalSearchFixture = internalMutation({
|
||||
sourceBytes: 0,
|
||||
},
|
||||
actor: "local-dev-seed",
|
||||
reason: "Reusable local canonical mixed-search browser proof fixture.",
|
||||
reason: "Reusable local skills.sh route browser proof fixture.",
|
||||
startedAt: now,
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
const digest = {
|
||||
externalId: LOCAL_CANONICAL_SEARCH_EXTERNAL_ID,
|
||||
externalId: LOCAL_SKILLS_SH_EXTERNAL_ID,
|
||||
sourceType: "github" as const,
|
||||
upstreamSourceType: "github",
|
||||
owner: "acme",
|
||||
owner: "doany-skills",
|
||||
repo: "skills",
|
||||
slug: "risk-auditor",
|
||||
normalizedSlug: "risk auditor",
|
||||
normalizedSlugFirstToken: "risk",
|
||||
displayName: "Risk Auditor",
|
||||
normalizedDisplayName: "risk auditor",
|
||||
normalizedDisplayNameFirstToken: "risk",
|
||||
searchSummary: "Audit agent workflows for security and operational risk.",
|
||||
slug: "reddit-automation",
|
||||
normalizedSlug: "reddit automation",
|
||||
normalizedSlugFirstToken: "reddit",
|
||||
displayName: "Reddit Automation",
|
||||
normalizedDisplayName: "reddit automation",
|
||||
normalizedDisplayNameFirstToken: "reddit",
|
||||
searchSummary:
|
||||
"Find relevant Reddit conversations and draft genuinely useful, disclosed replies.",
|
||||
searchText:
|
||||
"Risk Auditor risk-auditor acme skills security risk-management security-audit Audit agent workflows for security and operational risk.",
|
||||
sourceUrl: "https://skills.sh/acme/skills/risk-auditor",
|
||||
canonicalRepoUrl: "https://github.com/acme/skills",
|
||||
githubPath: "skills/risk-auditor",
|
||||
githubCommit: "0000000000000000000000000000000000000000",
|
||||
upstreamInstalls: 9_000_000,
|
||||
"Reddit Automation reddit-automation doany-skills skills automation reddit marketing Find relevant Reddit conversations and draft genuinely useful disclosed replies.",
|
||||
sourceUrl: `https://www.skills.sh/${LOCAL_SKILLS_SH_EXTERNAL_ID}`,
|
||||
canonicalRepoUrl: "https://github.com/doany-skills/skills",
|
||||
githubPath: "reddit-automation",
|
||||
githubCommit: LOCAL_SKILLS_SH_COMMIT,
|
||||
sourceContentHash: LOCAL_SKILLS_SH_CONTENT_HASH,
|
||||
upstreamInstalls: 202_996,
|
||||
upstreamScanners: {
|
||||
genAgentTrustHub: { status: "unavailable" },
|
||||
socket: { status: "unavailable" },
|
||||
snyk: { status: "unavailable" },
|
||||
genAgentTrustHub: {
|
||||
status: "pass",
|
||||
sourceUrl: `https://www.skills.sh/${LOCAL_SKILLS_SH_EXTERNAL_ID}/security/agent-trust-hub`,
|
||||
},
|
||||
socket: {
|
||||
status: "warn",
|
||||
sourceUrl: `https://www.skills.sh/${LOCAL_SKILLS_SH_EXTERNAL_ID}/security/socket`,
|
||||
},
|
||||
snyk: {
|
||||
status: "warn",
|
||||
sourceUrl: `https://www.skills.sh/${LOCAL_SKILLS_SH_EXTERNAL_ID}/security/snyk`,
|
||||
},
|
||||
},
|
||||
inferredCategories: ["security"],
|
||||
inferredTopics: ["risk-management", "security-audit"],
|
||||
inferredCategories: ["automation"],
|
||||
inferredTopics: ["reddit", "marketing"],
|
||||
sourceFreshnessStatus: "observed-only" as const,
|
||||
detailStatus: "missing" as const,
|
||||
observationFingerprint: "local-canonical-search-v1",
|
||||
sourceSnapshotId: "local-canonical-search-v1",
|
||||
detailStatus: "available" as const,
|
||||
observationFingerprint: LOCAL_SKILLS_SH_CONTENT_HASH,
|
||||
sourceSnapshotId: LOCAL_SKILLS_SH_SNAPSHOT_ID,
|
||||
lastObservedRunId: runId,
|
||||
active: true,
|
||||
publicVisible: true,
|
||||
@@ -1022,15 +1097,45 @@ export const seedCanonicalSearchFixture = internalMutation({
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
let digestId: Id<"skillsShMirrorDigests">;
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, digest);
|
||||
return { ok: true as const, digestId: existing._id };
|
||||
digestId = existing._id;
|
||||
} else {
|
||||
digestId = await ctx.db.insert("skillsShMirrorDigests", {
|
||||
...digest,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
const digestId = await ctx.db.insert("skillsShMirrorDigests", {
|
||||
...digest,
|
||||
|
||||
const existingDetail = await ctx.db
|
||||
.query("skillsShMirrorDetails")
|
||||
.withIndex("by_external_id", (q) => q.eq("externalId", LOCAL_SKILLS_SH_EXTERNAL_ID))
|
||||
.unique();
|
||||
const detail = {
|
||||
externalId: LOCAL_SKILLS_SH_EXTERNAL_ID,
|
||||
digestId,
|
||||
contentKind: "skill-md" as const,
|
||||
path: "SKILL.md",
|
||||
content: LOCAL_SKILLS_SH_CONTENT,
|
||||
contentBytes: new TextEncoder().encode(LOCAL_SKILLS_SH_CONTENT).byteLength,
|
||||
sourceBytes: new TextEncoder().encode(LOCAL_SKILLS_SH_CONTENT).byteLength,
|
||||
sourceFileCount: 1,
|
||||
truncated: false,
|
||||
sourceContentHash: LOCAL_SKILLS_SH_CONTENT_HASH,
|
||||
sourceSnapshotId: LOCAL_SKILLS_SH_SNAPSHOT_ID,
|
||||
lastObservedRunId: runId,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (existingDetail) {
|
||||
await ctx.db.patch(existingDetail._id, detail);
|
||||
return { ok: true as const, digestId, detailId: existingDetail._id };
|
||||
}
|
||||
const detailId = await ctx.db.insert("skillsShMirrorDetails", {
|
||||
...detail,
|
||||
createdAt: now,
|
||||
});
|
||||
return { ok: true as const, digestId };
|
||||
return { ok: true as const, digestId, detailId };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ describe("dev-worktree helpers", () => {
|
||||
expect(byName.DEV_AUTH_CONVEX_DEPLOYMENT).toBe("anonymous:anonymous-agent");
|
||||
expect(byName.SECURITY_SCAN_WORKER_TOKEN).toBe("local-dev-worker-token");
|
||||
expect(byName.SECURITY_SCAN_DEFAULT_VT_WAIT_MS).toBe("0");
|
||||
expect(byName.CLAWHUB_SKILLS_SH_ROLLOUT_MODE).toBe("test");
|
||||
expect(byName.AUTH_GITHUB_ID).toBe("local-dev");
|
||||
expect(byName.AUTH_GITHUB_SECRET).toBe("local-dev");
|
||||
expect(byName.JWT_PRIVATE_KEY).toContain("BEGIN PRIVATE KEY");
|
||||
@@ -147,6 +148,16 @@ describe("dev-worktree helpers", () => {
|
||||
expect(byName.CONVEX_SITE_URL).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves an explicit local skills.sh rollout override", () => {
|
||||
const changes = buildLocalConvexEnvChanges({
|
||||
CONVEX_DEPLOYMENT: "anonymous:anonymous-agent",
|
||||
CLAWHUB_SKILLS_SH_ROLLOUT_MODE: "off",
|
||||
});
|
||||
const byName = Object.fromEntries(changes.map((change) => [change.name, change.value]));
|
||||
|
||||
expect(byName.CLAWHUB_SKILLS_SH_ROLLOUT_MODE).toBe("off");
|
||||
});
|
||||
|
||||
it("does not apply local dev auth overrides for cloud Convex URLs", () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
SECURITY_SCAN_WORKER_TOKEN: "remote-worker-token",
|
||||
|
||||
@@ -139,6 +139,10 @@ export function buildLocalConvexEnvChanges(env: NodeJS.ProcessEnv) {
|
||||
{ name: "DEV_AUTH_CONVEX_DEPLOYMENT", value: deployment },
|
||||
{ name: "SECURITY_SCAN_WORKER_TOKEN", value: LOCAL_DEV_WORKER_TOKEN },
|
||||
{ name: "SECURITY_SCAN_DEFAULT_VT_WAIT_MS", value: "0" },
|
||||
{
|
||||
name: "CLAWHUB_SKILLS_SH_ROLLOUT_MODE",
|
||||
value: env.CLAWHUB_SKILLS_SH_ROLLOUT_MODE?.trim() || "test",
|
||||
},
|
||||
{ name: "JWT_PRIVATE_KEY", value: authKeys.JWT_PRIVATE_KEY },
|
||||
{ name: "JWKS", value: authKeys.JWKS },
|
||||
{ name: "AUTH_GITHUB_ID", value: env.AUTH_GITHUB_ID?.trim() || "local-dev" },
|
||||
|
||||
@@ -8,6 +8,10 @@ describe("shared seed runner", () => {
|
||||
command: "bunx",
|
||||
args: ["convex", "run", "--no-push", "devSeed:seedLocalFixtures"],
|
||||
},
|
||||
{
|
||||
command: "bunx",
|
||||
args: ["convex", "run", "--no-push", "devSeed:seedCanonicalSearchFixture"],
|
||||
},
|
||||
{
|
||||
command: "bun",
|
||||
args: ["scripts/public-corpus/seed-public-corpus.ts"],
|
||||
@@ -29,6 +33,16 @@ describe("shared seed runner", () => {
|
||||
command: "bunx",
|
||||
args: ["convex", "run", "--preview-name", "feature/demo", "devSeed:seedLocalFixtures"],
|
||||
},
|
||||
{
|
||||
command: "bunx",
|
||||
args: [
|
||||
"convex",
|
||||
"run",
|
||||
"--preview-name",
|
||||
"feature/demo",
|
||||
"devSeed:seedCanonicalSearchFixture",
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "bun",
|
||||
args: ["scripts/public-corpus/seed-public-corpus.ts", "--preview-name", "feature/demo"],
|
||||
|
||||
@@ -42,6 +42,10 @@ export function buildSeedSteps(options: SeedOptions): SeedStep[] {
|
||||
command: "bunx",
|
||||
args: ["convex", "run", ...convexTargetArgs, "devSeed:seedLocalFixtures"],
|
||||
},
|
||||
{
|
||||
command: "bunx",
|
||||
args: ["convex", "run", ...convexTargetArgs, "devSeed:seedCanonicalSearchFixture"],
|
||||
},
|
||||
{
|
||||
command: "bun",
|
||||
args: ["scripts/public-corpus/seed-public-corpus.ts", ...corpusTargetArgs],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CanonicalTrendingItem } from "../lib/trendingApi";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const convexQueryMock = vi.fn();
|
||||
@@ -114,17 +115,50 @@ describe("HomeListingSection", () => {
|
||||
expect(screen.getByText("29")).toBeTruthy();
|
||||
expect(screen.queryByText("17")).toBeNull();
|
||||
expect(screen.queryByText("3")).toBeNull();
|
||||
expect(screen.getByText("24h downloads")).toBeTruthy();
|
||||
expect(screen.getAllByLabelText("24-hour downloads")).toHaveLength(2);
|
||||
expect(screen.getByText("Downloads")).toBeTruthy();
|
||||
expect(screen.getAllByLabelText("Downloads")).toHaveLength(2);
|
||||
expect(screen.queryByText("24h installs")).toBeNull();
|
||||
expect(screen.queryByLabelText("24-hour installs")).toBeNull();
|
||||
expect(screen.queryByText("9K")).toBeNull();
|
||||
expect(screen.queryByText("8K")).toBeNull();
|
||||
expect(screen.queryByText("skills.sh")).toBeNull();
|
||||
expect(document.querySelector(".home-v2-listing-row-icon")).toBeNull();
|
||||
expect(document.querySelector(".home-v2-listing-row-stats svg")).toBeNull();
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Grid view" })).toBeNull();
|
||||
});
|
||||
|
||||
it("identifies skills.sh rows by their source owner and upstream install count", () => {
|
||||
const external = {
|
||||
...makeTrending("reddit-automation", "reddit-automation", 0, 12_345, 0),
|
||||
id: "skills-sh:doany-skills/skills/reddit-automation",
|
||||
source: "skills-sh" as const,
|
||||
canonicalUrl: "/skills-sh/doany-skills/skills/reddit-automation",
|
||||
publisher: null,
|
||||
sourceIdentity: {
|
||||
id: "doany-skills/skills/reddit-automation",
|
||||
owner: "doany-skills",
|
||||
repo: "skills",
|
||||
host: null,
|
||||
lifetimeInstalls: 12_345,
|
||||
},
|
||||
metrics: {
|
||||
trending24hDownloads: null,
|
||||
trending24hInstalls: null,
|
||||
trending24hBookmarks: null,
|
||||
lifetimeInstalls: 12_345,
|
||||
lifetimeInstallsPeriod: "lifetime" as const,
|
||||
updatedAt: 1,
|
||||
},
|
||||
};
|
||||
|
||||
render(<HomeListingSection initialListing={initialTrending([external])} />);
|
||||
|
||||
expect(screen.getByText("@doany-skills")).toBeTruthy();
|
||||
expect(screen.getByText("skills.sh")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Downloads").textContent).toContain("12.3k");
|
||||
});
|
||||
|
||||
it("hides unavailable Trending and falls back to the Featured feed", async () => {
|
||||
render(<HomeListingSection initialListing={initialTrending([], false, "unavailable")} />);
|
||||
|
||||
@@ -273,7 +307,7 @@ describe("HomeListingSection", () => {
|
||||
});
|
||||
|
||||
function initialTrending(
|
||||
items: ReturnType<typeof makeTrending>[],
|
||||
items: CanonicalTrendingItem[],
|
||||
hasMore = false,
|
||||
trendingState: "available" | "empty" | "unavailable" = items.length ? "available" : "empty",
|
||||
) {
|
||||
@@ -288,7 +322,7 @@ function initialTrending(
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalPage(items: ReturnType<typeof makeTrending>[], nextCursor: string | null = null) {
|
||||
function canonicalPage(items: CanonicalTrendingItem[], nextCursor: string | null = null) {
|
||||
return {
|
||||
kind: "skills" as const,
|
||||
snapshotId: "snapshot-1",
|
||||
@@ -308,7 +342,7 @@ function makeTrending(
|
||||
installs: number,
|
||||
lifetime: number,
|
||||
downloads = installs,
|
||||
) {
|
||||
): CanonicalTrendingItem {
|
||||
return {
|
||||
id: `clawhub:${slug}`,
|
||||
source: "clawhub" as const,
|
||||
|
||||
@@ -167,9 +167,9 @@ describe("HomeListingSection", () => {
|
||||
expect(screen.queryByRole("button", { name: "Grid view" })).toBeNull();
|
||||
expect(screen.getByText("Demo Plugin")).toBeTruthy();
|
||||
expect(document.querySelector(".home-v2-listing-list")).toBeTruthy();
|
||||
expect(document.querySelector(".marketplace-icon-image")?.getAttribute("src")).toBe(
|
||||
featuredPlugin.icon,
|
||||
);
|
||||
expect(screen.getByText("Downloads")).toBeTruthy();
|
||||
expect(document.querySelector(".home-v2-listing-row-icon")).toBeNull();
|
||||
expect(document.querySelector(".home-v2-listing-row-stats svg")).toBeNull();
|
||||
});
|
||||
|
||||
it("previews long skill and plugin names while retaining their full labels", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Bookmark, CloudOff, Download, Loader2, Moon, Plus } from "lucide-react";
|
||||
import { CloudOff, Loader2, Moon, Plus } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchHomePluginListing as fetchPluginListing,
|
||||
@@ -20,7 +20,6 @@ import type { PackageListItem } from "../lib/packageApi";
|
||||
import { buildPluginDetailHref } from "../lib/pluginRoutes";
|
||||
import { presentationTitle } from "../lib/presentationTitle";
|
||||
import { PUBLIC_CATALOG_NAME_PREVIEW_LENGTH, truncateText } from "../lib/truncateText";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import { OfficialBadge } from "./OfficialBadge";
|
||||
import { BrowseResultsSkeleton } from "./skeletons/BrowseResultsSkeleton";
|
||||
|
||||
@@ -122,33 +121,38 @@ function skillLink(entry: HomeNativeSkillListingEntry) {
|
||||
return `/${encodeURIComponent(owner)}/${encodeURIComponent(entry.skill.slug)}`;
|
||||
}
|
||||
|
||||
function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; showStats: boolean }) {
|
||||
function HomeListingSkillRow({ entry }: { entry: SkillPageEntry }) {
|
||||
if (isHomeTrendingSkillEntry(entry)) {
|
||||
const item = entry.trending;
|
||||
const owner = item.publisher?.handle;
|
||||
const isSkillsSh = item.source === "skills-sh";
|
||||
const owner = isSkillsSh
|
||||
? (item.sourceIdentity?.owner ?? item.sourceIdentity?.host)
|
||||
: item.publisher?.handle;
|
||||
const upstreamInstalls = item.sourceIdentity?.lifetimeInstalls ?? item.metrics.lifetimeInstalls;
|
||||
return (
|
||||
<Link to={item.canonicalUrl} className="home-v2-listing-row">
|
||||
<span className="home-v2-listing-row-icon" aria-hidden="true">
|
||||
<MarketplaceIcon kind="skill" label={item.displayName} size="sm" />
|
||||
</span>
|
||||
<div className="home-v2-listing-row-body">
|
||||
<div className="home-v2-listing-row-title">
|
||||
<span className="home-v2-listing-row-name" title={item.displayName}>
|
||||
{truncateText(item.displayName, PUBLIC_CATALOG_NAME_PREVIEW_LENGTH)}
|
||||
</span>
|
||||
{isSkillsSh ? <span className="home-v2-listing-source-badge">skills.sh</span> : null}
|
||||
{owner ? <span className="home-v2-listing-row-by">@{owner}</span> : null}
|
||||
</div>
|
||||
<p className="home-v2-listing-row-summary">
|
||||
{truncateText(item.summary || "Agent-ready skill pack.", 80)}
|
||||
</p>
|
||||
</div>
|
||||
{typeof item.metrics.trending24hDownloads === "number" ? (
|
||||
<div className="home-v2-listing-row-stats" aria-label="24-hour downloads">
|
||||
<span>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{formatCompactStat(item.metrics.trending24hDownloads)}
|
||||
{isSkillsSh && typeof upstreamInstalls === "number" ? (
|
||||
<div className="home-v2-listing-row-stats" aria-label="Downloads">
|
||||
<span title={`${upstreamInstalls.toLocaleString()} skills.sh installs`}>
|
||||
{formatCompactStat(upstreamInstalls)}
|
||||
</span>
|
||||
</div>
|
||||
) : typeof item.metrics.trending24hDownloads === "number" ? (
|
||||
<div className="home-v2-listing-row-stats" aria-label="Downloads">
|
||||
<span>{formatCompactStat(item.metrics.trending24hDownloads)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
@@ -157,19 +161,7 @@ function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; show
|
||||
const name = presentationTitle(entry.skill.displayName, entry.skill.slug);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={skillLink(entry)}
|
||||
className={`home-v2-listing-row${showStats ? "" : " has-no-stats"}`}
|
||||
>
|
||||
<span className="home-v2-listing-row-icon" aria-hidden="true">
|
||||
<MarketplaceIcon
|
||||
kind="skill"
|
||||
label={name}
|
||||
imageUrl={entry.skill.icon}
|
||||
skill={entry.skill}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
<Link to={skillLink(entry)} className="home-v2-listing-row">
|
||||
<div className="home-v2-listing-row-body">
|
||||
<div className="home-v2-listing-row-title">
|
||||
<span className="home-v2-listing-row-name" title={name}>
|
||||
@@ -181,18 +173,9 @@ function HomeListingSkillRow({ entry, showStats }: { entry: SkillPageEntry; show
|
||||
{truncateText(entry.skill.summary || "Agent-ready skill pack.", 80)}
|
||||
</p>
|
||||
</div>
|
||||
{showStats ? (
|
||||
<div className="home-v2-listing-row-stats" aria-label="Popularity">
|
||||
<span>
|
||||
<Bookmark size={13} aria-hidden="true" />
|
||||
{formatCompactStat(entry.skill.stats?.stars ?? 0)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{formatCompactStat(entry.skill.stats?.downloads ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="home-v2-listing-row-stats" aria-label="Downloads">
|
||||
<span>{formatCompactStat(entry.skill.stats?.downloads ?? 0)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -203,15 +186,6 @@ function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) {
|
||||
|
||||
return (
|
||||
<Link to={pluginHref} className="home-v2-listing-row">
|
||||
<span className="home-v2-listing-row-icon" aria-hidden="true">
|
||||
<MarketplaceIcon
|
||||
kind="plugin"
|
||||
label={name}
|
||||
imageUrl={plugin.icon}
|
||||
categorySlug={plugin.categories?.[0]}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
<div className="home-v2-listing-row-body">
|
||||
<div className="home-v2-listing-row-title">
|
||||
<span className="home-v2-listing-row-name" title={name}>
|
||||
@@ -227,10 +201,7 @@ function HomeListingPluginRow({ plugin }: { plugin: PackageListItem }) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-v2-listing-row-stats" aria-label="Downloads">
|
||||
<span>
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{formatCompactStat(plugin.stats?.downloads ?? 0)}
|
||||
</span>
|
||||
<span>{formatCompactStat(plugin.stats?.downloads ?? 0)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
@@ -307,7 +278,6 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
const activeItems = kind === "skills" ? skills : plugins;
|
||||
const activeStatus = status;
|
||||
const isEmpty = activeStatus === "idle" && activeItems.length === 0;
|
||||
const showSkillStats = true;
|
||||
const showListingMore =
|
||||
activeStatus === "idle" && (activeItems.length > visibleCount || listingHasMore);
|
||||
|
||||
@@ -487,19 +457,11 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
</div>
|
||||
|
||||
{activeStatus === "idle" && activeItems.length > 0 ? (
|
||||
<div
|
||||
className={`home-v2-listing-head${showSkillStats ? "" : " has-no-stats"}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="home-v2-listing-head-icon-spacer" />
|
||||
<div className="home-v2-listing-head" aria-hidden="true">
|
||||
<span className="home-v2-listing-head-label">
|
||||
{kind === "skills" ? "Skill" : "Plugin"}
|
||||
</span>
|
||||
{kind === "skills" && tab === "trending" ? (
|
||||
<span className="home-v2-listing-head-stat">24h downloads</span>
|
||||
) : showSkillStats ? (
|
||||
<span className="home-v2-listing-head-stat">Popularity</span>
|
||||
) : null}
|
||||
<span className="home-v2-listing-head-stat">Downloads</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -532,7 +494,6 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection
|
||||
<HomeListingSkillRow
|
||||
key={isHomeTrendingSkillEntry(entry) ? entry.trending.id : String(entry.skill._id)}
|
||||
entry={entry}
|
||||
showStats={showSkillStats}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+113
-114
@@ -31,6 +31,7 @@ import { DetailBody, DetailPageShell } from "./DetailPageShell";
|
||||
import { DetailSecuritySummary } from "./DetailSecuritySummary";
|
||||
import { GenericNotFoundPage } from "./GenericNotFoundPage";
|
||||
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
|
||||
import { SkillDetailPageView } from "./SkillDetailPageView";
|
||||
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
|
||||
import {
|
||||
buildSkillHref,
|
||||
@@ -39,7 +40,6 @@ import {
|
||||
formatOsList,
|
||||
stripFrontmatter,
|
||||
} from "./skillDetailUtils";
|
||||
import { SkillHeader } from "./SkillHeader";
|
||||
import { buildSkillInstallTabs } from "./SkillInstallCard";
|
||||
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
|
||||
import { SkillPublishSuccessDialog } from "./SkillPublishSuccessDialog";
|
||||
@@ -882,123 +882,122 @@ export function SkillDetailPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section detail-page-section skill-detail-page">
|
||||
<DetailPageShell>
|
||||
<SkillHeader
|
||||
skill={displayedSkill}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
latestVersion={latestVersion}
|
||||
modInfo={modInfo}
|
||||
canManage={canManage}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isStaff={isStaff}
|
||||
isStarred={effectiveIsStarred}
|
||||
onToggleStar={() => void handleToggleStar()}
|
||||
onOpenReport={openReportDialog}
|
||||
onRequireSignIn={requireSignIn}
|
||||
forkOf={forkOf}
|
||||
forkOfLabel={forkOfLabel}
|
||||
forkOfHref={forkOfHref}
|
||||
forkOfOwnerHandle={forkOfOwnerHandle}
|
||||
canonical={canonical}
|
||||
canonicalHref={canonicalHref}
|
||||
canonicalOwnerHandle={canonicalOwnerHandle}
|
||||
staffVisibilityTag={staffVisibilityTag}
|
||||
isAutoHidden={isAutoHidden}
|
||||
isRemoved={isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
hasPluginBundle={hasPluginBundle}
|
||||
configRequirements={configRequirements}
|
||||
cliHelp={cliHelp}
|
||||
clawdis={clawdis}
|
||||
category={relatedCategory}
|
||||
categories={relatedCategories}
|
||||
staffVisibilityAlert={staffVisibilityAlert}
|
||||
securityAuditSummary={securitySummary}
|
||||
activityTrend={activityTrend}
|
||||
activityTrendLoading={activityTrendLoading}
|
||||
newVersionHref={newVersionHref}
|
||||
settingsHref={settingsHref}
|
||||
showArchiveMetadata={!isGitHubBackedSkill}
|
||||
>
|
||||
{nixSnippet ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">Install via Nix</h3>
|
||||
<pre className="hero-install-code mt-2">{nixSnippet}</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{configExample ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">Config example</h3>
|
||||
<pre className="hero-install-code mt-2">{configExample}</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<SkillDetailTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
onCompareIntent={() => setShouldPrefetchCompare(true)}
|
||||
readmeContent={readmeContent}
|
||||
readmeError={displayedReadmeError}
|
||||
skillCardContent={displayedSkillCard}
|
||||
skillCardError={displayedSkillCardError}
|
||||
hasSkillCard={hasSkillCard}
|
||||
latestFiles={latestFiles}
|
||||
latestVersionId={latestVersion?._id ?? null}
|
||||
latestVersion={latestVersion?.version ?? null}
|
||||
canDeleteVersions={canDeleteSkillVersions}
|
||||
skill={skill as Doc<"skills">}
|
||||
ownerHandle={ownerHandle}
|
||||
diffVersions={diffVersions}
|
||||
versions={versions}
|
||||
nixPlugin={Boolean(nixPlugin)}
|
||||
showArchiveTabs={!isGitHubBackedSkill}
|
||||
suppressVersionScanResults={suppressVersionScanResults}
|
||||
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
readmeHrefResolver={readmeHrefResolver}
|
||||
<SkillDetailPageView
|
||||
skill={displayedSkill}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
latestVersion={latestVersion}
|
||||
modInfo={modInfo}
|
||||
canManage={canManage}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isStaff={isStaff}
|
||||
isStarred={effectiveIsStarred}
|
||||
onToggleStar={() => void handleToggleStar()}
|
||||
onOpenReport={openReportDialog}
|
||||
onRequireSignIn={requireSignIn}
|
||||
forkOf={forkOf}
|
||||
forkOfLabel={forkOfLabel}
|
||||
forkOfHref={forkOfHref}
|
||||
forkOfOwnerHandle={forkOfOwnerHandle}
|
||||
canonical={canonical}
|
||||
canonicalHref={canonicalHref}
|
||||
canonicalOwnerHandle={canonicalOwnerHandle}
|
||||
staffVisibilityTag={staffVisibilityTag}
|
||||
isAutoHidden={isAutoHidden}
|
||||
isRemoved={isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
hasPluginBundle={hasPluginBundle}
|
||||
configRequirements={configRequirements}
|
||||
cliHelp={cliHelp}
|
||||
clawdis={clawdis}
|
||||
category={relatedCategory}
|
||||
categories={relatedCategories}
|
||||
staffVisibilityAlert={staffVisibilityAlert}
|
||||
securityAuditSummary={securitySummary}
|
||||
activityTrend={activityTrend}
|
||||
activityTrendLoading={activityTrendLoading}
|
||||
newVersionHref={newVersionHref}
|
||||
settingsHref={settingsHref}
|
||||
showArchiveMetadata={!isGitHubBackedSkill}
|
||||
pageOverlays={
|
||||
<>
|
||||
<SkillReportDialog
|
||||
isOpen={isAuthenticated && isReportDialogOpen}
|
||||
isSubmitting={isSubmittingReport}
|
||||
reportReason={reportReason}
|
||||
reportError={reportError}
|
||||
onReasonChange={setReportReason}
|
||||
onCancel={closeReportDialog}
|
||||
onSubmit={() => void submitReport()}
|
||||
/>
|
||||
<SkillRelatedSection
|
||||
category={relatedCategory}
|
||||
relatedSkills={relatedSkillsResult?.items ?? []}
|
||||
isLoading={shouldLoadRelatedSkills && relatedSkillsResult === undefined}
|
||||
variant="compact"
|
||||
<SkillPublishSuccessDialog
|
||||
isOpen={showPublishSuccessDialog}
|
||||
displayName={skill.displayName}
|
||||
skillPath={detailHref}
|
||||
skill={skill}
|
||||
publisher={
|
||||
owner
|
||||
? {
|
||||
displayName: owner.displayName,
|
||||
handle: owner.handle ?? ownerHandle,
|
||||
image: owner.image,
|
||||
kind: owner.kind,
|
||||
}
|
||||
: ownerHandle
|
||||
? { handle: ownerHandle }
|
||||
: null
|
||||
}
|
||||
categoryLabel={relatedCategory?.label ?? null}
|
||||
onDismiss={onDismissPostPublish ?? (() => undefined)}
|
||||
/>
|
||||
</SkillHeader>
|
||||
</DetailPageShell>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{nixSnippet ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">Install via Nix</h3>
|
||||
<pre className="hero-install-code mt-2">{nixSnippet}</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<SkillReportDialog
|
||||
isOpen={isAuthenticated && isReportDialogOpen}
|
||||
isSubmitting={isSubmittingReport}
|
||||
reportReason={reportReason}
|
||||
reportError={reportError}
|
||||
onReasonChange={setReportReason}
|
||||
onCancel={closeReportDialog}
|
||||
onSubmit={() => void submitReport()}
|
||||
{configExample ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">Config example</h3>
|
||||
<pre className="hero-install-code mt-2">{configExample}</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<SkillDetailTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
onCompareIntent={() => setShouldPrefetchCompare(true)}
|
||||
readmeContent={readmeContent}
|
||||
readmeError={displayedReadmeError}
|
||||
skillCardContent={displayedSkillCard}
|
||||
skillCardError={displayedSkillCardError}
|
||||
hasSkillCard={hasSkillCard}
|
||||
latestFiles={latestFiles}
|
||||
latestVersionId={latestVersion?._id ?? null}
|
||||
latestVersion={latestVersion?.version ?? null}
|
||||
canDeleteVersions={canDeleteSkillVersions}
|
||||
skill={skill as Doc<"skills">}
|
||||
ownerHandle={ownerHandle}
|
||||
diffVersions={diffVersions}
|
||||
versions={versions}
|
||||
nixPlugin={Boolean(nixPlugin)}
|
||||
showArchiveTabs={!isGitHubBackedSkill}
|
||||
suppressVersionScanResults={suppressVersionScanResults}
|
||||
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
readmeHrefResolver={readmeHrefResolver}
|
||||
/>
|
||||
<SkillPublishSuccessDialog
|
||||
isOpen={showPublishSuccessDialog}
|
||||
displayName={skill.displayName}
|
||||
skillPath={detailHref}
|
||||
skill={skill}
|
||||
publisher={
|
||||
owner
|
||||
? {
|
||||
displayName: owner.displayName,
|
||||
handle: owner.handle ?? ownerHandle,
|
||||
image: owner.image,
|
||||
kind: owner.kind,
|
||||
}
|
||||
: ownerHandle
|
||||
? { handle: ownerHandle }
|
||||
: null
|
||||
}
|
||||
categoryLabel={relatedCategory?.label ?? null}
|
||||
onDismiss={onDismissPostPublish ?? (() => undefined)}
|
||||
<SkillRelatedSection
|
||||
category={relatedCategory}
|
||||
relatedSkills={relatedSkillsResult?.items ?? []}
|
||||
isLoading={shouldLoadRelatedSkills && relatedSkillsResult === undefined}
|
||||
variant="compact"
|
||||
/>
|
||||
</main>
|
||||
</SkillDetailPageView>
|
||||
);
|
||||
}
|
||||
|
||||
+8
-8
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { SkillHeader } from "./SkillHeader";
|
||||
import { SkillDetailPageView } from "./SkillDetailPageView";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
@@ -31,7 +31,7 @@ vi.mock("../lib/useHeroCreatorPublisher", () => ({
|
||||
useHeroCreatorPublisher: ({ owner }: { owner?: PublicPublisher | null }) => owner,
|
||||
}));
|
||||
|
||||
describe("SkillHeader", () => {
|
||||
describe("SkillDetailPageView", () => {
|
||||
function sidebarStatsRoot(container: HTMLElement) {
|
||||
const node = container.querySelector(".detail-sidebar-stats");
|
||||
if (!node) throw new Error("Missing .detail-sidebar-stats");
|
||||
@@ -121,8 +121,8 @@ describe("SkillHeader", () => {
|
||||
linkedUserId: "users:owner" as Id<"users">,
|
||||
};
|
||||
|
||||
function renderHeader(overrides: Partial<Parameters<typeof SkillHeader>[0]> = {}) {
|
||||
const props: Parameters<typeof SkillHeader>[0] = {
|
||||
function renderHeader(overrides: Partial<Parameters<typeof SkillDetailPageView>[0]> = {}) {
|
||||
const props: Parameters<typeof SkillDetailPageView>[0] = {
|
||||
skill,
|
||||
owner,
|
||||
ownerHandle: "local",
|
||||
@@ -157,7 +157,7 @@ describe("SkillHeader", () => {
|
||||
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<SkillHeader {...props} />
|
||||
<SkillDetailPageView {...props} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
@@ -387,7 +387,7 @@ describe("SkillHeader", () => {
|
||||
isAuthenticated: true,
|
||||
settingsHref: "/local/demo/settings",
|
||||
newVersionHref: "/skills/publish?updateSlug=demo&ownerHandle=local",
|
||||
} as Partial<Parameters<typeof SkillHeader>[0]>);
|
||||
} as Partial<Parameters<typeof SkillDetailPageView>[0]>);
|
||||
|
||||
const newVersionLink = screen.getByRole("link", { name: "New version" });
|
||||
const settingsLink = screen.getByRole("link", { name: "Settings" });
|
||||
@@ -448,7 +448,7 @@ describe("SkillHeader", () => {
|
||||
isAuthenticated: true,
|
||||
settingsHref: "/local/demo/settings",
|
||||
newVersionHref: "/skills/publish?updateSlug=demo&ownerHandle=local",
|
||||
} as Partial<Parameters<typeof SkillHeader>[0]>);
|
||||
} as Partial<Parameters<typeof SkillDetailPageView>[0]>);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Report" })).toBeNull();
|
||||
});
|
||||
@@ -460,7 +460,7 @@ describe("SkillHeader", () => {
|
||||
isStaff: true,
|
||||
settingsHref: "/local/demo/settings",
|
||||
newVersionHref: "/skills/publish?updateSlug=demo&ownerHandle=local",
|
||||
} as Partial<Parameters<typeof SkillHeader>[0]>);
|
||||
} as Partial<Parameters<typeof SkillDetailPageView>[0]>);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Report" })).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SkillDetailPageView, type SkillDetailPageViewProps } from "./SkillDetailPageView";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({ children, to }: { children?: ReactNode; to?: string }) => (
|
||||
<a href={to ?? "#"}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useHeroCreatorPublisher", () => ({
|
||||
useHeroCreatorPublisher: () => null,
|
||||
}));
|
||||
|
||||
describe("SkillDetailPageView", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("matchMedia", () => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
it("owns the whole shared detail page while rendering source-specific slots", () => {
|
||||
const props: SkillDetailPageViewProps = {
|
||||
skill: {
|
||||
slug: "demo",
|
||||
displayName: "Demo Skill",
|
||||
summary: "Shared detail page",
|
||||
stats: { downloads: 12, stars: 0 },
|
||||
updatedAt: 1,
|
||||
},
|
||||
owner: null,
|
||||
ownerHandle: "publisher",
|
||||
latestVersion: null,
|
||||
modInfo: null,
|
||||
canManage: false,
|
||||
isAuthenticated: false,
|
||||
isStaff: false,
|
||||
isStarred: false,
|
||||
onToggleStar: vi.fn(),
|
||||
onOpenReport: vi.fn(),
|
||||
onRequireSignIn: vi.fn(),
|
||||
forkOf: null,
|
||||
forkOfLabel: "fork of",
|
||||
forkOfHref: null,
|
||||
forkOfOwnerHandle: null,
|
||||
canonical: null,
|
||||
canonicalHref: null,
|
||||
canonicalOwnerHandle: null,
|
||||
staffVisibilityTag: null,
|
||||
isAutoHidden: false,
|
||||
isRemoved: false,
|
||||
nixPlugin: undefined,
|
||||
hasPluginBundle: false,
|
||||
configRequirements: undefined,
|
||||
cliHelp: undefined,
|
||||
clawdis: undefined,
|
||||
installContent: <div>Source install</div>,
|
||||
renderSidebarContent: () => <div>Source provenance</div>,
|
||||
children: <div>Source tabs</div>,
|
||||
};
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SkillDetailPageView {...props} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("main").classList.contains("skill-detail-page")).toBe(true);
|
||||
expect(screen.getByRole("heading", { name: "Demo Skill" })).toBeTruthy();
|
||||
expect(screen.getByText("Source install")).toBeTruthy();
|
||||
expect(screen.getByText("Source tabs")).toBeTruthy();
|
||||
expect(screen.getAllByText("Source provenance")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("omits the fallback title icon and renders source metadata before taxonomy", () => {
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<SkillDetailPageView
|
||||
{...makeMinimalProps()}
|
||||
taxonomyPrefix={<a href="https://skills.sh/example/skills/demo">Synced from skills.sh</a>}
|
||||
categories={[{ slug: "development", label: "Development", icon: "wrench", keywords: [] }]}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".marketplace-icon")).toBeNull();
|
||||
const taxonomy = container.querySelector(".skill-hero-taxonomy-row");
|
||||
expect(taxonomy).toBeTruthy();
|
||||
expect(taxonomy?.textContent).toContain("Synced from skills.sh");
|
||||
expect(taxonomy?.textContent).toContain("Development");
|
||||
expect(taxonomy?.querySelector(".skill-hero-taxonomy-separator")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the supplied title icon", () => {
|
||||
const icon = `/api/v1/skill-icons/${"a".repeat(64)}`;
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<SkillDetailPageView
|
||||
{...makeMinimalProps()}
|
||||
skill={{
|
||||
...makeMinimalProps().skill,
|
||||
icon,
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".marketplace-icon img")?.getAttribute("src")).toBe(icon);
|
||||
});
|
||||
});
|
||||
|
||||
function makeMinimalProps(): SkillDetailPageViewProps {
|
||||
return {
|
||||
skill: {
|
||||
slug: "demo",
|
||||
displayName: "Demo Skill",
|
||||
summary: "Shared detail page",
|
||||
icon: null,
|
||||
stats: { downloads: 12, stars: 0 },
|
||||
updatedAt: 1,
|
||||
},
|
||||
owner: null,
|
||||
ownerHandle: "publisher",
|
||||
latestVersion: null,
|
||||
modInfo: null,
|
||||
canManage: false,
|
||||
isAuthenticated: false,
|
||||
isStaff: false,
|
||||
isStarred: false,
|
||||
onToggleStar: vi.fn(),
|
||||
onOpenReport: vi.fn(),
|
||||
onRequireSignIn: vi.fn(),
|
||||
forkOf: null,
|
||||
forkOfLabel: "fork of",
|
||||
forkOfHref: null,
|
||||
forkOfOwnerHandle: null,
|
||||
canonical: null,
|
||||
canonicalHref: null,
|
||||
canonicalOwnerHandle: null,
|
||||
staffVisibilityTag: null,
|
||||
isAutoHidden: false,
|
||||
isRemoved: false,
|
||||
nixPlugin: undefined,
|
||||
hasPluginBundle: false,
|
||||
configRequirements: undefined,
|
||||
cliHelp: undefined,
|
||||
clawdis: undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
|
||||
import { Bookmark, Flag, Settings, ShieldCheck, Upload } from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import type { ActivityTrend } from "../lib/activityTrend";
|
||||
import { getSkillBadges, isSkillOfficial } from "../lib/badges";
|
||||
import { BrowseCategoryIcon } from "../lib/browseCategoryIcons";
|
||||
import {
|
||||
buildSkillCategoryBrowseHref,
|
||||
buildSkillTopicBrowseHref,
|
||||
formatCatalogTopicLabel,
|
||||
type SkillCategory,
|
||||
} from "../lib/categories";
|
||||
import { formatSkillStatsTriplet } from "../lib/numberFormat";
|
||||
import { buildPublisherProfileHref } from "../lib/ownerRoute";
|
||||
import { presentationTitle } from "../lib/presentationTitle";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { useHeroCreatorPublisher } from "../lib/useHeroCreatorPublisher";
|
||||
import { useMediaQuery } from "../lib/useMediaQuery";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ActivityMetricLabel } from "./ActivityMetricLabel";
|
||||
import { DetailHero, DetailPageShell, DETAIL_HERO_TOPIC_LIMIT } from "./DetailPageShell";
|
||||
import { DetailSecuritySummaryLabel } from "./DetailSecuritySummary";
|
||||
import { useDownloadsSidebarMetricBlock } from "./DownloadsMetricCard";
|
||||
import { InlineCodeSummary } from "./InlineCodeSummary";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import { SidebarMetadata } from "./SidebarMetadata";
|
||||
import { buildSkillHref } from "./skillDetailUtils";
|
||||
import { SkillCommandLineCard } from "./SkillInstallSurface";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { UserBadge } from "./UserBadge";
|
||||
|
||||
type SkillModerationInfo = {
|
||||
isPendingScan: boolean;
|
||||
isMalwareBlocked: boolean;
|
||||
isSuspicious: boolean;
|
||||
isHiddenByMod: boolean;
|
||||
isRemoved: boolean;
|
||||
overrideActive?: boolean;
|
||||
verdict?: "clean" | "suspicious" | "malicious";
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type SkillFork = {
|
||||
kind: "fork" | "duplicate";
|
||||
version: string | null;
|
||||
skill: { slug: string; displayName: string };
|
||||
owner: { handle: string | null; userId: Id<"users"> | null };
|
||||
};
|
||||
|
||||
type SkillCanonical = {
|
||||
skill: { slug: string; displayName: string };
|
||||
owner: { handle: string | null; userId: Id<"users"> | null };
|
||||
};
|
||||
|
||||
const SUMMARY_COLLAPSE_THRESHOLD = 220;
|
||||
|
||||
type MobileDetailPanel = "content" | "stats";
|
||||
|
||||
function formatHeaderTopic(topic: string) {
|
||||
return formatCatalogTopicLabel(topic);
|
||||
}
|
||||
|
||||
type SkillDetailLatestVersion =
|
||||
| (Omit<Doc<"skillVersions">, "parsed"> & {
|
||||
parsed?: (Partial<Doc<"skillVersions">["parsed"]> & { description?: string }) | null;
|
||||
})
|
||||
| null;
|
||||
|
||||
function getLatestVersionDescription(latestVersion: SkillDetailLatestVersion) {
|
||||
const parsed = latestVersion?.parsed;
|
||||
const description =
|
||||
typeof parsed?.presentation?.summary === "string"
|
||||
? parsed.presentation.summary
|
||||
: typeof parsed?.description === "string"
|
||||
? parsed.description
|
||||
: typeof parsed?.frontmatter?.description === "string"
|
||||
? parsed.frontmatter.description
|
||||
: null;
|
||||
return description?.trim() || null;
|
||||
}
|
||||
|
||||
export type SkillDetailViewSkill = {
|
||||
_id?: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary?: string | null;
|
||||
icon?: string | null;
|
||||
ownerUserId?: Id<"users">;
|
||||
ownerPublisherId?: Id<"publishers">;
|
||||
installKind?: PublicSkill["installKind"];
|
||||
githubSourceRepo?: string;
|
||||
githubPath?: string;
|
||||
githubCurrentCommit?: string;
|
||||
githubCurrentStatus?: PublicSkill["githubCurrentStatus"];
|
||||
githubScanStatus?: PublicSkill["githubScanStatus"];
|
||||
githubHasSkillCard?: boolean;
|
||||
categories?: string[];
|
||||
inferredCategories?: string[];
|
||||
latestVersionId?: Id<"skillVersions">;
|
||||
inferredFromVersionId?: Id<"skillVersions">;
|
||||
topics?: string[];
|
||||
badges?: PublicSkill["badges"];
|
||||
stats: {
|
||||
downloads: number;
|
||||
stars: number;
|
||||
installs?: number | null;
|
||||
installsAllTime?: number;
|
||||
installsCurrent?: number;
|
||||
versions?: number;
|
||||
comments?: number;
|
||||
};
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function getGitHubRepositoryLink(skill: SkillDetailViewSkill) {
|
||||
const repo = "githubSourceRepo" in skill ? skill.githubSourceRepo : undefined;
|
||||
if (skill.installKind !== "github" || !repo) return null;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`https://github.com/${repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="plugin-external-link"
|
||||
>
|
||||
{repo}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export type SkillDetailPageViewProps = {
|
||||
skill: SkillDetailViewSkill;
|
||||
owner: PublicPublisher | null;
|
||||
ownerHandle: string | null;
|
||||
latestVersion: SkillDetailLatestVersion;
|
||||
modInfo: SkillModerationInfo | null;
|
||||
canManage: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isStaff: boolean;
|
||||
isStarred: boolean | undefined;
|
||||
onToggleStar: () => void;
|
||||
onOpenReport: () => void;
|
||||
onRequireSignIn: () => void;
|
||||
forkOf: SkillFork | null;
|
||||
forkOfLabel: string;
|
||||
forkOfHref: string | null;
|
||||
forkOfOwnerHandle: string | null;
|
||||
canonical: SkillCanonical | null;
|
||||
canonicalHref: string | null;
|
||||
canonicalOwnerHandle: string | null;
|
||||
staffVisibilityTag: string | null;
|
||||
isAutoHidden: boolean;
|
||||
isRemoved: boolean;
|
||||
nixPlugin: string | undefined;
|
||||
hasPluginBundle: boolean;
|
||||
configRequirements: ClawdisSkillMetadata["config"] | undefined;
|
||||
cliHelp: string | undefined;
|
||||
clawdis: ClawdisSkillMetadata | undefined;
|
||||
category?: SkillCategory | null;
|
||||
categories?: SkillCategory[] | null;
|
||||
staffVisibilityAlert?: ReactNode;
|
||||
postInstallContent?: ReactNode;
|
||||
securityAuditSummary?: ReactNode;
|
||||
activityTrend?: ActivityTrend | null;
|
||||
activityTrendLoading?: boolean;
|
||||
newVersionHref?: string | null;
|
||||
settingsHref?: string | null;
|
||||
showArchiveMetadata?: boolean;
|
||||
titleAccessory?: ReactNode;
|
||||
breadcrumbOwnerHref?: string | null;
|
||||
breadcrumbOwnerLabel?: ReactNode;
|
||||
breadcrumbSkillHref?: string;
|
||||
creatorContent?: ReactNode;
|
||||
heroNotice?: ReactNode;
|
||||
taxonomyPrefix?: ReactNode;
|
||||
installContent?: ReactNode;
|
||||
renderSidebarContent?: () => ReactNode;
|
||||
showBookmarkAction?: boolean;
|
||||
showReportAction?: boolean;
|
||||
pageClassName?: string;
|
||||
pageOverlays?: ReactNode;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function SkillDetailPageView({
|
||||
skill,
|
||||
owner,
|
||||
ownerHandle,
|
||||
latestVersion,
|
||||
modInfo,
|
||||
canManage,
|
||||
isAuthenticated,
|
||||
isStaff,
|
||||
isStarred,
|
||||
onToggleStar,
|
||||
onOpenReport,
|
||||
onRequireSignIn,
|
||||
forkOf,
|
||||
forkOfLabel,
|
||||
forkOfHref,
|
||||
forkOfOwnerHandle,
|
||||
canonical,
|
||||
canonicalHref,
|
||||
canonicalOwnerHandle,
|
||||
nixPlugin,
|
||||
hasPluginBundle,
|
||||
configRequirements,
|
||||
cliHelp,
|
||||
clawdis,
|
||||
category,
|
||||
categories,
|
||||
staffVisibilityAlert,
|
||||
postInstallContent,
|
||||
securityAuditSummary,
|
||||
activityTrend,
|
||||
activityTrendLoading = false,
|
||||
newVersionHref,
|
||||
settingsHref,
|
||||
showArchiveMetadata = true,
|
||||
titleAccessory,
|
||||
breadcrumbOwnerHref,
|
||||
breadcrumbOwnerLabel,
|
||||
breadcrumbSkillHref,
|
||||
creatorContent,
|
||||
heroNotice,
|
||||
taxonomyPrefix,
|
||||
installContent,
|
||||
renderSidebarContent,
|
||||
showBookmarkAction = true,
|
||||
showReportAction: showReportActionOverride,
|
||||
pageClassName,
|
||||
pageOverlays,
|
||||
children,
|
||||
}: SkillDetailPageViewProps) {
|
||||
const formattedStats = formatSkillStatsTriplet(skill.stats);
|
||||
const installOwnerId = owner?._id ?? skill.ownerPublisherId ?? skill.ownerUserId ?? null;
|
||||
const hasOwnerActions = Boolean(newVersionHref) || Boolean(settingsHref);
|
||||
const showReportAction = showReportActionOverride ?? (!canManage || isStaff);
|
||||
const badges = getSkillBadges(skill);
|
||||
const titleBadges = badges.filter((badge) => badge !== "Official");
|
||||
const heroCreatorPublisher = useHeroCreatorPublisher({
|
||||
owner,
|
||||
skillOfficial: isSkillOfficial(skill),
|
||||
});
|
||||
const showHeroMeta = Boolean((forkOf && forkOfHref) || canonicalHref);
|
||||
const showTitleBadges = titleBadges.length > 0;
|
||||
const headerDescription =
|
||||
getLatestVersionDescription(latestVersion) ?? skill.summary ?? "No summary provided.";
|
||||
const headerTopics = (skill.topics ?? [])
|
||||
.map((topic) => topic.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, DETAIL_HERO_TOPIC_LIMIT);
|
||||
const headerCategories = (categories ?? (category ? [category] : [])).slice(0, 3);
|
||||
const hasSummaryToggle = headerDescription.length > SUMMARY_COLLAPSE_THRESHOLD;
|
||||
const [isSummaryExpanded, setIsSummaryExpanded] = useState(false);
|
||||
const [mobileDetailPanel, setMobileDetailPanel] = useState<MobileDetailPanel>("content");
|
||||
const isMobileDetailLayout = useMediaQuery("(max-width: 900px)");
|
||||
|
||||
const renderStarAction = () => (
|
||||
<SignedInActionTooltip
|
||||
isAuthenticated={isAuthenticated}
|
||||
message="You must be signed in to bookmark a skill"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="skill-sidebar-action-link skill-sidebar-star-action"
|
||||
onClick={isAuthenticated ? onToggleStar : onRequireSignIn}
|
||||
aria-pressed={Boolean(isAuthenticated && isStarred)}
|
||||
aria-label={isStarred ? "Unbookmark skill" : "Bookmark skill"}
|
||||
>
|
||||
<Bookmark
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
fill={isAuthenticated && isStarred ? "currentColor" : "none"}
|
||||
/>
|
||||
{isAuthenticated && isStarred ? "Unbookmark" : "Bookmark"}
|
||||
<span className="skill-action-count">{formattedStats.stars}</span>
|
||||
</button>
|
||||
</SignedInActionTooltip>
|
||||
);
|
||||
|
||||
const renderSidebarActions = () => {
|
||||
if (!showReportAction) return null;
|
||||
return (
|
||||
<div className="skill-sidebar-actions skill-sidebar-actions-secondary">
|
||||
<SignedInActionTooltip
|
||||
isAuthenticated={isAuthenticated}
|
||||
message="You must be signed in to report a skill"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="skill-sidebar-action-link"
|
||||
onClick={isAuthenticated ? onOpenReport : onRequireSignIn}
|
||||
>
|
||||
<Flag size={14} aria-hidden="true" />
|
||||
Report
|
||||
</button>
|
||||
</SignedInActionTooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const managementToolbar =
|
||||
hasOwnerActions || isStaff || staffVisibilityAlert ? (
|
||||
<div className="skill-management-toolbar">
|
||||
{staffVisibilityAlert ? (
|
||||
<div className="skill-management-toolbar-alert">{staffVisibilityAlert}</div>
|
||||
) : null}
|
||||
{hasOwnerActions || isStaff ? (
|
||||
<div className="skill-management-toolbar-inner">
|
||||
{newVersionHref ? (
|
||||
<Button asChild variant="ghost" size="xs" className="skill-management-toolbar-action">
|
||||
<a href={newVersionHref} aria-label="New version">
|
||||
<Upload size={13} aria-hidden="true" />
|
||||
New version
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{settingsHref ? (
|
||||
<Button asChild variant="ghost" size="xs" className="skill-management-toolbar-action">
|
||||
<a href={settingsHref} aria-label="Settings">
|
||||
<Settings size={13} aria-hidden="true" />
|
||||
Settings
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<Button asChild variant="ghost" size="xs" className="skill-management-toolbar-action">
|
||||
<Link to="/management" search={{ skill: skill.slug, plugin: undefined }}>
|
||||
<ShieldCheck size={13} aria-hidden="true" />
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const defaultStatsContent = () => (
|
||||
<>
|
||||
<SkillSidebarDeferredStats
|
||||
skill={skill}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
formattedStats={formattedStats}
|
||||
latestVersion={latestVersion}
|
||||
showArchiveMetadata={showArchiveMetadata}
|
||||
securityAuditSummary={securityAuditSummary}
|
||||
activityTrend={activityTrend}
|
||||
activityTrendLoading={activityTrendLoading}
|
||||
hideCreator
|
||||
/>
|
||||
{renderSidebarActions()}
|
||||
</>
|
||||
);
|
||||
// Match the normal shell's CSS-exclusive desktop/mobile copies so responsive
|
||||
// transitions never move or remount the active sidebar subtree.
|
||||
const desktopStatsContent = renderSidebarContent ? renderSidebarContent() : defaultStatsContent();
|
||||
const mobileStatsContent = renderSidebarContent ? renderSidebarContent() : defaultStatsContent();
|
||||
const resolvedBreadcrumbOwnerHref =
|
||||
breadcrumbOwnerHref === undefined
|
||||
? ownerHandle
|
||||
? buildPublisherProfileHref(ownerHandle)
|
||||
: "#"
|
||||
: breadcrumbOwnerHref;
|
||||
const displayName = presentationTitle(skill.displayName, skill.slug);
|
||||
|
||||
return (
|
||||
<main className={cn("section detail-page-section skill-detail-page", pageClassName)}>
|
||||
<DetailPageShell>
|
||||
{modInfo?.isPendingScan ? (
|
||||
<div className="pending-banner">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Security scan in progress</strong>
|
||||
<p>
|
||||
Your skill is being scanned by VirusTotal. It will be visible to others once the
|
||||
scan completes. This usually takes up to 5 minutes — grab a coffee or exfoliate your
|
||||
shell while you wait.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isRemoved ? (
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill removed by moderator</strong>
|
||||
<p>This skill has been removed and is not visible to others.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isHiddenByMod ? (
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill hidden</strong>
|
||||
<p>This skill is currently hidden and not visible to others.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{managementToolbar}
|
||||
|
||||
<DetailHero
|
||||
topClassName={hasPluginBundle ? "has-plugin" : undefined}
|
||||
sidebar={
|
||||
<div className="skill-hero-sidebar-stack">
|
||||
{!isMobileDetailLayout && showBookmarkAction ? (
|
||||
<div className="skill-sidebar-star-band detail-hero-summary-row">
|
||||
{renderStarAction()}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="detail-sidebar-stats">{desktopStatsContent}</div>
|
||||
</div>
|
||||
}
|
||||
main={
|
||||
<>
|
||||
<div className="skill-hero-title">
|
||||
<nav className="skill-hero-breadcrumbs" aria-label="Skill breadcrumbs">
|
||||
<a href="/skills">skills</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
{resolvedBreadcrumbOwnerHref ? (
|
||||
<a href={resolvedBreadcrumbOwnerHref}>
|
||||
{breadcrumbOwnerLabel ??
|
||||
ownerHandle ??
|
||||
owner?.displayName ??
|
||||
owner?._id ??
|
||||
"unknown"}
|
||||
</a>
|
||||
) : (
|
||||
<span>
|
||||
{breadcrumbOwnerLabel ??
|
||||
ownerHandle ??
|
||||
owner?.displayName ??
|
||||
owner?._id ??
|
||||
"unknown"}
|
||||
</span>
|
||||
)}
|
||||
<span aria-hidden="true">/</span>
|
||||
<a
|
||||
href={
|
||||
breadcrumbSkillHref ??
|
||||
buildSkillHref(ownerHandle, owner?._id ?? null, skill.slug)
|
||||
}
|
||||
aria-current="page"
|
||||
>
|
||||
{skill.slug}
|
||||
</a>
|
||||
</nav>
|
||||
<div className="skill-hero-heading-stack">
|
||||
{taxonomyPrefix || headerCategories.length > 0 || headerTopics.length > 0 ? (
|
||||
<div className="skill-hero-taxonomy-row" aria-label="Skill metadata">
|
||||
{taxonomyPrefix ? (
|
||||
<div className="skill-hero-taxonomy-prefix">{taxonomyPrefix}</div>
|
||||
) : null}
|
||||
{taxonomyPrefix &&
|
||||
(headerCategories.length > 0 || headerTopics.length > 0) ? (
|
||||
<span className="skill-hero-taxonomy-separator" aria-hidden="true" />
|
||||
) : null}
|
||||
{headerCategories.length > 0 ? (
|
||||
<div className="skill-category-meta-list" aria-label="Categories">
|
||||
{headerCategories.map((categoryItem) => (
|
||||
<a
|
||||
key={categoryItem.slug}
|
||||
className="skill-category-meta-link"
|
||||
href={buildSkillCategoryBrowseHref(categoryItem)}
|
||||
aria-label={`View ${categoryItem.label} skills`}
|
||||
>
|
||||
<BrowseCategoryIcon
|
||||
slug={categoryItem.slug}
|
||||
icon={categoryItem.icon}
|
||||
size={14}
|
||||
className="skill-category-icon"
|
||||
/>
|
||||
<span>{categoryItem.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{headerCategories.length > 0 && headerTopics.length > 0 ? (
|
||||
<span className="skill-hero-taxonomy-separator" aria-hidden="true" />
|
||||
) : null}
|
||||
{headerTopics.length > 0 ? (
|
||||
<div className="skill-hero-topic-list" aria-label="Topics">
|
||||
{headerTopics.map((topic) => (
|
||||
<a
|
||||
key={topic}
|
||||
className="skill-hero-topic"
|
||||
href={buildSkillTopicBrowseHref(topic)}
|
||||
aria-label={`View skills tagged ${formatHeaderTopic(topic)}`}
|
||||
>
|
||||
{formatHeaderTopic(topic)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="skill-hero-title-row">
|
||||
{skill.icon ? (
|
||||
<MarketplaceIcon
|
||||
kind="skill"
|
||||
label={displayName}
|
||||
imageUrl={skill.icon}
|
||||
skill={skill}
|
||||
size="md"
|
||||
/>
|
||||
) : null}
|
||||
<h1 className="skill-page-title">{displayName}</h1>
|
||||
{showTitleBadges ? (
|
||||
<div className="skill-title-badges">
|
||||
{titleBadges.map((badge) => (
|
||||
<Badge key={badge} variant="compact">
|
||||
{badge}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
{titleAccessory}
|
||||
</div>
|
||||
{showHeroMeta ? (
|
||||
<div className="skill-hero-meta-row" aria-label="Skill lineage">
|
||||
{forkOf && forkOfHref ? (
|
||||
<span className="skill-hero-meta-item">
|
||||
<span className="skill-hero-meta-label">{forkOfLabel}</span>
|
||||
<a className="skill-hero-meta-link" href={forkOfHref}>
|
||||
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
|
||||
{forkOf.skill.slug}
|
||||
</a>
|
||||
{forkOf.version ? (
|
||||
<span className="skill-hero-meta-version">({forkOf.version})</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{canonicalHref ? (
|
||||
<span className="skill-hero-meta-item">
|
||||
<span className="skill-hero-meta-label">canonical</span>
|
||||
<a className="skill-hero-meta-link" href={canonicalHref}>
|
||||
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
|
||||
{canonical?.skill?.slug}
|
||||
</a>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="skill-summary-block">
|
||||
<p
|
||||
className={`section-subtitle skill-summary-line${
|
||||
hasSummaryToggle && !isSummaryExpanded ? " line-clamp-2" : ""
|
||||
}`}
|
||||
>
|
||||
<InlineCodeSummary>{headerDescription}</InlineCodeSummary>
|
||||
</p>
|
||||
{hasSummaryToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
className="skill-summary-toggle"
|
||||
aria-expanded={isSummaryExpanded}
|
||||
onClick={() => setIsSummaryExpanded((expanded) => !expanded)}
|
||||
>
|
||||
{isSummaryExpanded ? "Show less" : "Read more"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{creatorContent || owner || ownerHandle ? (
|
||||
<div className="skill-hero-creator">
|
||||
{creatorContent ?? (
|
||||
<UserBadge
|
||||
user={heroCreatorPublisher}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
size="md"
|
||||
showName
|
||||
showHandle={false}
|
||||
showMutedHandle
|
||||
stackMutedHandleBelowName
|
||||
disableTooltip
|
||||
/>
|
||||
)}
|
||||
{isMobileDetailLayout && showBookmarkAction ? (
|
||||
<div className="skill-hero-creator-star">{renderStarAction()}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{heroNotice}
|
||||
|
||||
{nixPlugin ? (
|
||||
<div className="skill-hero-note">
|
||||
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="detail-mobile-install">
|
||||
{installContent ?? (
|
||||
<SkillCommandLineCard
|
||||
slug={skill.slug}
|
||||
displayName={skill.displayName}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={installOwnerId}
|
||||
clawdis={clawdis}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-mobile-master-tabs" data-active={mobileDetailPanel}>
|
||||
<div
|
||||
className="detail-mobile-master-tab-list"
|
||||
role="tablist"
|
||||
aria-label="Skill mobile sections"
|
||||
>
|
||||
<button
|
||||
id="skill-mobile-master-tab-content"
|
||||
className={`detail-mobile-master-tab${
|
||||
mobileDetailPanel === "content" ? " is-active" : ""
|
||||
}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileDetailPanel === "content"}
|
||||
aria-controls="skill-mobile-master-panel-content"
|
||||
onClick={() => setMobileDetailPanel("content")}
|
||||
>
|
||||
SKILL.md
|
||||
</button>
|
||||
<button
|
||||
id="skill-mobile-master-tab-stats"
|
||||
className={`detail-mobile-master-tab${
|
||||
mobileDetailPanel === "stats" ? " is-active" : ""
|
||||
}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileDetailPanel === "stats"}
|
||||
aria-controls="skill-mobile-master-panel-stats"
|
||||
onClick={() => setMobileDetailPanel("stats")}
|
||||
>
|
||||
Stats & details
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="detail-mobile-master-panel detail-mobile-master-panel-content"
|
||||
id="skill-mobile-master-panel-content"
|
||||
role="tabpanel"
|
||||
aria-labelledby="skill-mobile-master-tab-content"
|
||||
hidden={mobileDetailPanel !== "content"}
|
||||
>
|
||||
{postInstallContent}
|
||||
|
||||
{children}
|
||||
|
||||
{hasPluginBundle ? (
|
||||
<div className="skill-panel bundle-card">
|
||||
<div className="bundle-header">
|
||||
<div className="bundle-title">Plugin bundle (nix)</div>
|
||||
<div className="bundle-subtitle">Skill pack · CLI binary · Config</div>
|
||||
</div>
|
||||
<div className="bundle-includes">
|
||||
<span>SKILL.md</span>
|
||||
<span>CLI</span>
|
||||
<span>Config</span>
|
||||
</div>
|
||||
{configRequirements ? (
|
||||
<div className="bundle-section">
|
||||
<div className="bundle-section-title">Config requirements</div>
|
||||
<div className="bundle-meta">
|
||||
{configRequirements.requiredEnv?.length ? (
|
||||
<div className="stat">
|
||||
<strong>Required env</strong>
|
||||
<span>{configRequirements.requiredEnv.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{configRequirements.stateDirs?.length ? (
|
||||
<div className="stat">
|
||||
<strong>State dirs</strong>
|
||||
<span>{configRequirements.stateDirs.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{cliHelp ? (
|
||||
<details className="bundle-section bundle-details">
|
||||
<summary>CLI help (from plugin)</summary>
|
||||
<pre className="hero-install-code mono">{cliHelp}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="detail-mobile-master-panel detail-mobile-master-stats"
|
||||
id="skill-mobile-master-panel-stats"
|
||||
role="tabpanel"
|
||||
aria-labelledby="skill-mobile-master-tab-stats"
|
||||
hidden={mobileDetailPanel !== "stats"}
|
||||
>
|
||||
{mobileStatsContent}
|
||||
</div>
|
||||
</div>
|
||||
</DetailHero>
|
||||
</DetailPageShell>
|
||||
{pageOverlays}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SignedInActionTooltip({
|
||||
children,
|
||||
isAuthenticated,
|
||||
message,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
isAuthenticated: boolean;
|
||||
message: string;
|
||||
}) {
|
||||
if (isAuthenticated) return children;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center">
|
||||
{message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillSidebarDeferredStats({
|
||||
skill,
|
||||
owner,
|
||||
ownerHandle,
|
||||
formattedStats,
|
||||
latestVersion,
|
||||
showArchiveMetadata,
|
||||
securityAuditSummary,
|
||||
activityTrend,
|
||||
activityTrendLoading = false,
|
||||
hideCreator = false,
|
||||
}: {
|
||||
skill: SkillDetailViewSkill;
|
||||
owner: PublicPublisher | null;
|
||||
ownerHandle: string | null;
|
||||
formattedStats: ReturnType<typeof formatSkillStatsTriplet>;
|
||||
latestVersion: SkillDetailLatestVersion;
|
||||
showArchiveMetadata: boolean;
|
||||
securityAuditSummary?: ReactNode;
|
||||
activityTrend?: ActivityTrend | null;
|
||||
activityTrendLoading?: boolean;
|
||||
hideCreator?: boolean;
|
||||
}) {
|
||||
const githubRepositoryLink = getGitHubRepositoryLink(skill);
|
||||
const downloadsMetricBlock = useDownloadsSidebarMetricBlock({
|
||||
allTimeDownloads: skill.stats.downloads,
|
||||
activityTrend: activityTrend?.downloads,
|
||||
loading: activityTrendLoading,
|
||||
});
|
||||
|
||||
return (
|
||||
<SidebarMetadata
|
||||
ariaLabel="Skill metadata"
|
||||
density="compact"
|
||||
className="skill-sidebar-deferred-metadata"
|
||||
blocks={[
|
||||
activityTrend || activityTrendLoading
|
||||
? downloadsMetricBlock
|
||||
: {
|
||||
label: <ActivityMetricLabel label="Downloads" />,
|
||||
value: formattedStats.downloads,
|
||||
large: true,
|
||||
},
|
||||
{ label: "Repository", value: githubRepositoryLink },
|
||||
...(hideCreator
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: "Creator",
|
||||
value: (
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
size="md"
|
||||
showName
|
||||
showHandle={false}
|
||||
showMutedHandle
|
||||
disableTooltip
|
||||
/>
|
||||
),
|
||||
},
|
||||
]),
|
||||
securityAuditSummary
|
||||
? {
|
||||
key: "security-audit",
|
||||
label: <DetailSecuritySummaryLabel />,
|
||||
value: securityAuditSummary,
|
||||
}
|
||||
: { label: "", value: null },
|
||||
...(showArchiveMetadata
|
||||
? [
|
||||
{
|
||||
grid: [
|
||||
{
|
||||
label: "Last updated",
|
||||
value: (
|
||||
<span title={new Date(skill.updatedAt).toLocaleString()}>
|
||||
{timeAgo(skill.updatedAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Current version",
|
||||
value: latestVersion?.version ? `v${latestVersion.version}` : "None",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ label: "License", value: PLATFORM_SKILL_LICENSE },
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: "Last updated",
|
||||
value: (
|
||||
<span title={new Date(skill.updatedAt).toLocaleString()}>
|
||||
{timeAgo(skill.updatedAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,750 +0,0 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
|
||||
import { Bookmark, Flag, Settings, ShieldCheck, Upload } from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import type { ActivityTrend } from "../lib/activityTrend";
|
||||
import { getSkillBadges, isSkillOfficial } from "../lib/badges";
|
||||
import { BrowseCategoryIcon } from "../lib/browseCategoryIcons";
|
||||
import {
|
||||
buildSkillCategoryBrowseHref,
|
||||
buildSkillTopicBrowseHref,
|
||||
formatCatalogTopicLabel,
|
||||
type SkillCategory,
|
||||
} from "../lib/categories";
|
||||
import { formatSkillStatsTriplet } from "../lib/numberFormat";
|
||||
import { buildPublisherProfileHref } from "../lib/ownerRoute";
|
||||
import { presentationTitle } from "../lib/presentationTitle";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { useHeroCreatorPublisher } from "../lib/useHeroCreatorPublisher";
|
||||
import { useMediaQuery } from "../lib/useMediaQuery";
|
||||
import { ActivityMetricLabel } from "./ActivityMetricLabel";
|
||||
import { DetailHero, DETAIL_HERO_TOPIC_LIMIT } from "./DetailPageShell";
|
||||
import { DetailSecuritySummaryLabel } from "./DetailSecuritySummary";
|
||||
import { useDownloadsSidebarMetricBlock } from "./DownloadsMetricCard";
|
||||
import { InlineCodeSummary } from "./InlineCodeSummary";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import { SidebarMetadata } from "./SidebarMetadata";
|
||||
import { buildSkillHref } from "./skillDetailUtils";
|
||||
import { SkillCommandLineCard } from "./SkillInstallSurface";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { UserBadge } from "./UserBadge";
|
||||
|
||||
type SkillModerationInfo = {
|
||||
isPendingScan: boolean;
|
||||
isMalwareBlocked: boolean;
|
||||
isSuspicious: boolean;
|
||||
isHiddenByMod: boolean;
|
||||
isRemoved: boolean;
|
||||
overrideActive?: boolean;
|
||||
verdict?: "clean" | "suspicious" | "malicious";
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type SkillFork = {
|
||||
kind: "fork" | "duplicate";
|
||||
version: string | null;
|
||||
skill: { slug: string; displayName: string };
|
||||
owner: { handle: string | null; userId: Id<"users"> | null };
|
||||
};
|
||||
|
||||
type SkillCanonical = {
|
||||
skill: { slug: string; displayName: string };
|
||||
owner: { handle: string | null; userId: Id<"users"> | null };
|
||||
};
|
||||
|
||||
const SUMMARY_COLLAPSE_THRESHOLD = 220;
|
||||
|
||||
type MobileDetailPanel = "content" | "stats";
|
||||
|
||||
function formatHeaderTopic(topic: string) {
|
||||
return formatCatalogTopicLabel(topic);
|
||||
}
|
||||
|
||||
type SkillHeaderLatestVersion =
|
||||
| (Omit<Doc<"skillVersions">, "parsed"> & {
|
||||
parsed?: (Partial<Doc<"skillVersions">["parsed"]> & { description?: string }) | null;
|
||||
})
|
||||
| null;
|
||||
|
||||
function getLatestVersionDescription(latestVersion: SkillHeaderLatestVersion) {
|
||||
const parsed = latestVersion?.parsed;
|
||||
const description =
|
||||
typeof parsed?.presentation?.summary === "string"
|
||||
? parsed.presentation.summary
|
||||
: typeof parsed?.description === "string"
|
||||
? parsed.description
|
||||
: typeof parsed?.frontmatter?.description === "string"
|
||||
? parsed.frontmatter.description
|
||||
: null;
|
||||
return description?.trim() || null;
|
||||
}
|
||||
|
||||
function getGitHubRepositoryLink(skill: Doc<"skills"> | PublicSkill) {
|
||||
const repo = "githubSourceRepo" in skill ? skill.githubSourceRepo : undefined;
|
||||
if (skill.installKind !== "github" || !repo) return null;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`https://github.com/${repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="plugin-external-link"
|
||||
>
|
||||
{repo}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
type SkillHeaderProps = {
|
||||
skill: Doc<"skills"> | PublicSkill;
|
||||
owner: PublicPublisher | null;
|
||||
ownerHandle: string | null;
|
||||
latestVersion: SkillHeaderLatestVersion;
|
||||
modInfo: SkillModerationInfo | null;
|
||||
canManage: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isStaff: boolean;
|
||||
isStarred: boolean | undefined;
|
||||
onToggleStar: () => void;
|
||||
onOpenReport: () => void;
|
||||
onRequireSignIn: () => void;
|
||||
forkOf: SkillFork | null;
|
||||
forkOfLabel: string;
|
||||
forkOfHref: string | null;
|
||||
forkOfOwnerHandle: string | null;
|
||||
canonical: SkillCanonical | null;
|
||||
canonicalHref: string | null;
|
||||
canonicalOwnerHandle: string | null;
|
||||
staffVisibilityTag: string | null;
|
||||
isAutoHidden: boolean;
|
||||
isRemoved: boolean;
|
||||
nixPlugin: string | undefined;
|
||||
hasPluginBundle: boolean;
|
||||
configRequirements: ClawdisSkillMetadata["config"] | undefined;
|
||||
cliHelp: string | undefined;
|
||||
clawdis: ClawdisSkillMetadata | undefined;
|
||||
category?: SkillCategory | null;
|
||||
categories?: SkillCategory[] | null;
|
||||
staffVisibilityAlert?: ReactNode;
|
||||
postInstallContent?: ReactNode;
|
||||
securityAuditSummary?: ReactNode;
|
||||
activityTrend?: ActivityTrend | null;
|
||||
activityTrendLoading?: boolean;
|
||||
newVersionHref?: string | null;
|
||||
settingsHref?: string | null;
|
||||
showArchiveMetadata?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function SkillHeader({
|
||||
skill,
|
||||
owner,
|
||||
ownerHandle,
|
||||
latestVersion,
|
||||
modInfo,
|
||||
canManage,
|
||||
isAuthenticated,
|
||||
isStaff,
|
||||
isStarred,
|
||||
onToggleStar,
|
||||
onOpenReport,
|
||||
onRequireSignIn,
|
||||
forkOf,
|
||||
forkOfLabel,
|
||||
forkOfHref,
|
||||
forkOfOwnerHandle,
|
||||
canonical,
|
||||
canonicalHref,
|
||||
canonicalOwnerHandle,
|
||||
nixPlugin,
|
||||
hasPluginBundle,
|
||||
configRequirements,
|
||||
cliHelp,
|
||||
clawdis,
|
||||
category,
|
||||
categories,
|
||||
staffVisibilityAlert,
|
||||
postInstallContent,
|
||||
securityAuditSummary,
|
||||
activityTrend,
|
||||
activityTrendLoading = false,
|
||||
newVersionHref,
|
||||
settingsHref,
|
||||
showArchiveMetadata = true,
|
||||
children,
|
||||
}: SkillHeaderProps) {
|
||||
const formattedStats = formatSkillStatsTriplet(skill.stats);
|
||||
const installOwnerId = owner?._id ?? skill.ownerPublisherId ?? skill.ownerUserId ?? null;
|
||||
const hasOwnerActions = Boolean(newVersionHref) || Boolean(settingsHref);
|
||||
const showReportAction = !canManage || isStaff;
|
||||
const badges = getSkillBadges(skill);
|
||||
const titleBadges = badges.filter((badge) => badge !== "Official");
|
||||
const heroCreatorPublisher = useHeroCreatorPublisher({
|
||||
owner,
|
||||
skillOfficial: isSkillOfficial(skill),
|
||||
});
|
||||
const showHeroMeta = Boolean((forkOf && forkOfHref) || canonicalHref);
|
||||
const showTitleBadges = titleBadges.length > 0;
|
||||
const headerDescription =
|
||||
getLatestVersionDescription(latestVersion) ?? skill.summary ?? "No summary provided.";
|
||||
const headerTopics = (skill.topics ?? [])
|
||||
.map((topic) => topic.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, DETAIL_HERO_TOPIC_LIMIT);
|
||||
const headerCategories = (categories ?? (category ? [category] : [])).slice(0, 3);
|
||||
const hasSummaryToggle = headerDescription.length > SUMMARY_COLLAPSE_THRESHOLD;
|
||||
const [isSummaryExpanded, setIsSummaryExpanded] = useState(false);
|
||||
const [mobileDetailPanel, setMobileDetailPanel] = useState<MobileDetailPanel>("content");
|
||||
const isMobileDetailLayout = useMediaQuery("(max-width: 900px)");
|
||||
|
||||
const renderStarAction = () => (
|
||||
<SignedInActionTooltip
|
||||
isAuthenticated={isAuthenticated}
|
||||
message="You must be signed in to bookmark a skill"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="skill-sidebar-action-link skill-sidebar-star-action"
|
||||
onClick={isAuthenticated ? onToggleStar : onRequireSignIn}
|
||||
aria-pressed={Boolean(isAuthenticated && isStarred)}
|
||||
aria-label={isStarred ? "Unbookmark skill" : "Bookmark skill"}
|
||||
>
|
||||
<Bookmark
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
fill={isAuthenticated && isStarred ? "currentColor" : "none"}
|
||||
/>
|
||||
{isAuthenticated && isStarred ? "Unbookmark" : "Bookmark"}
|
||||
<span className="skill-action-count">{formattedStats.stars}</span>
|
||||
</button>
|
||||
</SignedInActionTooltip>
|
||||
);
|
||||
|
||||
const renderSidebarActions = () => {
|
||||
if (!showReportAction) return null;
|
||||
return (
|
||||
<div className="skill-sidebar-actions skill-sidebar-actions-secondary">
|
||||
<SignedInActionTooltip
|
||||
isAuthenticated={isAuthenticated}
|
||||
message="You must be signed in to report a skill"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="skill-sidebar-action-link"
|
||||
onClick={isAuthenticated ? onOpenReport : onRequireSignIn}
|
||||
>
|
||||
<Flag size={14} aria-hidden="true" />
|
||||
Report
|
||||
</button>
|
||||
</SignedInActionTooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const managementToolbar =
|
||||
hasOwnerActions || isStaff || staffVisibilityAlert ? (
|
||||
<div className="skill-management-toolbar">
|
||||
{staffVisibilityAlert ? (
|
||||
<div className="skill-management-toolbar-alert">{staffVisibilityAlert}</div>
|
||||
) : null}
|
||||
{hasOwnerActions || isStaff ? (
|
||||
<div className="skill-management-toolbar-inner">
|
||||
{newVersionHref ? (
|
||||
<Button asChild variant="ghost" size="xs" className="skill-management-toolbar-action">
|
||||
<a href={newVersionHref} aria-label="New version">
|
||||
<Upload size={13} aria-hidden="true" />
|
||||
New version
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{settingsHref ? (
|
||||
<Button asChild variant="ghost" size="xs" className="skill-management-toolbar-action">
|
||||
<a href={settingsHref} aria-label="Settings">
|
||||
<Settings size={13} aria-hidden="true" />
|
||||
Settings
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<Button asChild variant="ghost" size="xs" className="skill-management-toolbar-action">
|
||||
<Link to="/management" search={{ skill: skill.slug, plugin: undefined }}>
|
||||
<ShieldCheck size={13} aria-hidden="true" />
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const desktopStatsContent = (
|
||||
<>
|
||||
<SkillSidebarDeferredStats
|
||||
skill={skill}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
formattedStats={formattedStats}
|
||||
latestVersion={latestVersion}
|
||||
showArchiveMetadata={showArchiveMetadata}
|
||||
securityAuditSummary={securityAuditSummary}
|
||||
activityTrend={activityTrend}
|
||||
activityTrendLoading={activityTrendLoading}
|
||||
hideCreator
|
||||
/>
|
||||
{renderSidebarActions()}
|
||||
</>
|
||||
);
|
||||
|
||||
const mobileStatsContent = (
|
||||
<>
|
||||
<SkillSidebarDeferredStats
|
||||
skill={skill}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
formattedStats={formattedStats}
|
||||
latestVersion={latestVersion}
|
||||
showArchiveMetadata={showArchiveMetadata}
|
||||
securityAuditSummary={securityAuditSummary}
|
||||
activityTrend={activityTrend}
|
||||
activityTrendLoading={activityTrendLoading}
|
||||
hideCreator
|
||||
/>
|
||||
{renderSidebarActions()}
|
||||
</>
|
||||
);
|
||||
const displayName = presentationTitle(skill.displayName, skill.slug);
|
||||
|
||||
return (
|
||||
<>
|
||||
{modInfo?.isPendingScan ? (
|
||||
<div className="pending-banner">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Security scan in progress</strong>
|
||||
<p>
|
||||
Your skill is being scanned by VirusTotal. It will be visible to others once the scan
|
||||
completes. This usually takes up to 5 minutes — grab a coffee or exfoliate your shell
|
||||
while you wait.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isRemoved ? (
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill removed by moderator</strong>
|
||||
<p>This skill has been removed and is not visible to others.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isHiddenByMod ? (
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill hidden</strong>
|
||||
<p>This skill is currently hidden and not visible to others.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{managementToolbar}
|
||||
|
||||
<DetailHero
|
||||
topClassName={hasPluginBundle ? "has-plugin" : undefined}
|
||||
sidebar={
|
||||
<div className="skill-hero-sidebar-stack">
|
||||
{!isMobileDetailLayout ? (
|
||||
<div className="skill-sidebar-star-band detail-hero-summary-row">
|
||||
{renderStarAction()}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="detail-sidebar-stats">{desktopStatsContent}</div>
|
||||
</div>
|
||||
}
|
||||
main={
|
||||
<>
|
||||
<div className="skill-hero-title">
|
||||
<nav className="skill-hero-breadcrumbs" aria-label="Skill breadcrumbs">
|
||||
<a href="/skills">skills</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
<a href={ownerHandle ? buildPublisherProfileHref(ownerHandle) : "#"}>
|
||||
{ownerHandle ?? owner?.displayName ?? owner?._id ?? "unknown"}
|
||||
</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
<a
|
||||
href={buildSkillHref(ownerHandle, owner?._id ?? null, skill.slug)}
|
||||
aria-current="page"
|
||||
>
|
||||
{skill.slug}
|
||||
</a>
|
||||
</nav>
|
||||
<div className="skill-hero-heading-stack">
|
||||
{headerCategories.length > 0 || headerTopics.length > 0 ? (
|
||||
<div className="skill-hero-taxonomy-row" aria-label="Skill metadata">
|
||||
{headerCategories.length > 0 ? (
|
||||
<div className="skill-category-meta-list" aria-label="Categories">
|
||||
{headerCategories.map((categoryItem) => (
|
||||
<a
|
||||
key={categoryItem.slug}
|
||||
className="skill-category-meta-link"
|
||||
href={buildSkillCategoryBrowseHref(categoryItem)}
|
||||
aria-label={`View ${categoryItem.label} skills`}
|
||||
>
|
||||
<BrowseCategoryIcon
|
||||
slug={categoryItem.slug}
|
||||
icon={categoryItem.icon}
|
||||
size={14}
|
||||
className="skill-category-icon"
|
||||
/>
|
||||
<span>{categoryItem.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{headerCategories.length > 0 && headerTopics.length > 0 ? (
|
||||
<span className="skill-hero-taxonomy-separator" aria-hidden="true" />
|
||||
) : null}
|
||||
{headerTopics.length > 0 ? (
|
||||
<div className="skill-hero-topic-list" aria-label="Topics">
|
||||
{headerTopics.map((topic) => (
|
||||
<a
|
||||
key={topic}
|
||||
className="skill-hero-topic"
|
||||
href={buildSkillTopicBrowseHref(topic)}
|
||||
aria-label={`View skills tagged ${formatHeaderTopic(topic)}`}
|
||||
>
|
||||
{formatHeaderTopic(topic)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="skill-hero-title-row">
|
||||
<MarketplaceIcon
|
||||
kind="skill"
|
||||
label={displayName}
|
||||
imageUrl={skill.icon}
|
||||
skill={skill}
|
||||
size="md"
|
||||
/>
|
||||
<h1 className="skill-page-title">{displayName}</h1>
|
||||
{showTitleBadges ? (
|
||||
<div className="skill-title-badges">
|
||||
{titleBadges.map((badge) => (
|
||||
<Badge key={badge} variant="compact">
|
||||
{badge}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
</div>
|
||||
{showHeroMeta ? (
|
||||
<div className="skill-hero-meta-row" aria-label="Skill lineage">
|
||||
{forkOf && forkOfHref ? (
|
||||
<span className="skill-hero-meta-item">
|
||||
<span className="skill-hero-meta-label">{forkOfLabel}</span>
|
||||
<a className="skill-hero-meta-link" href={forkOfHref}>
|
||||
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
|
||||
{forkOf.skill.slug}
|
||||
</a>
|
||||
{forkOf.version ? (
|
||||
<span className="skill-hero-meta-version">({forkOf.version})</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{canonicalHref ? (
|
||||
<span className="skill-hero-meta-item">
|
||||
<span className="skill-hero-meta-label">canonical</span>
|
||||
<a className="skill-hero-meta-link" href={canonicalHref}>
|
||||
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
|
||||
{canonical?.skill?.slug}
|
||||
</a>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="skill-summary-block">
|
||||
<p
|
||||
className={`section-subtitle skill-summary-line${
|
||||
hasSummaryToggle && !isSummaryExpanded ? " line-clamp-2" : ""
|
||||
}`}
|
||||
>
|
||||
<InlineCodeSummary>{headerDescription}</InlineCodeSummary>
|
||||
</p>
|
||||
{hasSummaryToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
className="skill-summary-toggle"
|
||||
aria-expanded={isSummaryExpanded}
|
||||
onClick={() => setIsSummaryExpanded((expanded) => !expanded)}
|
||||
>
|
||||
{isSummaryExpanded ? "Show less" : "Read more"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{owner || ownerHandle ? (
|
||||
<div className="skill-hero-creator">
|
||||
<UserBadge
|
||||
user={heroCreatorPublisher}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
size="md"
|
||||
showName
|
||||
showHandle={false}
|
||||
showMutedHandle
|
||||
stackMutedHandleBelowName
|
||||
disableTooltip
|
||||
/>
|
||||
{isMobileDetailLayout ? (
|
||||
<div className="skill-hero-creator-star">{renderStarAction()}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{nixPlugin ? (
|
||||
<div className="skill-hero-note">
|
||||
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="detail-mobile-install">
|
||||
<SkillCommandLineCard
|
||||
slug={skill.slug}
|
||||
displayName={skill.displayName}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={installOwnerId}
|
||||
clawdis={clawdis}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="detail-mobile-master-tabs" data-active={mobileDetailPanel}>
|
||||
<div
|
||||
className="detail-mobile-master-tab-list"
|
||||
role="tablist"
|
||||
aria-label="Skill mobile sections"
|
||||
>
|
||||
<button
|
||||
id="skill-mobile-master-tab-content"
|
||||
className={`detail-mobile-master-tab${
|
||||
mobileDetailPanel === "content" ? " is-active" : ""
|
||||
}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileDetailPanel === "content"}
|
||||
aria-controls="skill-mobile-master-panel-content"
|
||||
onClick={() => setMobileDetailPanel("content")}
|
||||
>
|
||||
SKILL.md
|
||||
</button>
|
||||
<button
|
||||
id="skill-mobile-master-tab-stats"
|
||||
className={`detail-mobile-master-tab${
|
||||
mobileDetailPanel === "stats" ? " is-active" : ""
|
||||
}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileDetailPanel === "stats"}
|
||||
aria-controls="skill-mobile-master-panel-stats"
|
||||
onClick={() => setMobileDetailPanel("stats")}
|
||||
>
|
||||
Stats & details
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="detail-mobile-master-panel detail-mobile-master-panel-content"
|
||||
id="skill-mobile-master-panel-content"
|
||||
role="tabpanel"
|
||||
aria-labelledby="skill-mobile-master-tab-content"
|
||||
hidden={mobileDetailPanel !== "content"}
|
||||
>
|
||||
{postInstallContent}
|
||||
|
||||
{children}
|
||||
|
||||
{hasPluginBundle ? (
|
||||
<div className="skill-panel bundle-card">
|
||||
<div className="bundle-header">
|
||||
<div className="bundle-title">Plugin bundle (nix)</div>
|
||||
<div className="bundle-subtitle">Skill pack · CLI binary · Config</div>
|
||||
</div>
|
||||
<div className="bundle-includes">
|
||||
<span>SKILL.md</span>
|
||||
<span>CLI</span>
|
||||
<span>Config</span>
|
||||
</div>
|
||||
{configRequirements ? (
|
||||
<div className="bundle-section">
|
||||
<div className="bundle-section-title">Config requirements</div>
|
||||
<div className="bundle-meta">
|
||||
{configRequirements.requiredEnv?.length ? (
|
||||
<div className="stat">
|
||||
<strong>Required env</strong>
|
||||
<span>{configRequirements.requiredEnv.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{configRequirements.stateDirs?.length ? (
|
||||
<div className="stat">
|
||||
<strong>State dirs</strong>
|
||||
<span>{configRequirements.stateDirs.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{cliHelp ? (
|
||||
<details className="bundle-section bundle-details">
|
||||
<summary>CLI help (from plugin)</summary>
|
||||
<pre className="hero-install-code mono">{cliHelp}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="detail-mobile-master-panel detail-mobile-master-stats"
|
||||
id="skill-mobile-master-panel-stats"
|
||||
role="tabpanel"
|
||||
aria-labelledby="skill-mobile-master-tab-stats"
|
||||
hidden={mobileDetailPanel !== "stats"}
|
||||
>
|
||||
{mobileStatsContent}
|
||||
</div>
|
||||
</div>
|
||||
</DetailHero>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SignedInActionTooltip({
|
||||
children,
|
||||
isAuthenticated,
|
||||
message,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
isAuthenticated: boolean;
|
||||
message: string;
|
||||
}) {
|
||||
if (isAuthenticated) return children;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center">
|
||||
{message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillSidebarDeferredStats({
|
||||
skill,
|
||||
owner,
|
||||
ownerHandle,
|
||||
formattedStats,
|
||||
latestVersion,
|
||||
showArchiveMetadata,
|
||||
securityAuditSummary,
|
||||
activityTrend,
|
||||
activityTrendLoading = false,
|
||||
hideCreator = false,
|
||||
}: {
|
||||
skill: Doc<"skills"> | PublicSkill;
|
||||
owner: PublicPublisher | null;
|
||||
ownerHandle: string | null;
|
||||
formattedStats: ReturnType<typeof formatSkillStatsTriplet>;
|
||||
latestVersion: SkillHeaderLatestVersion;
|
||||
showArchiveMetadata: boolean;
|
||||
securityAuditSummary?: ReactNode;
|
||||
activityTrend?: ActivityTrend | null;
|
||||
activityTrendLoading?: boolean;
|
||||
hideCreator?: boolean;
|
||||
}) {
|
||||
const githubRepositoryLink = getGitHubRepositoryLink(skill);
|
||||
const downloadsMetricBlock = useDownloadsSidebarMetricBlock({
|
||||
allTimeDownloads: skill.stats.downloads,
|
||||
activityTrend: activityTrend?.downloads,
|
||||
loading: activityTrendLoading,
|
||||
});
|
||||
|
||||
return (
|
||||
<SidebarMetadata
|
||||
ariaLabel="Skill metadata"
|
||||
density="compact"
|
||||
className="skill-sidebar-deferred-metadata"
|
||||
blocks={[
|
||||
activityTrend || activityTrendLoading
|
||||
? downloadsMetricBlock
|
||||
: {
|
||||
label: <ActivityMetricLabel label="Downloads" />,
|
||||
value: formattedStats.downloads,
|
||||
large: true,
|
||||
},
|
||||
{ label: "Repository", value: githubRepositoryLink },
|
||||
...(hideCreator
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: "Creator",
|
||||
value: (
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
size="md"
|
||||
showName
|
||||
showHandle={false}
|
||||
showMutedHandle
|
||||
disableTooltip
|
||||
/>
|
||||
),
|
||||
},
|
||||
]),
|
||||
securityAuditSummary
|
||||
? {
|
||||
key: "security-audit",
|
||||
label: <DetailSecuritySummaryLabel />,
|
||||
value: securityAuditSummary,
|
||||
}
|
||||
: { label: "", value: null },
|
||||
...(showArchiveMetadata
|
||||
? [
|
||||
{
|
||||
grid: [
|
||||
{
|
||||
label: "Last updated",
|
||||
value: (
|
||||
<span title={new Date(skill.updatedAt).toLocaleString()}>
|
||||
{timeAgo(skill.updatedAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Current version",
|
||||
value: latestVersion?.version ? `v${latestVersion.version}` : "None",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ label: "License", value: PLATFORM_SKILL_LICENSE },
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: "Last updated",
|
||||
value: (
|
||||
<span title={new Date(skill.updatedAt).toLocaleString()}>
|
||||
{timeAgo(skill.updatedAt)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,13 @@ type SkillInstallSurfaceProps = {
|
||||
ownerHandle: string | null;
|
||||
ownerId: Id<"users"> | Id<"publishers"> | null;
|
||||
clawdis?: ClawdisSkillMetadata;
|
||||
installTarget?: string;
|
||||
skillPageUrl?: string | null;
|
||||
secondaryInstall?: {
|
||||
label: string;
|
||||
command: string;
|
||||
copyAriaLabel: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function SkillInstallSurface({
|
||||
@@ -52,6 +59,8 @@ export function SkillInstallSurface({
|
||||
ownerHandle,
|
||||
ownerId,
|
||||
clawdis,
|
||||
installTarget: installTargetOverride,
|
||||
skillPageUrl: skillPageUrlOverride,
|
||||
}: SkillInstallSurfaceProps) {
|
||||
const headingId = useId();
|
||||
const [promptMode, setPromptMode] = useState<SkillPromptMode>("install-and-setup");
|
||||
@@ -80,7 +89,8 @@ export function SkillInstallSurface({
|
||||
|
||||
const selectedPrompt =
|
||||
PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1];
|
||||
const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug);
|
||||
const installTarget =
|
||||
installTargetOverride ?? buildSkillInstallTarget(ownerHandle, ownerId, slug);
|
||||
const promptPreview = formatOpenClawPrompt({
|
||||
mode: promptMode,
|
||||
skillName: displayName,
|
||||
@@ -88,6 +98,8 @@ export function SkillInstallSurface({
|
||||
ownerHandle,
|
||||
ownerId,
|
||||
clawdis,
|
||||
installTarget: installTargetOverride,
|
||||
skillPageUrl: skillPageUrlOverride,
|
||||
});
|
||||
|
||||
const promptFeedback =
|
||||
@@ -105,6 +117,8 @@ export function SkillInstallSurface({
|
||||
ownerHandle,
|
||||
ownerId,
|
||||
clawdis,
|
||||
installTarget: installTargetOverride,
|
||||
skillPageUrl: skillPageUrlOverride,
|
||||
});
|
||||
|
||||
setPromptMode(mode);
|
||||
@@ -193,15 +207,24 @@ export function SkillCommandLineCard({
|
||||
ownerHandle,
|
||||
ownerId,
|
||||
clawdis,
|
||||
installTarget: installTargetOverride,
|
||||
skillPageUrl: skillPageUrlOverride,
|
||||
secondaryInstall,
|
||||
}: SkillInstallSurfaceProps) {
|
||||
const headingId = useId();
|
||||
type InstallTab = "cli" | "skills" | "prompt";
|
||||
const [activeInstallTab, setActiveInstallTab] = useState<InstallTab>("cli");
|
||||
const [installTabDirection, setInstallTabDirection] = useState<"left" | "right">("right");
|
||||
const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug);
|
||||
const installTarget =
|
||||
installTargetOverride ?? buildSkillInstallTarget(ownerHandle, ownerId, slug);
|
||||
const openClawCommand = formatOpenClawInstallCommand(installTarget);
|
||||
const skillPageUrl = buildSkillPageUrl(ownerHandle, ownerId, slug);
|
||||
const skillsCliCommand = skillPageUrl ? formatSkillsCliInstallCommand(skillPageUrl) : null;
|
||||
const skillPageUrl =
|
||||
skillPageUrlOverride === undefined
|
||||
? buildSkillPageUrl(ownerHandle, ownerId, slug)
|
||||
: skillPageUrlOverride;
|
||||
const skillsCliCommand =
|
||||
secondaryInstall?.command ??
|
||||
(skillPageUrl ? formatSkillsCliInstallCommand(skillPageUrl) : null);
|
||||
const promptPreview = formatOpenClawPrompt({
|
||||
mode: "install-and-setup",
|
||||
skillName: displayName,
|
||||
@@ -209,6 +232,8 @@ export function SkillCommandLineCard({
|
||||
ownerHandle,
|
||||
ownerId,
|
||||
clawdis,
|
||||
installTarget: installTargetOverride,
|
||||
skillPageUrl: skillPageUrlOverride,
|
||||
});
|
||||
const activeInstallText =
|
||||
activeInstallTab === "prompt"
|
||||
@@ -250,7 +275,7 @@ export function SkillCommandLineCard({
|
||||
aria-pressed={activeInstallTab === "skills"}
|
||||
onClick={() => selectInstallTab("skills")}
|
||||
>
|
||||
npx skills
|
||||
{secondaryInstall?.label ?? "npx skills"}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
@@ -295,7 +320,7 @@ export function SkillCommandLineCard({
|
||||
activeInstallTab === "prompt"
|
||||
? "Copy OpenClaw prompt"
|
||||
: activeInstallTab === "skills"
|
||||
? "Copy npx skills command"
|
||||
? (secondaryInstall?.copyAriaLabel ?? "Copy npx skills command")
|
||||
: "Copy OpenClaw CLI command"
|
||||
}
|
||||
className="skill-install-command-inline-button"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
import { SkillRelatedSection } from "./SkillRelatedSection";
|
||||
|
||||
describe("SkillRelatedSection", () => {
|
||||
it("shows only the plain download count in compact related rows", () => {
|
||||
const { container } = render(
|
||||
<SkillRelatedSection
|
||||
category={{
|
||||
slug: "productivity",
|
||||
label: "Productivity",
|
||||
icon: "list-checks",
|
||||
keywords: [],
|
||||
}}
|
||||
relatedSkills={[{ skill: makeSkill(), ownerHandle: "creator" }]}
|
||||
isLoading={false}
|
||||
variant="compact"
|
||||
/>,
|
||||
);
|
||||
|
||||
const stats = screen.getByLabelText("4,321 downloads");
|
||||
expect(stats.textContent).toBe("4.3k");
|
||||
expect(stats.querySelector("svg")).toBeNull();
|
||||
expect(screen.queryByText("654")).toBeNull();
|
||||
expect(screen.queryByText("7.3k")).toBeNull();
|
||||
expect(container.querySelector(".lucide-bookmark")).toBeNull();
|
||||
expect(container.querySelector(".lucide-download")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function makeSkill(): PublicSkill {
|
||||
return {
|
||||
_id: "skills:related" as Id<"skills">,
|
||||
_creationTime: 1,
|
||||
slug: "related",
|
||||
displayName: "Related Skill",
|
||||
summary: "A related skill.",
|
||||
icon: undefined,
|
||||
ownerUserId: "users:owner" as Id<"users">,
|
||||
ownerPublisherId: "publishers:owner" as Id<"publishers">,
|
||||
canonicalSkillId: undefined,
|
||||
forkOf: undefined,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
downloads: 4_321,
|
||||
stars: 654,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
installs: 7_300,
|
||||
},
|
||||
isSuspicious: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Bookmark, Download } from "lucide-react";
|
||||
import { BrowseCategoryIcon } from "../lib/browseCategoryIcons";
|
||||
import { buildSkillCategoryBrowseHref, type SkillCategory } from "../lib/categories";
|
||||
import { formatSkillStatsTriplet } from "../lib/numberFormat";
|
||||
@@ -107,15 +106,11 @@ export function SkillRelatedSection({
|
||||
) : null}
|
||||
</span>
|
||||
{isCompact ? (
|
||||
<span className="related-skill-stats" aria-label="Related skill stats">
|
||||
<span className="related-skill-stat">
|
||||
<Bookmark size={13} aria-hidden="true" />
|
||||
{formattedStats.stars}
|
||||
</span>
|
||||
<span className="related-skill-stat">
|
||||
<Download size={13} aria-hidden="true" />
|
||||
{formattedStats.installs}
|
||||
</span>
|
||||
<span
|
||||
className="related-skill-stats"
|
||||
aria-label={`${entry.skill.stats.downloads.toLocaleString()} downloads`}
|
||||
>
|
||||
{formattedStats.downloads}
|
||||
</span>
|
||||
) : (
|
||||
<span className="related-skill-owner">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SkillsShCatalogDetail } from "../lib/skillsShCatalog";
|
||||
@@ -18,34 +18,67 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
}) => <a href={`${to}?${new URLSearchParams(search).toString()}`}>{children}</a>,
|
||||
}));
|
||||
|
||||
describe("SkillsShCatalogDetailPage", () => {
|
||||
it("shows the external trust boundary, upstream checks, provenance, and freshness", () => {
|
||||
render(<SkillsShCatalogDetailPage entry={makeEntry()} />);
|
||||
vi.mock("../lib/useHeroCreatorPublisher", () => ({
|
||||
useHeroCreatorPublisher: ({ owner }: { owner?: unknown }) => owner,
|
||||
}));
|
||||
|
||||
expect(screen.getAllByText("Not scanned by ClawHub").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Gen Agent Trust Hub")).toBeTruthy();
|
||||
expect(screen.getByText("Socket")).toBeTruthy();
|
||||
expect(screen.getByText("Snyk")).toBeTruthy();
|
||||
expect(screen.getByText("Upstream checks are separate from ClawHub scanning.")).toBeTruthy();
|
||||
expect(screen.getAllByText("Observed 1m ago").length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("link", { name: /View on skills\.sh/i }).getAttribute("href")).toBe(
|
||||
describe("SkillsShCatalogDetailPage", () => {
|
||||
it("shows skills.sh provenance and simple security audit rows", () => {
|
||||
const { container } = render(<SkillsShCatalogDetailPage entry={makeEntry()} />);
|
||||
|
||||
expect(screen.queryByText("Not scanned by ClawHub")).toBeNull();
|
||||
expect(screen.queryByText("ClawHub")).toBeNull();
|
||||
expect(screen.getAllByText("Gen Agent Trust Hub").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Socket").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Snyk").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("Separate from ClawHub scanning.")).toBeNull();
|
||||
expect(screen.queryByText("Observed")).toBeNull();
|
||||
expect(screen.queryByText("Trust")).toBeNull();
|
||||
expect(screen.queryByText("Path")).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: /View on skills\.sh/i })).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "skills.sh" }).getAttribute("href")).toBe(
|
||||
"https://skills.sh/patrick-erichsen/skills/html",
|
||||
);
|
||||
expect(screen.getByText("Synced from").closest("a")).toBeNull();
|
||||
expect(screen.getAllByText("100").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Downloads").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("Upstream installs")).toBeNull();
|
||||
expect(screen.getByText("HTML Artifact Chooser Build useful artifacts.")).toBeTruthy();
|
||||
expect(container.querySelector(".skill-hero-layout.has-sidebar")).toBeTruthy();
|
||||
expect(container.querySelector(".skill-hero-sidebar")?.textContent).toContain(
|
||||
"Security Audits",
|
||||
);
|
||||
const audits = screen.getByRole("region", { name: "Security Audits" });
|
||||
expect(audits.querySelectorAll(".skills-sh-security-audit-row")).toHaveLength(3);
|
||||
expect(audits.querySelector("h2")?.classList.contains("sidebar-metadata-label")).toBe(true);
|
||||
const verdicts = audits.querySelectorAll(".skills-sh-security-audit-verdict");
|
||||
expect(verdicts).toHaveLength(3);
|
||||
expect(verdicts[1]?.className).toContain("bg-status-success-bg");
|
||||
expect(verdicts[2]?.className).toContain("bg-status-warning-bg");
|
||||
expect(container.querySelector(".skills-sh-detail-trust-alert")).toBeNull();
|
||||
expect(container.querySelector(".skills-sh-detail-source-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows colon-form install commands and a preselected GitHub Skill Sync claim", () => {
|
||||
it("shows the GitHub owner, repository, and a preselected GitHub Skill Sync claim", () => {
|
||||
render(<SkillsShCatalogDetailPage entry={makeEntry()} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("openclaw skills install skills-sh:patrick-erichsen/skills/html", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("clawhub install skills-sh:patrick-erichsen/skills/html", { exact: true }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("openclaw skills install")).toBeTruthy();
|
||||
expect(screen.getByText("skills-sh:patrick-erichsen/skills/html")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "ClawHub" })).toBeNull();
|
||||
const owner = screen.getByRole("link", { name: "View openclaw profile" });
|
||||
expect(owner.getAttribute("href")).toBe("https://github.com/openclaw");
|
||||
expect(owner.querySelector("img")?.getAttribute("src")).toBe(
|
||||
"https://github.com/openclaw.png?size=96",
|
||||
);
|
||||
const repository = screen.getByRole("link", { name: "openclaw/openclaw" });
|
||||
expect(repository.getAttribute("href")).toBe(
|
||||
"https://github.com/openclaw/openclaw/tree/050daba89f6b6636470add5cb300aac46a412cf8/skills/html",
|
||||
);
|
||||
expect(repository.classList.contains("plugin-external-link")).toBe(true);
|
||||
expect(repository.querySelector("svg")).toBeTruthy();
|
||||
expect(screen.queryByRole("link", { name: "View on skills.sh" })).toBeNull();
|
||||
const claimUrl = new URL(
|
||||
screen.getByRole("link", { name: "Claim" }).getAttribute("href") ?? "",
|
||||
screen.getByRole("link", { name: "Claim this skill" }).getAttribute("href") ?? "",
|
||||
"https://clawhub.test",
|
||||
);
|
||||
expect(claimUrl.pathname).toBe("/settings");
|
||||
@@ -64,13 +97,51 @@ describe("SkillsShCatalogDetailPage", () => {
|
||||
it("renders only stored bounded content and no file explorer", () => {
|
||||
render(<SkillsShCatalogDetailPage entry={makeEntry()} />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Stored SKILL.md" })).toBeTruthy();
|
||||
const detailTabs = screen.getByRole("tablist", { name: "Skill detail tabs" });
|
||||
expect(detailTabs).toBeTruthy();
|
||||
expect(detailTabs.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toBe(
|
||||
"SKILL.md",
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "Use this skill" })).toBeTruthy();
|
||||
expect(screen.queryByText("Files")).toBeNull();
|
||||
expect(screen.queryByText("File explorer")).toBeNull();
|
||||
expect(screen.queryByText("skills/html/SKILL.md")).toBeNull();
|
||||
expect(screen.getByText("Content is truncated to the stored 64 KiB snapshot.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the normal skill install card with the exact skills.sh reference", () => {
|
||||
const { container } = render(<SkillsShCatalogDetailPage entry={makeEntry()} />);
|
||||
|
||||
expect(container.querySelector(".skill-install-command-card")).toBeTruthy();
|
||||
expect(screen.getByText("openclaw skills install")).toBeTruthy();
|
||||
expect(screen.getByText("skills-sh:patrick-erichsen/skills/html")).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "skills.sh" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the shared skill detail shell for content and stats", () => {
|
||||
render(<SkillsShCatalogDetailPage entry={makeEntry()} />);
|
||||
|
||||
const sectionTabs = screen.getByRole("tablist", { name: "Skill mobile sections" });
|
||||
expect(sectionTabs).toBeTruthy();
|
||||
expect(sectionTabs.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toBe(
|
||||
"SKILL.md",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "html", current: "page" }).getAttribute("href")).toBe(
|
||||
"/skills-sh/patrick-erichsen/skills/html",
|
||||
);
|
||||
const breadcrumbs = screen.getByRole("navigation", { name: "Skill breadcrumbs" });
|
||||
expect(breadcrumbs.querySelector("a[href*='skills.sh']")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Stats & details" }));
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Stats & details" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getAllByText("Security Audits").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole("button", { name: "Bookmark skill" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Report" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides install commands without a commit-pinned GitHub folder", () => {
|
||||
const entry = makeEntry();
|
||||
delete entry.githubCommit;
|
||||
@@ -79,6 +150,16 @@ describe("SkillsShCatalogDetailPage", () => {
|
||||
expect(screen.queryByText(/^openclaw skills install /)).toBeNull();
|
||||
expect(screen.queryByText(/^clawhub install /)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps ownerless upstream entries labeled as skills.sh", () => {
|
||||
const entry = makeEntry();
|
||||
delete entry.owner;
|
||||
render(<SkillsShCatalogDetailPage entry={entry} />);
|
||||
|
||||
const breadcrumbs = screen.getByRole("navigation", { name: "Skill breadcrumbs" });
|
||||
expect(breadcrumbs.textContent).toContain("skills.sh");
|
||||
expect(breadcrumbs.textContent).not.toContain("patrick-erichsen/skills");
|
||||
});
|
||||
});
|
||||
|
||||
function makeEntry(): SkillsShCatalogDetail {
|
||||
@@ -91,6 +172,7 @@ function makeEntry(): SkillsShCatalogDetail {
|
||||
repo: "skills",
|
||||
slug: "html",
|
||||
displayName: "HTML Artifact Chooser",
|
||||
summary: "# HTML Artifact Chooser **Build useful artifacts.**",
|
||||
categories: ["development"],
|
||||
topics: [],
|
||||
sourceUrl: "https://skills.sh/patrick-erichsen/skills/html",
|
||||
|
||||
@@ -1,243 +1,312 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { BadgeCheck, ShieldAlert } from "lucide-react";
|
||||
import { getSkillCategoriesForSkill } from "../lib/categories";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleHelp,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
ShieldAlert,
|
||||
TriangleAlert,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
buildSkillsShInstallCommands,
|
||||
isSkillsShCatalogInstallable,
|
||||
SKILLS_SH_TRUST_LABEL,
|
||||
skillsShRepositoryLabel,
|
||||
type SkillsShCatalogDetail,
|
||||
type SkillsShUpstreamCheck,
|
||||
} from "../lib/skillsShCatalog";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { InstallCopyButton } from "./InstallCopyButton";
|
||||
import { Container } from "./layout/Container";
|
||||
import { truncateText } from "../lib/truncateText";
|
||||
import { MarkdownPreview } from "./MarkdownPreview";
|
||||
import { SidebarMetadata } from "./SidebarMetadata";
|
||||
import { SkillDetailPageView, type SkillDetailViewSkill } from "./SkillDetailPageView";
|
||||
import { SkillCommandLineCard } from "./SkillInstallSurface";
|
||||
import { Alert, AlertDescription } from "./ui/alert";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { UserBadge } from "./UserBadge";
|
||||
|
||||
const CHECK_PRESENTATION = {
|
||||
passed: { Icon: CheckCircle2, className: "text-status-success-fg" },
|
||||
warning: { Icon: TriangleAlert, className: "text-status-warning-fg" },
|
||||
failed: { Icon: XCircle, className: "text-status-error-fg" },
|
||||
unavailable: { Icon: CircleHelp, className: "text-ink-soft" },
|
||||
passed: "success",
|
||||
warning: "warning",
|
||||
failed: "destructive",
|
||||
unavailable: "compact",
|
||||
} as const;
|
||||
|
||||
function GitHubIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width={size} height={size} aria-hidden="true">
|
||||
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56 0-.28-.01-1.02-.02-2-3.2.7-3.88-1.54-3.88-1.54-.52-1.33-1.28-1.69-1.28-1.69-1.05-.72.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.68 0-1.25.45-2.28 1.18-3.08-.12-.29-.51-1.46.11-3.04 0 0 .97-.31 3.16 1.18.92-.26 1.9-.38 2.88-.39.98 0 1.96.13 2.88.39 2.19-1.49 3.15-1.18 3.15-1.18.63 1.58.24 2.75.12 3.04.74.8 1.18 1.83 1.18 3.08 0 4.42-2.69 5.39-5.25 5.67.42.36.78 1.07.78 2.15 0 1.55-.01 2.8-.01 3.18 0 .31.21.67.8.56A11.51 11.51 0 0 0 23.5 12C23.5 5.65 18.35.5 12 .5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function skillsShSummary(summary: string | undefined) {
|
||||
if (!summary) return "Agent-ready skill pack from skills.sh.";
|
||||
const plain = summary
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/gu, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/gu, "$1")
|
||||
.replace(/^[#>]+\s*/gmu, "")
|
||||
.replace(/[*_`~]/gu, "")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
return truncateText(plain, 280);
|
||||
}
|
||||
|
||||
function pinnedGitHubSourceUrl(entry: SkillsShCatalogDetail) {
|
||||
const repositoryUrl = `https://github.com/${entry.canonicalGitHubRepo}`;
|
||||
if (!entry.githubCommit) return entry.canonicalRepoUrl ?? repositoryUrl;
|
||||
if (!entry.githubPath) return `${repositoryUrl}/tree/${entry.githubCommit}`;
|
||||
const encodedPath = entry.githubPath.split("/").map(encodeURIComponent).join("/");
|
||||
return `${repositoryUrl}/tree/${entry.githubCommit}/${encodedPath}`;
|
||||
}
|
||||
|
||||
export function SkillsShCatalogDetailPage({ entry }: { entry: SkillsShCatalogDetail }) {
|
||||
const installable = isSkillsShCatalogInstallable(entry);
|
||||
const githubOwner = entry.canonicalGitHubRepo.split("/")[0] ?? entry.canonicalGitHubRepo;
|
||||
const skill: SkillDetailViewSkill = {
|
||||
slug: entry.slug,
|
||||
displayName: entry.displayName,
|
||||
summary: skillsShSummary(entry.summary),
|
||||
icon: null,
|
||||
...(installable
|
||||
? { installKind: "github" as const, githubSourceRepo: entry.canonicalGitHubRepo }
|
||||
: {}),
|
||||
categories: entry.categories,
|
||||
inferredCategories: [],
|
||||
topics: entry.topics,
|
||||
badges: {},
|
||||
stats: {
|
||||
downloads: entry.upstreamInstalls,
|
||||
stars: 0,
|
||||
installs: entry.upstreamInstalls,
|
||||
versions: 0,
|
||||
comments: 0,
|
||||
},
|
||||
updatedAt: entry.lastObservedAt,
|
||||
};
|
||||
|
||||
const installContent = installable ? (
|
||||
<SkillCommandLineCard
|
||||
slug={entry.slug}
|
||||
displayName={entry.displayName}
|
||||
ownerHandle={entry.owner ?? null}
|
||||
ownerId={null}
|
||||
installTarget={entry.reference}
|
||||
skillPageUrl={null}
|
||||
/>
|
||||
) : (
|
||||
<Alert variant="warn">
|
||||
<ShieldAlert aria-hidden="true" size={17} />
|
||||
<AlertDescription>
|
||||
This snapshot does not include a commit-pinned GitHub folder, so it cannot be installed yet.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="py-10 sm:py-14">
|
||||
<Container size="narrow">
|
||||
<div className="flex flex-col gap-5 border-b border-[color:var(--oc-border-subtle)] pb-7 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="break-all font-mono text-xs text-[color:var(--oc-text-muted)]">
|
||||
{entry.reference}
|
||||
</p>
|
||||
<h1 className="mt-2 font-display text-3xl font-black leading-tight text-[color:var(--oc-text-primary)] sm:text-4xl">
|
||||
{entry.displayName}
|
||||
</h1>
|
||||
{entry.summary ? (
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-[color:var(--oc-text-secondary)] sm:text-base">
|
||||
{entry.summary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Badge variant="warning" className="shrink-0 self-start">
|
||||
<ShieldAlert aria-hidden="true" size={15} /> {SKILLS_SH_TRUST_LABEL}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Alert variant="warn" className="mt-7">
|
||||
<ShieldAlert aria-hidden="true" size={17} />
|
||||
<AlertDescription>
|
||||
This is a stored upstream skills.sh listing. ClawHub has not scanned or accepted this
|
||||
source.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<dl className="grid grid-cols-1 gap-x-8 gap-y-6 py-7 sm:grid-cols-2">
|
||||
<DetailField label="Source" value={skillsShRepositoryLabel(entry)} mono />
|
||||
<DetailField label="Freshness" value={`Observed ${timeAgo(entry.lastObservedAt)}`} />
|
||||
{entry.githubPath ? <DetailField label="Path" value={entry.githubPath} mono /> : null}
|
||||
{entry.githubCommit ? (
|
||||
<DetailField label="Commit" value={entry.githubCommit} mono />
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
<div className="flex flex-wrap gap-3 border-b border-[color:var(--oc-border-subtle)] pb-7">
|
||||
<SkillDetailPageView
|
||||
pageClassName="skills-sh-detail-page"
|
||||
skill={skill}
|
||||
owner={null}
|
||||
ownerHandle={entry.owner ?? null}
|
||||
latestVersion={null}
|
||||
modInfo={null}
|
||||
canManage={false}
|
||||
isAuthenticated={false}
|
||||
isStaff={false}
|
||||
isStarred={false}
|
||||
onToggleStar={() => undefined}
|
||||
onOpenReport={() => undefined}
|
||||
onRequireSignIn={() => undefined}
|
||||
forkOf={null}
|
||||
forkOfLabel="fork of"
|
||||
forkOfHref={null}
|
||||
forkOfOwnerHandle={null}
|
||||
canonical={null}
|
||||
canonicalHref={null}
|
||||
canonicalOwnerHandle={null}
|
||||
staffVisibilityTag={null}
|
||||
isAutoHidden={false}
|
||||
isRemoved={false}
|
||||
nixPlugin={undefined}
|
||||
hasPluginBundle={false}
|
||||
configRequirements={undefined}
|
||||
cliHelp={undefined}
|
||||
clawdis={undefined}
|
||||
categories={getSkillCategoriesForSkill(skill)}
|
||||
showArchiveMetadata={false}
|
||||
showBookmarkAction={false}
|
||||
showReportAction={false}
|
||||
taxonomyPrefix={
|
||||
<span className="skills-sh-sync-source-label">
|
||||
Synced from{" "}
|
||||
<a
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--oc-accent-primary)] hover:underline"
|
||||
className="skills-sh-sync-source"
|
||||
href={entry.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View on skills.sh <ExternalLink aria-hidden="true" size={14} />
|
||||
skills.sh
|
||||
</a>
|
||||
{entry.canonicalRepoUrl ? (
|
||||
<a
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--oc-text-secondary)] hover:underline"
|
||||
href={entry.canonicalRepoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View repository <ExternalLink aria-hidden="true" size={14} />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="border-b border-[color:var(--oc-border-subtle)] py-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--oc-text-primary)]">
|
||||
Install
|
||||
</h2>
|
||||
{entry.githubPath && entry.githubCommit && entry.githubContentHash ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
to="/settings"
|
||||
search={{
|
||||
view: "githubSources",
|
||||
ownerHandle: entry.canonicalGitHubRepo.split("/")[0],
|
||||
repo: entry.canonicalGitHubRepo,
|
||||
sourceRepo: entry.canonicalGitHubRepo,
|
||||
sourceExternalId: entry.externalId,
|
||||
sourcePath: entry.githubPath,
|
||||
sourceCommit: entry.githubCommit,
|
||||
sourceContentHash: entry.githubContentHash,
|
||||
}}
|
||||
>
|
||||
<GitBranch size={15} aria-hidden="true" /> Claim
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{installable ? (
|
||||
<div className="mt-3 grid gap-3">
|
||||
{buildSkillsShInstallCommands(entry.reference).map(({ client, command }) => (
|
||||
<div key={client}>
|
||||
<p className="mb-1 text-xs font-semibold text-[color:var(--oc-text-muted)]">
|
||||
{client}
|
||||
</p>
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-[var(--oc-radius-inset)] border border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-surface)] p-2 pl-3">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-sm text-[color:var(--oc-text-primary)]">
|
||||
{command}
|
||||
</code>
|
||||
<InstallCopyButton
|
||||
text={command}
|
||||
ariaLabel={`Copy ${client} install command`}
|
||||
showLabel={false}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Alert variant="warn" className="mt-3">
|
||||
<ShieldAlert aria-hidden="true" size={17} />
|
||||
<AlertDescription>
|
||||
This snapshot does not include a commit-pinned GitHub folder, so it cannot be
|
||||
installed yet.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="border-b border-[color:var(--oc-border-subtle)] py-7">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--oc-text-primary)]">
|
||||
Upstream checks
|
||||
</h2>
|
||||
<p className="text-xs text-[color:var(--oc-text-muted)]">
|
||||
Upstream checks are separate from ClawHub scanning.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
||||
{entry.upstreamChecks.map((check) => (
|
||||
<UpstreamCheck key={check.scanner} check={check} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{entry.content ? (
|
||||
<section className="pt-7">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--oc-text-primary)]">
|
||||
Stored {entry.content.kind === "skill-md" ? "SKILL.md" : "README"}
|
||||
</h2>
|
||||
<code className="break-all text-xs text-[color:var(--oc-text-muted)]">
|
||||
{entry.content.path}
|
||||
</code>
|
||||
</div>
|
||||
{entry.content.truncated ? (
|
||||
<p className="mt-2 text-xs text-[color:var(--oc-text-muted)]">
|
||||
Content is truncated to the stored 64 KiB snapshot.
|
||||
</p>
|
||||
) : null}
|
||||
<MarkdownPreview className="mt-5">{entry.content.markdown}</MarkdownPreview>
|
||||
</section>
|
||||
) : null}
|
||||
</Container>
|
||||
</main>
|
||||
</span>
|
||||
}
|
||||
breadcrumbOwnerHref={null}
|
||||
breadcrumbOwnerLabel={entry.owner ?? "skills.sh"}
|
||||
breadcrumbSkillHref={entry.route}
|
||||
creatorContent={
|
||||
<UserBadge
|
||||
user={{
|
||||
handle: githubOwner,
|
||||
displayName: githubOwner,
|
||||
image: `https://github.com/${githubOwner}.png?size=96`,
|
||||
}}
|
||||
fallbackHandle={githubOwner}
|
||||
prefix=""
|
||||
size="md"
|
||||
showName
|
||||
showHandle={false}
|
||||
showMutedHandle
|
||||
stackMutedHandleBelowName
|
||||
disableTooltip
|
||||
profileHref={`https://github.com/${githubOwner}`}
|
||||
/>
|
||||
}
|
||||
installContent={installContent}
|
||||
renderSidebarContent={() => <SkillsShSidebar entry={entry} />}
|
||||
>
|
||||
<SkillsShContentTabs entry={entry} />
|
||||
</SkillDetailPageView>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
function SkillsShSidebar({ entry }: { entry: SkillsShCatalogDetail }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-xs font-semibold text-[color:var(--oc-text-muted)]">{label}</dt>
|
||||
<dd
|
||||
className={`mt-1 break-all text-sm text-[color:var(--oc-text-primary)]${mono ? " font-mono" : ""}`}
|
||||
<div className="skill-hero-sidebar-stack">
|
||||
<SidebarMetadata
|
||||
ariaLabel="skills.sh metadata"
|
||||
density="compact"
|
||||
blocks={[
|
||||
{
|
||||
label: "Downloads",
|
||||
value: (
|
||||
<span title={`${entry.upstreamInstalls.toLocaleString()} downloads`}>
|
||||
{formatCompactStat(entry.upstreamInstalls)}
|
||||
</span>
|
||||
),
|
||||
large: true,
|
||||
},
|
||||
{
|
||||
label: "Repository",
|
||||
value: (
|
||||
<a
|
||||
href={pinnedGitHubSourceUrl(entry)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="plugin-external-link"
|
||||
>
|
||||
<GitHubIcon />
|
||||
{entry.canonicalGitHubRepo}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
...(entry.githubCommit
|
||||
? [
|
||||
{
|
||||
label: "Commit",
|
||||
value: <code>{entry.githubCommit.slice(0, 12)}</code>,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<section className="skills-sh-security-audits" aria-label="Security Audits">
|
||||
<h2 className="sidebar-metadata-label">Security Audits</h2>
|
||||
<div className="skills-sh-security-audit-list">
|
||||
{entry.upstreamChecks.map((check) => (
|
||||
<UpstreamCheck key={check.scanner} check={check} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="skills-sh-detail-links">
|
||||
{entry.githubPath && entry.githubCommit && entry.githubContentHash ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
to="/settings"
|
||||
search={{
|
||||
view: "githubSources",
|
||||
ownerHandle: entry.canonicalGitHubRepo.split("/")[0],
|
||||
repo: entry.canonicalGitHubRepo,
|
||||
sourceRepo: entry.canonicalGitHubRepo,
|
||||
sourceExternalId: entry.externalId,
|
||||
sourcePath: entry.githubPath,
|
||||
sourceCommit: entry.githubCommit,
|
||||
sourceContentHash: entry.githubContentHash,
|
||||
}}
|
||||
>
|
||||
<BadgeCheck size={15} aria-hidden="true" /> Claim this skill
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillsShContentTabs({ entry }: { entry: SkillsShCatalogDetail }) {
|
||||
return (
|
||||
<div className="tab-card detail-mobile-tabs skill-detail-tabs-card">
|
||||
<div className="tab-header" role="tablist" aria-label="Skill detail tabs">
|
||||
<button
|
||||
id="skill-tab-readme"
|
||||
className="tab-button is-active"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
aria-controls="skill-tabpanel-readme"
|
||||
>
|
||||
{entry.content?.kind === "readme" ? "README" : "SKILL.md"}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="tab-body skill-readme-body"
|
||||
role="tabpanel"
|
||||
id="skill-tabpanel-readme"
|
||||
aria-labelledby="skill-tab-readme"
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
{entry.content ? (
|
||||
<>
|
||||
{entry.content.truncated ? (
|
||||
<p className="skills-sh-content-note">
|
||||
Content is truncated to the stored 64 KiB snapshot.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="skill-readme-preview">
|
||||
<MarkdownPreview highlight={false}>{entry.content.markdown}</MarkdownPreview>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
|
||||
<p className="empty-state-title">No stored content available</p>
|
||||
<p className="empty-state-body">This skills.sh listing has no stored Markdown.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpstreamCheck({ check }: { check: SkillsShUpstreamCheck }) {
|
||||
const presentation = CHECK_PRESENTATION[check.status];
|
||||
const Icon = presentation.Icon;
|
||||
return (
|
||||
<div className="rounded-[var(--oc-radius-inset)] border border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-surface)] px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon aria-hidden="true" size={15} className={presentation.className} />
|
||||
<span className="text-sm font-semibold text-[color:var(--oc-text-primary)]">
|
||||
{check.scanner}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`mt-1 text-xs font-medium ${presentation.className}`}>{check.sourceStatus}</p>
|
||||
{check.checkedAt ? (
|
||||
<p className="mt-1 text-xs text-[color:var(--oc-text-muted)]">
|
||||
Checked {timeAgo(check.checkedAt)}
|
||||
</p>
|
||||
) : null}
|
||||
{check.url ? (
|
||||
<a
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-[color:var(--oc-accent-primary)] hover:underline"
|
||||
href={check.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View result <ExternalLink aria-hidden="true" size={12} />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
const content = (
|
||||
<>
|
||||
<span>{check.scanner}</span>
|
||||
<Badge
|
||||
variant={CHECK_PRESENTATION[check.status]}
|
||||
size="sm"
|
||||
className="skills-sh-security-audit-verdict"
|
||||
>
|
||||
{check.sourceStatus}
|
||||
</Badge>
|
||||
</>
|
||||
);
|
||||
return check.url ? (
|
||||
<a className="skills-sh-security-audit-row" href={check.url} target="_blank" rel="noreferrer">
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div className="skills-sh-security-audit-row">{content}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type UserBadgeProps = {
|
||||
/** Hero creator row: stack `@handle` below the display name. */
|
||||
stackMutedHandleBelowName?: boolean;
|
||||
disableTooltip?: boolean;
|
||||
profileHref?: string | null;
|
||||
};
|
||||
|
||||
export function UserBadge({
|
||||
@@ -46,12 +47,14 @@ export function UserBadge({
|
||||
showMutedHandle = false,
|
||||
stackMutedHandleBelowName = false,
|
||||
disableTooltip = false,
|
||||
profileHref,
|
||||
}: UserBadgeProps) {
|
||||
const userName =
|
||||
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 ? buildPublisherProfileHref(handle) : null;
|
||||
const href =
|
||||
profileHref === undefined ? (handle ? buildPublisherProfileHref(handle) : null) : profileHref;
|
||||
const label = handle ? `@${handle}` : "user";
|
||||
const image = user?.image ?? null;
|
||||
const showStackedMutedHandle =
|
||||
|
||||
@@ -19,6 +19,8 @@ type SkillPromptContext = {
|
||||
ownerHandle: string | null;
|
||||
ownerId: SkillOwnerId | null;
|
||||
clawdis?: ClawdisSkillMetadata;
|
||||
installTarget?: string;
|
||||
skillPageUrl?: string | null;
|
||||
};
|
||||
|
||||
export function buildSkillHref(
|
||||
@@ -205,9 +207,12 @@ export function formatOpenClawPrompt({
|
||||
ownerHandle,
|
||||
ownerId,
|
||||
clawdis,
|
||||
installTarget,
|
||||
skillPageUrl,
|
||||
}: SkillPromptContext) {
|
||||
const target = buildSkillInstallTarget(ownerHandle, ownerId, slug);
|
||||
const pageUrl = buildSkillPageUrl(ownerHandle, ownerId, slug);
|
||||
const target = installTarget?.trim() || buildSkillInstallTarget(ownerHandle, ownerId, slug);
|
||||
const pageUrl =
|
||||
skillPageUrl === undefined ? buildSkillPageUrl(ownerHandle, ownerId, slug) : skillPageUrl;
|
||||
const displayName = skillName.trim() || slug;
|
||||
const requiredEnvVars = new Set(clawdis?.requires?.env ?? []);
|
||||
|
||||
|
||||
@@ -98,13 +98,6 @@ export function skillsShRepositoryLabel(result: SkillsShSearchResult) {
|
||||
return result.sourceHost ?? "skills.sh";
|
||||
}
|
||||
|
||||
export function buildSkillsShInstallCommands(reference: string) {
|
||||
return [
|
||||
{ client: "OpenClaw", command: `openclaw skills install ${reference}` },
|
||||
{ client: "ClawHub", command: `clawhub install ${reference}` },
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function isSkillsShCatalogInstallable(
|
||||
detail: Pick<SkillsShCatalogDetail, "githubCommit" | "githubContentHash" | "githubPath">,
|
||||
) {
|
||||
|
||||
@@ -16,6 +16,13 @@ export type CanonicalTrendingItem = {
|
||||
image: string | null;
|
||||
official: boolean;
|
||||
} | null;
|
||||
sourceIdentity?: {
|
||||
id: string;
|
||||
owner: string | null;
|
||||
repo: string | null;
|
||||
host: string | null;
|
||||
lifetimeInstalls: number | null;
|
||||
};
|
||||
official: boolean;
|
||||
featured: boolean;
|
||||
metrics: {
|
||||
@@ -88,6 +95,19 @@ function isCanonicalPublisher(value: unknown): value is CanonicalTrendingItem["p
|
||||
);
|
||||
}
|
||||
|
||||
function isCanonicalSourceIdentity(
|
||||
value: unknown,
|
||||
): value is NonNullable<CanonicalTrendingItem["sourceIdentity"]> {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
typeof value.id === "string" &&
|
||||
isNullableString(value.owner) &&
|
||||
isNullableString(value.repo) &&
|
||||
isNullableString(value.host) &&
|
||||
isNullableNumber(value.lifetimeInstalls)
|
||||
);
|
||||
}
|
||||
|
||||
function isCanonicalTrendingItem(value: unknown): value is LegacyCanonicalTrendingItem {
|
||||
if (!isRecord(value) || !isRecord(value.metrics)) return false;
|
||||
return (
|
||||
@@ -98,6 +118,7 @@ function isCanonicalTrendingItem(value: unknown): value is LegacyCanonicalTrendi
|
||||
isNullableString(value.summary) &&
|
||||
isCanonicalPath(value.canonicalUrl) &&
|
||||
isCanonicalPublisher(value.publisher) &&
|
||||
(value.sourceIdentity === undefined || isCanonicalSourceIdentity(value.sourceIdentity)) &&
|
||||
typeof value.official === "boolean" &&
|
||||
typeof value.featured === "boolean" &&
|
||||
(value.metrics.trending24hDownloads === undefined ||
|
||||
|
||||
+131
-9
@@ -7059,13 +7059,35 @@ code {
|
||||
.skill-hero-taxonomy-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
color: color-mix(in srgb, var(--ink-soft) 76%, var(--bg));
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.skill-hero-taxonomy-prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.skills-sh-sync-source-label {
|
||||
color: inherit;
|
||||
font-weight: 520;
|
||||
}
|
||||
|
||||
.skills-sh-sync-source {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.skills-sh-sync-source:hover,
|
||||
.skills-sh-sync-source:focus-visible {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.skill-category-meta-list {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -7328,6 +7350,70 @@ code {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.skills-sh-security-audits {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.skills-sh-security-audits h2 {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-family: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.skills-sh-security-audit-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.skills-sh-security-audit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
padding: 8px 0;
|
||||
color: var(--ink);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skills-sh-security-audit-verdict {
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--oc-radius-control);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
a.skills-sh-security-audit-row:hover,
|
||||
a.skills-sh-security-audit-row:focus-visible {
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.skills-sh-detail-links {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.skills-sh-detail-links .btn {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skills-sh-content-note {
|
||||
margin: 0;
|
||||
padding-bottom: 14px;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.skill-sidebar-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -8801,6 +8887,28 @@ code {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.skills-sh-detail-page .detail-mobile-install .skill-install-command-shell-cli {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.skills-sh-detail-page .detail-mobile-install .skill-install-command-prompt {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.skills-sh-detail-page
|
||||
.detail-mobile-install
|
||||
.skill-install-command-shell-cli
|
||||
.skill-install-command,
|
||||
.skills-sh-detail-page
|
||||
.detail-mobile-install
|
||||
.skill-install-command-shell-cli
|
||||
.skill-install-command
|
||||
code {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-mobile-install .skill-install-command-shell-cli .skill-install-command,
|
||||
.detail-mobile-install .skill-install-command-shell-cli .skill-install-command code {
|
||||
white-space: nowrap;
|
||||
@@ -22739,7 +22847,7 @@ a.search-empty-action {
|
||||
.home-v2-listing-head {
|
||||
--home-v2-listing-copy-max: min(54rem, 60vw);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr auto;
|
||||
grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr auto;
|
||||
gap: 16px 20px;
|
||||
align-items: center;
|
||||
padding: 0 4px 8px;
|
||||
@@ -22748,7 +22856,7 @@ a.search-empty-action {
|
||||
}
|
||||
|
||||
.home-v2-listing-head.has-no-stats {
|
||||
grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr;
|
||||
grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr;
|
||||
}
|
||||
|
||||
.home-v2-listing-head-icon-spacer {
|
||||
@@ -22767,11 +22875,11 @@ a.search-empty-action {
|
||||
}
|
||||
|
||||
.home-v2-listing-head-label {
|
||||
grid-column: 2;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.home-v2-listing-head-stat {
|
||||
grid-column: 4;
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
@@ -23035,7 +23143,7 @@ a.search-empty-action {
|
||||
--home-v2-listing-copy-max: min(54rem, 60vw);
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr auto;
|
||||
grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr auto;
|
||||
gap: 16px 20px;
|
||||
align-items: center;
|
||||
padding: 13px 4px;
|
||||
@@ -23046,7 +23154,7 @@ a.search-empty-action {
|
||||
}
|
||||
|
||||
.home-v2-listing-row.has-no-stats {
|
||||
grid-template-columns: auto minmax(0, var(--home-v2-listing-copy-max)) 1fr;
|
||||
grid-template-columns: minmax(0, var(--home-v2-listing-copy-max)) 1fr;
|
||||
}
|
||||
|
||||
.home-v2-listing-row::before {
|
||||
@@ -23132,7 +23240,7 @@ a.search-empty-action {
|
||||
}
|
||||
|
||||
.home-v2-listing-row-body {
|
||||
grid-column: 2;
|
||||
grid-column: 1;
|
||||
min-width: 0;
|
||||
max-width: var(--home-v2-listing-copy-max);
|
||||
display: grid;
|
||||
@@ -23182,6 +23290,20 @@ a.search-empty-action {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-v2-listing-source-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--hv2-text-tertiary) 42%, transparent);
|
||||
border-radius: var(--r-pill);
|
||||
color: var(--hv2-text-tertiary);
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
font-size: 9px;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-v2-listing-row-summary {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
@@ -23209,7 +23331,7 @@ a.search-empty-action {
|
||||
}
|
||||
|
||||
.home-v2-listing-row-stats {
|
||||
grid-column: 4;
|
||||
grid-column: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
Reference in New Issue
Block a user