SPB Git

Repository Public MIT

artifact-view.tsx 23.4 KB 417 lines tsx

1import Link from "next/link";2import { notFound } from "next/navigation";3import { AlertTriangle, Download, ExternalLink, Flag } from "lucide-react";4import { EXAMPLE_CLIENTS, EXAMPLE_CLIENT_LABELS, TYPE_LABELS, type Finding } from "@skills/registry-core";5import { bytes, date, dateTime, plural } from "@/lib/format";6import { t } from "@/lib/i18n";7import { publicCtx } from "@/lib/session";8import { artifactHistory } from "@/server/community";9import { ApiError } from "@/server/errors";10import { artifactDto, getArtifactRow, getRelease, instructionsFor, listArtifacts, listReleases, releaseDetail, releaseDto } from "@/server/registry";11import { ArtifactCard } from "./artifact-card";12import { SecurityBadge, StateBadge, TrustBadges, TypeBadge } from "./badges";13import { Checksum, CodeBlock } from "./code";14import { ExamplesGallery, type ExampleItem } from "./examples-gallery";15import { FileExplorer } from "./file-explorer";16import { InstallTabs } from "./install-tabs";17import { Markdown } from "./markdown";1819export async function loadArtifact(namespace: string, slug: string, version?: string) {20  const ctx = await publicCtx();21  try {22    const a = await getArtifactRow(ctx, namespace, slug);23    const rel = a.latest_version || version ? await getRelease(ctx, a, version) : null;24    return { ctx, a, rel };25  } catch (e) {26    if (e instanceof ApiError && (e.status === 404 || e.status === 410)) notFound();27    throw e;28  }29}3031function Section({ id, title, children, aside }: { id: string; title: string; children: React.ReactNode; aside?: React.ReactNode }) {32  return (33    <section id={id} aria-labelledby={`${id}-h`} className="scroll-mt-24 border-t border-line pt-8">34      <div className="mb-4 flex flex-wrap items-baseline justify-between gap-2">35        <h2 id={`${id}-h`} className="text-lg font-semibold tracking-tight">36          {title}37        </h2>38        {aside}39      </div>40      {children}41    </section>42  );43}4445function Meta({ label, children }: { label: string; children: React.ReactNode }) {46  return (47    <div className="flex items-start justify-between gap-4 border-b border-line py-2.5 last:border-0">48      <dt className="shrink-0 text-sm text-muted">{label}</dt>49      <dd className="min-w-0 break-words text-right text-sm text-text">{children}</dd>50    </div>51  );52}53const missing = <span className="text-faint">not provided</span>;5455const SEV: Record<string, string> = { critical: "text-danger", high: "text-danger", medium: "text-warn", low: "text-muted", info: "text-faint" };5657export async function ArtifactView({ namespace, slug, version }: { namespace: string; slug: string; version?: string }) {58  const { ctx, a, rel } = await loadArtifact(namespace, slug, version);59  const dto = artifactDto(a);60  const name = `@${a.namespace}/${a.slug}`;61  const [detail, releases, history, related] = await Promise.all([62    rel ? releaseDetail(ctx, a, rel) : null,63    listReleases(ctx, a),64    artifactHistory(ctx, a),65    listArtifacts(ctx, { type: a.type, perPage: 4 }),66  ]);67  const instructions = rel && rel.state === "published" ? instructionsFor(a, rel) : [];68  const isLatest = !!rel && rel.version === a.latest_version;69  const findings: Finding[] = detail?.scan?.findings ?? [];70  const advisories = findings.filter((f) => f.severity !== "info");71  const perms = detail?.manifest.permissions ?? [];72  const relatedItems = related.items.filter((r) => r.id !== a.id).slice(0, 3);73  const mcp = detail?.manifest.mcp;74  const tools = mcp?.tools ?? [];75  const envVars = mcp?.env ?? [];76  // Screenshots are shown only for an installable release; the URLs are same-origin paths into the archive.77  const examples = rel && rel.state === "published" ? detail?.examples ?? [] : [];78  const exampleGroups = EXAMPLE_CLIENTS.map((c) => ({79    client: c as string,80    label: EXAMPLE_CLIENT_LABELS[c],81    items: examples.filter((e) => e.client === c).map((e): ExampleItem => ({82      title: e.title, client: e.client, clientLabel: EXAMPLE_CLIENT_LABELS[c], src: e.url.replace(/^https?:\/\/[^/]+/, ""), alt: e.alt,83      prompt: e.prompt, caption: e.caption, capturedAt: e.capturedAt, width: e.width, height: e.height,84    })),85  })).filter((g) => g.items.length > 0);86  const toc: [string, string][] = [["install", "Install"], ...(examples.length ? [["examples", `Examples (${examples.length})`] as [string, string]] : []), ["readme", "Documentation"], ...(tools.length ? [["tools", `Tools (${tools.length})`] as [string, string]] : []), ["files", "Files"], ["security", "Security"], ["versions", "Versions"], ["history", "History"]];87  const facts: [string, React.ReactNode][] = rel88    ? [89        ["Version", <span key="v" className="font-mono">{rel.version}</span>],90        ["Published", date(rel.published_at)],91        ["Licence", a.license ?? "not provided"],92        ["Archive", bytes(Number(rel.size_bytes))],93        ...(detail ? [["Files", String(detail.fileCount)] as [string, React.ReactNode]] : []),94        ...(mcp ? [["Transport", <span key="t" className="font-mono">{mcp.transport}</span>] as [string, React.ReactNode]] : []),95        ...(tools.length ? [["Tools", String(tools.length)] as [string, React.ReactNode]] : []),96        ...(examples.length ? [["Examples", String(examples.length)] as [string, React.ReactNode]] : []),97      ]98    : [];99100  return (101    <div className="container-page pb-10 pt-10">102      <nav aria-label="Breadcrumb" className="mb-5 font-mono text-[12px] text-faint">103        <Link href="/explore" className="hover:text-muted">explore</Link> / <Link href={`/explore?type=${a.type}`} className="hover:text-muted">{TYPE_LABELS[a.type].plural.toLowerCase()}</Link> /{" "}104        <Link href={`/@${a.namespace}`} className="hover:text-muted">@{a.namespace}</Link>105      </nav>106107      <header className="mb-8">108        <div className="mb-3 flex flex-wrap items-center gap-1.5">109          <TypeBadge type={a.type} />110          <StateBadge state={a.state} reason={a.deprecation_reason} />111          {rel && <StateBadge state={rel.state} reason={rel.revocation_reason} />}112          <TrustBadges identityVerified={dto.publisher.identityVerified} sourceVerified={a.source_verified} />113          {rel && <SecurityBadge status={rel.security_status} />}114        </div>115        <h1 className="break-words font-mono text-2xl font-medium tracking-tight sm:text-3xl">116          <Link href={`/@${a.namespace}`} className="text-muted underline decoration-line-strong decoration-1 underline-offset-4 hover:text-text">@{a.namespace}</Link>117          <span className="text-faint">/</span>118          {a.slug}119          {rel && <span className="ml-3 align-middle font-mono text-base text-faint">v{rel.version}</span>}120        </h1>121        <p className="mt-3 max-w-3xl text-base text-muted">{a.summary}</p>122        {a.capabilities.length > 0 && (123          <ul className="mt-4 flex flex-wrap gap-1.5" aria-label="What this entry is">124            {a.capabilities.map((c) => (125              <li key={c} className="chip">{c}</li>126            ))}127          </ul>128        )}129        {facts.length > 0 && (130          <dl className="mt-6 grid grid-cols-2 overflow-hidden rounded-xl border border-line bg-graphite sm:grid-cols-4 lg:flex" data-testid="quick-facts">131            {facts.map(([k, v]) => (132              <div key={k} className="min-w-0 border-b border-r border-line px-4 py-3 last:border-r-0 lg:flex-1 lg:border-b-0">133                <dt className="eyebrow">{k}</dt>134                <dd className="mt-1 truncate text-sm font-medium text-text">{v}</dd>135              </div>136            ))}137          </dl>138        )}139      </header>140141      {a.state === "deprecated" && (142        <div role="note" className="callout mb-6 border-warn/40 bg-warn/5">143          <AlertTriangle size={16} aria-hidden className="mt-0.5 shrink-0 text-warn" />144          <p><strong className="font-semibold">Deprecated.</strong> {a.deprecation_reason ?? "The maintainer no longer recommends this artifact."}</p>145        </div>146      )}147      {a.state === "archived" && (148        <div role="note" className="mb-6 rounded-lg border border-line-strong p-4 text-sm text-muted">Archived: this artifact is read-only and receives no new releases.</div>149      )}150      {rel?.state === "revoked" && (151        <div role="alert" className="callout mb-6 border-danger/40 bg-danger/5">152          <AlertTriangle size={16} aria-hidden className="mt-0.5 shrink-0 text-danger" />153          <p><strong className="font-semibold">Version {rel.version} was revoked on {date(rel.revoked_at)}.</strong> {rel.revocation_reason} Downloads are blocked; the record is kept for auditability.</p>154        </div>155      )}156      {rel && !isLatest && a.latest_version && rel.state !== "revoked" && (157        <div role="note" className="mb-6 rounded-lg border border-line-strong p-4 text-sm text-muted">158          You are viewing v{rel.version}. The latest release is <Link className="link" href={`/@${a.namespace}/${a.slug}`}>v{a.latest_version}</Link>.159        </div>160      )}161162      <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_21rem]">163        <div className="min-w-0 space-y-10">164          <nav aria-label="On this page" className="flex gap-1 overflow-x-auto border-b border-line pb-px">165            {toc.map(([id, label]) => (166              <a key={id} href={`#${id}`} className="whitespace-nowrap rounded-t-md px-3 py-2 text-sm text-muted hover:text-text">{label}</a>167            ))}168          </nav>169170          <section id="install" aria-labelledby="install-h" className="scroll-mt-24">171            <h2 id="install-h" className="mb-4 text-lg font-semibold tracking-tight">Install</h2>172            {!rel ? (173              <p className="text-sm text-muted">Nothing to install: this artifact has no published release.</p>174            ) : rel.state !== "published" ? (175              <p className="text-sm text-muted">This version cannot be installed because it is {rel.state}.</p>176            ) : (177              <>178                <InstallTabs instructions={instructions} />179                <p className="mt-4 text-xs text-faint">180                  Installing only copies or configures. Nothing is executed for you. The <Link className="link" href="/docs/cli">Skills CLI</Link> verifies the checksum before writing files.181                </p>182              </>183            )}184          </section>185186187          {exampleGroups.length > 0 && (188            <Section id="examples" title="Examples" aside={<span className="font-mono text-xs text-faint">{plural(examples.length, "screenshot")} · from the release archive</span>}>189              <ExamplesGallery groups={exampleGroups} />190              <p className="mt-4 text-xs text-faint">Screenshots supplied by the publisher. The registry serves them from the release archive and does not verify what they show.</p>191            </Section>192          )}193194          <section id="readme" aria-labelledby="readme-h" className="scroll-mt-24 border-t border-line pt-8">195            <h2 id="readme-h" className="mb-4 text-lg font-semibold tracking-tight">Documentation</h2>196            {detail?.readme ? (197              <Markdown source={detail.readme} />198            ) : (199              <p className="text-sm text-muted">{rel ? "This release ships no README." : "No release has been published yet, so there is no documentation to show."}</p>200            )}201          </section>202203          {(tools.length > 0 || envVars.length > 0) && (204            <Section id="tools" title="Tools" aside={<span className="font-mono text-xs text-faint">{plural(tools.length, "tool")} · declared by the publisher</span>}>205              {tools.length > 0 && (206                <div className="max-h-[34rem] overflow-auto rounded-xl border border-line" tabIndex={0} role="group" aria-label="Tool list">207                  <table className="data-table" data-testid="tools-table">208                    <thead className="sticky top-0 bg-graphite"><tr><th scope="col">Tool</th><th scope="col">What it does</th></tr></thead>209                    <tbody>210                      {tools.map((tl) => (211                        <tr key={tl.name}>212                          <td className="whitespace-nowrap font-mono text-[12.5px] text-text">{tl.name}</td>213                          <td className="text-muted">{tl.description ?? "—"}</td>214                        </tr>215                      ))}216                    </tbody>217                  </table>218                </div>219              )}220              {envVars.length > 0 && (221                <div className="mt-5">222                  <h3 className="mb-2 text-sm font-medium">Environment variables</h3>223                  <div className="overflow-x-auto rounded-xl border border-line">224                    <table className="data-table">225                      <thead><tr><th scope="col">Variable</th><th scope="col">Required</th><th scope="col">Purpose</th></tr></thead>226                      <tbody>227                        {envVars.map((e) => (228                          <tr key={e.name}>229                            <td className="whitespace-nowrap font-mono text-[12.5px] text-text">{e.name}{e.secret && <span className="ml-2 rounded border border-warn/40 px-1 text-[10px] uppercase text-warn">secret</span>}</td>230                            <td className="text-muted">{e.required ? "yes" : "optional"}</td>231                            <td className="text-muted">{e.description ?? "—"}</td>232                          </tr>233                        ))}234                      </tbody>235                    </table>236                  </div>237                </div>238              )}239            </Section>240          )}241242          <Section id="files" title="Files" aside={detail ? <span className="font-mono text-xs text-faint">{plural(detail.fileCount, "file")} · {bytes(detail.unpackedBytes)} unpacked</span> : null}>243            {detail ? <FileExplorer namespace={a.namespace} slug={a.slug} version={detail.version} files={detail.files} /> : <p className="text-sm text-muted">No files yet.</p>}244          </Section>245246          <Section id="security" title="Security & permissions">247            <div className="space-y-5 text-sm">248              <div>249                <h3 className="mb-2 font-medium">Declared permissions</h3>250                {perms.length ? (251                  <ul className="flex flex-wrap gap-1.5">{perms.map((p) => <li key={p} className="chip text-text">{p}</li>)}</ul>252                ) : (253                  <p className="text-muted">{rel ? "The publisher declared no permissions. This is a claim, not a guarantee." : "—"}</p>254                )}255              </div>256              <div>257                <h3 className="mb-2 font-medium">Static checks</h3>258                {!detail?.scan ? (259                  <p className="text-muted">No scan result is recorded.</p>260                ) : advisories.length === 0 ? (261                  <p className="text-muted">No advisory findings from {detail.scan.scannerVersion} on {date(detail.scan.completedAt)}. {findings.length > 0 && `${plural(findings.length, "informational note")}.`}</p>262                ) : (263                  <ul className="divide-y divide-line rounded-lg border border-line">264                    {advisories.map((f, i) => (265                      <li key={i} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-3 py-2">266                        <span className={`w-16 shrink-0 font-mono text-[11px] uppercase ${SEV[f.severity]}`}>{f.severity}</span>267                        <span className="min-w-0 flex-1 text-muted">{f.message}</span>268                        {f.path && <code className="font-mono text-[11px] text-faint">{f.path}{f.line ? `:${f.line}` : ""}</code>}269                      </li>270                    ))}271                  </ul>272                )}273                <p className="mt-3 text-xs text-faint">{t.trust.advisory} Compatibility entries are publisher claims unless marked verified.</p>274              </div>275              {detail && detail.dependencies.length > 0 && (276                <div>277                  <h3 className="mb-2 font-medium">Dependencies</h3>278                  <ul className="space-y-1">279                    {detail.dependencies.map((d) => (280                      <li key={d.name} className="font-mono text-[13px]">281                        <Link className="link" href={`/${d.name}`}>{d.name}</Link> <span className="text-faint">{d.version}{d.optional ? " · optional" : ""}</span>282                      </li>283                    ))}284                  </ul>285                </div>286              )}287              {detail && detail.compatibility.length > 0 && (288                <div>289                  <h3 className="mb-2 font-medium">Compatibility</h3>290                  <ul className="flex flex-wrap gap-1.5">291                    {detail.compatibility.map((c) => (292                      <li key={c.client + c.platform} className="chip">293                        {c.client}{c.minVersion ? ` ≥ ${c.minVersion}` : ""}{c.platform !== "any" ? ` · ${c.platform}` : ""} · {c.verified ? "verified" : "claimed"}294                      </li>295                    ))}296                  </ul>297                </div>298              )}299            </div>300          </Section>301302          <Section id="versions" title="Versions" aside={<span className="font-mono text-xs text-faint">{plural(releases.length, "release")} · immutable</span>}>303            {releases.length === 0 ? (304              <p className="text-sm text-muted">No release has been published.</p>305            ) : (306              <div className="overflow-x-auto rounded-xl border border-line">307                <table className="data-table">308                  <thead>309                    <tr>310                      <th scope="col">Version</th>311                      <th scope="col">Published</th>312                      <th scope="col">Size</th>313                      <th scope="col">SHA-256</th>314                      <th scope="col">State</th>315                    </tr>316                  </thead>317                  <tbody>318                    {releases.map((r) => (319                      <tr key={r.id}>320                        <td className="font-mono">321                          <Link className="link" href={`/@${a.namespace}/${a.slug}/versions/${r.version}`}>{r.version}</Link>322                          {r.version === a.latest_version && <span className="ml-2 text-[10px] uppercase text-ok">latest</span>}323                        </td>324                        <td className="whitespace-nowrap text-muted">{date(r.published_at)}</td>325                        <td className="whitespace-nowrap text-muted">{bytes(Number(r.size_bytes))}</td>326                        <td className="font-mono text-[11px] text-faint" title={r.sha256}>{r.sha256.slice(0, 12)}…</td>327                        <td className="text-muted">{r.state}</td>328                      </tr>329                    ))}330                  </tbody>331                </table>332              </div>333            )}334            {detail?.releaseNotes && (335              <div className="mt-5">336                <h3 className="mb-2 text-sm font-medium">Release notes for v{detail.version}</h3>337                <Markdown source={detail.releaseNotes} />338              </div>339            )}340          </Section>341342          <Section id="history" title="Publication history">343            {history.length === 0 ? (344              <p className="text-sm text-muted">No recorded events.</p>345            ) : (346              <ol className="space-y-2 text-sm">347                {history.map((h, i) => (348                  <li key={i} className="flex flex-wrap gap-x-3 text-muted">349                    <time className="w-40 shrink-0 font-mono text-[12px] text-faint" dateTime={new Date(h.at).toISOString()}>{dateTime(h.at)}</time>350                    <span className="font-mono text-[12px] text-text">{h.action}</span>351                    <span className="min-w-0 break-all">{h.target}</span>352                    {h.actor && <span className="text-faint">by {h.actor}</span>}353                  </li>354                ))}355              </ol>356            )}357          </Section>358        </div>359360        <aside aria-label="Release details" className="space-y-5 lg:sticky lg:top-20 lg:self-start">361          {rel && rel.state === "published" && (362            <div className="card p-5">363              <a className="btn btn-primary w-full" href={releaseDto(a, rel).downloadUrl.replace(/^https?:\/\/[^/]+/, "")} download>364                <Download size={15} aria-hidden /> Download v{rel.version}365              </a>366              <p className="mb-1.5 mt-4 text-xs text-muted">SHA-256 of the archive ({bytes(Number(rel.size_bytes))})</p>367              <Checksum sha256={rel.sha256} />368              <div className="mt-3">369                <CodeBlock prompt label="Verify after downloading" code={`echo "${rel.sha256}  ${a.slug}-${rel.version}.tar.gz" | shasum -a 256 -c -`} />370              </div>371            </div>372          )}373          <dl className="card px-5 py-2">374            <Meta label="Type">{TYPE_LABELS[a.type].singular}</Meta>375            <Meta label="Version">{rel ? rel.version : missing}</Meta>376            <Meta label="Published">{rel ? date(rel.published_at) : missing}</Meta>377            <Meta label="Licence">{a.license ?? missing}</Meta>378            <Meta label="Maintainer"><Link className="link" href={`/@${a.namespace}`}>{dto.publisher.displayName ?? `@${a.namespace}`}</Link></Meta>379            <Meta label="Source">380              {a.repository_url ? <a className="link inline-flex items-center gap-1" href={a.repository_url} rel="nofollow noopener noreferrer">repository <ExternalLink size={11} aria-hidden /></a> : missing}381            </Meta>382            <Meta label="Source commit">{rel?.source_commit ? <code className="font-mono text-[12px]">{rel.source_commit.slice(0, 12)}</code> : missing}</Meta>383            <Meta label="Homepage">{a.homepage_url ? <a className="link" href={a.homepage_url} rel="nofollow noopener noreferrer">link</a> : missing}</Meta>384            <Meta label="Issues">{a.issues_url ? <a className="link" href={a.issues_url} rel="nofollow noopener noreferrer">tracker</a> : missing}</Meta>385            <Meta label="Downloads (30 d, est.)"><Link href="/docs/security#download-counts" className="link">{dto.downloads30d.toLocaleString("en-CA")}</Link></Meta>386          </dl>387          {a.keywords.length > 0 && (388            <ul className="flex flex-wrap gap-1.5" aria-label="Keywords">389              {a.keywords.map((k) => (390                <li key={k}><Link href={`/explore?keyword=${k}`} className="chip hover:border-line-strong hover:text-text">#{k}</Link></li>391              ))}392            </ul>393          )}394          <div className="card p-5 text-sm">395            <p className="eyebrow mb-2">API</p>396            <code className="block break-all font-mono text-[12px] text-muted">GET /api/v1/artifacts/{a.namespace}/{a.slug}</code>397            <p className="mt-3 flex flex-wrap gap-x-4 gap-y-1">398              <a className="link" href={`/api/v1/artifacts/${a.namespace}/${a.slug}`}>metadata JSON</a>399              {rel && <a className="link" href={`/api/v1/artifacts/${a.namespace}/${a.slug}/versions/${rel.version}`}>release JSON</a>}400            </p>401          </div>402          <Link href={`/report?artifact=${encodeURIComponent(name)}${rel ? `&version=${rel.version}` : ""}`} className="inline-flex items-center gap-2 text-sm text-muted hover:text-text">403            <Flag size={13} aria-hidden /> Report a problem with this artifact404          </Link>405        </aside>406      </div>407408      {relatedItems.length > 0 && (409        <section aria-labelledby="related-h" className="mt-16 border-t border-line pt-8">410          <h2 id="related-h" className="mb-4 text-lg font-semibold tracking-tight">More {TYPE_LABELS[a.type].plural.toLowerCase()}</h2>411          <ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">{relatedItems.map((r) => <li key={r.id}><ArtifactCard a={r} /></li>)}</ul>412        </section>413      )}414    </div>415  );416}417
Files main
Menu

Theme