Skip to content

Commit 1558f53

Browse files
thonatosCopilot
andauthored
feat: CNPM registry browser (#8)
## Summary Add an npm package search & browse experience powered by the npmmirror registry (`registry.npmmirror.com`), directly from the browser (no backend API round-trip). <img width="5116" height="2524" alt="49a5b55aafb4c1c0b72eb1a09e99c1ea" src="https://github.com/user-attachments/assets/fbfcc85a-8f0a-4997-a100-f3d5e0493c53" /> ### Changes - **`/cnpm` landing** — search entry, registry stats (package count / weekly / daily downloads), popular package pills, recently-visited packages, and an install guide for the npmmirror mirror (`npm config set registry https://registry.npmmirror.com`). - **`/cnpm/search`** — keyword search with pagination. - **`/cnpm/pkg/:name`** — package detail with README, version selector, download chart, maintainers, and resource links. - **`/cnpm/pkg/:name/{versions,files,deps,trends}`** — version list, file tree preview, dependency groups, download trends. - Header nav gains a **CNPM** entry (desktop top bar + mobile sheet). ### Tech - React Router routes wired in `apps/web/app/routes.ts`; shared `Layout`/Header reused. - `recharts` + shadcn `chart.tsx` for the download chart. - Browser-direct data layer in `apps/web/app/lib/registry/` (manifest / search / downloads / files), handling scoped packages, version sorting by publish time, and 404/network error states. - Smoke tests in `tests/CnpmRegistry.test.tsx`. ### OpenSpec - Archives completed changes `cnpm-registry-browser` and `simplify-deployment-documentation`; syncs their delta specs into `openspec/specs/`. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 36efd2a commit 1558f53

45 files changed

Lines changed: 3645 additions & 118 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/web/app/components/Layout.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
User,
1212
Settings,
1313
LogOut,
14+
Boxes,
1415
} from "lucide-react";
1516
import { ThemeToggle } from "./ThemeToggle";
1617
import { useNavTransition } from "./NavProgress";
@@ -102,6 +103,10 @@ export function Header() {
102103
{z.name}
103104
</NavLink>
104105
))}
106+
<NavLink to="/cnpm">
107+
<Boxes className="h-4 w-4" />
108+
CNPM
109+
</NavLink>
105110
<NavLink to="/api">
106111
<Code className="h-4 w-4" />
107112
API
@@ -317,6 +322,12 @@ function MobileNavTrigger({ visibleZones = [] }: { visibleZones?: any[] }) {
317322
>
318323
<Code className="h-5 w-5 text-primary" /> API
319324
</Link>
325+
<Link
326+
to="/cnpm"
327+
className="flex min-h-12 items-center gap-3 rounded-xl bg-muted px-3 text-sm text-foreground transition-colors hover:bg-accent"
328+
>
329+
<Boxes className="h-5 w-5 text-primary" /> CNPM
330+
</Link>
320331
<Link
321332
to="/about"
322333
className="flex min-h-12 items-center gap-3 rounded-xl bg-muted px-3 text-sm text-foreground transition-colors hover:bg-accent"
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Link } from "react-router";
2+
import {
3+
Table,
4+
TableBody,
5+
TableCell,
6+
TableHead,
7+
TableHeader,
8+
TableRow,
9+
} from "~/components/ui/table";
10+
import type { RegistryManifest } from "~/lib/registry/types";
11+
12+
type DepGroup = {
13+
key: "dependencies" | "devDependencies" | "optionalDependencies" | "peerDependencies";
14+
label: string;
15+
};
16+
17+
const GROUPS: DepGroup[] = [
18+
{ key: "dependencies", label: "dependencies" },
19+
{ key: "devDependencies", label: "devDependencies" },
20+
{ key: "optionalDependencies", label: "optionalDependencies" },
21+
{ key: "peerDependencies", label: "peerDependencies" },
22+
];
23+
24+
export function DepsView({ manifest, version }: { manifest: RegistryManifest; version: string }) {
25+
const versionData = manifest.versions?.[version];
26+
if (!versionData) {
27+
return <p className="text-sm text-muted-foreground">该版本没有依赖信息</p>;
28+
}
29+
30+
const groups = GROUPS.filter((group) => {
31+
const deps = versionData[group.key];
32+
return deps && Object.keys(deps).length > 0;
33+
});
34+
35+
if (groups.length === 0) {
36+
return <p className="text-sm text-muted-foreground">该版本没有任何依赖</p>;
37+
}
38+
39+
return (
40+
<div className="flex flex-col gap-6">
41+
{groups.map((group) => {
42+
const deps = versionData[group.key]!;
43+
const entries = Object.entries(deps).sort(([a], [b]) => a.localeCompare(b));
44+
return (
45+
<div key={group.key} className="flex flex-col gap-2">
46+
<h3 className="font-mono text-sm font-medium text-muted-foreground">
47+
{group.label}
48+
<span className="ml-2 text-muted-foreground/70">{entries.length}</span>
49+
</h3>
50+
<Table>
51+
<TableHeader>
52+
<TableRow>
53+
<TableHead>名称</TableHead>
54+
<TableHead>版本范围</TableHead>
55+
</TableRow>
56+
</TableHeader>
57+
<TableBody>
58+
{entries.map(([pkg, spec]) => (
59+
<TableRow key={pkg}>
60+
<TableCell>
61+
<Link
62+
to={`/cnpm/pkg/${pkg}`}
63+
className="text-foreground hover:text-primary"
64+
>
65+
{pkg}
66+
</Link>
67+
</TableCell>
68+
<TableCell className="font-mono text-muted-foreground">{spec}</TableCell>
69+
</TableRow>
70+
))}
71+
</TableBody>
72+
</Table>
73+
</div>
74+
);
75+
})}
76+
</div>
77+
);
78+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { getDownloads, useRegistryQuery } from "~/lib/registry/client";
2+
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
3+
import {
4+
ChartContainer,
5+
ChartTooltip,
6+
ChartTooltipContent,
7+
type ChartConfig,
8+
} from "~/components/ui/chart";
9+
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
10+
import { Skeleton } from "~/components/ui/skeleton";
11+
import { Empty, EmptyDescription, EmptyTitle } from "~/components/ui/empty";
12+
import { Alert, AlertDescription } from "~/components/ui/alert";
13+
import { Button } from "~/components/ui/button";
14+
15+
const chartConfig = {
16+
downloads: {
17+
label: "下载量",
18+
color: "var(--primary)",
19+
},
20+
} satisfies ChartConfig;
21+
22+
export function DownloadCard({
23+
pkgName,
24+
range = 7,
25+
}: {
26+
pkgName: string;
27+
range?: number;
28+
}) {
29+
const { data, error, loading, retry } = useRegistryQuery(
30+
() => getDownloads(pkgName, range),
31+
[pkgName, range],
32+
);
33+
34+
if (loading) {
35+
return (
36+
<Card>
37+
<CardHeader>
38+
<CardTitle>下载量</CardTitle>
39+
</CardHeader>
40+
<CardContent className="flex flex-col gap-3">
41+
<Skeleton className="h-6 w-24" />
42+
<Skeleton className="h-24 w-full" />
43+
</CardContent>
44+
</Card>
45+
);
46+
}
47+
48+
if (error) {
49+
return (
50+
<Card>
51+
<CardHeader>
52+
<CardTitle>下载量</CardTitle>
53+
</CardHeader>
54+
<CardContent>
55+
<Alert variant="destructive">
56+
<AlertDescription className="flex items-center justify-between gap-3">
57+
下载数据加载失败
58+
<Button type="button" variant="outline" size="sm" onClick={retry}>
59+
重试
60+
</Button>
61+
</AlertDescription>
62+
</Alert>
63+
</CardContent>
64+
</Card>
65+
);
66+
}
67+
68+
const points = data?.downloads || [];
69+
const total = points.reduce((sum, point) => sum + point.downloads, 0);
70+
71+
if (points.length === 0) {
72+
return (
73+
<Card>
74+
<CardHeader>
75+
<CardTitle>下载量</CardTitle>
76+
</CardHeader>
77+
<CardContent>
78+
<Empty>
79+
<EmptyTitle>暂无数据</EmptyTitle>
80+
<EmptyDescription>该包暂无下载数据</EmptyDescription>
81+
</Empty>
82+
</CardContent>
83+
</Card>
84+
);
85+
}
86+
87+
return (
88+
<Card>
89+
<CardHeader>
90+
<CardTitle className="flex items-baseline gap-2">
91+
近 {range} 天下载
92+
<span className="font-mono text-xl font-semibold tabular-nums text-foreground">
93+
{total.toLocaleString("en-US")}
94+
</span>
95+
</CardTitle>
96+
</CardHeader>
97+
<CardContent>
98+
<ChartContainer
99+
config={chartConfig}
100+
className="h-32 aspect-auto [&_.recharts-text]:fill-muted-foreground"
101+
>
102+
<AreaChart data={points} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
103+
<defs>
104+
<linearGradient id="cnpm-download-fill" x1="0" y1="0" x2="0" y2="1">
105+
<stop offset="5%" stopColor="var(--color-downloads)" stopOpacity={0.35} />
106+
<stop offset="95%" stopColor="var(--color-downloads)" stopOpacity={0.02} />
107+
</linearGradient>
108+
</defs>
109+
<CartesianGrid vertical={false} strokeDasharray="4 4" />
110+
<XAxis
111+
dataKey="day"
112+
tickLine={false}
113+
axisLine={false}
114+
tickMargin={8}
115+
minTickGap={24}
116+
tickFormatter={(value: string) => value.slice(5)}
117+
/>
118+
<ChartTooltip content={<ChartTooltipContent />} />
119+
<Area
120+
dataKey="downloads"
121+
type="monotone"
122+
fill="url(#cnpm-download-fill)"
123+
stroke="var(--color-downloads)"
124+
strokeWidth={2}
125+
dot={false}
126+
/>
127+
</AreaChart>
128+
</ChartContainer>
129+
</CardContent>
130+
</Card>
131+
);
132+
}

0 commit comments

Comments
 (0)