diff --git a/apps/web/app/components/Layout.tsx b/apps/web/app/components/Layout.tsx index 4d990fa..6b7af7b 100644 --- a/apps/web/app/components/Layout.tsx +++ b/apps/web/app/components/Layout.tsx @@ -11,6 +11,7 @@ import { User, Settings, LogOut, + Boxes, } from "lucide-react"; import { ThemeToggle } from "./ThemeToggle"; import { useNavTransition } from "./NavProgress"; @@ -102,6 +103,10 @@ export function Header() { {z.name} ))} + + + CNPM + API @@ -317,6 +322,12 @@ function MobileNavTrigger({ visibleZones = [] }: { visibleZones?: any[] }) { > API + + CNPM + 该版本没有依赖信息

; + } + + const groups = GROUPS.filter((group) => { + const deps = versionData[group.key]; + return deps && Object.keys(deps).length > 0; + }); + + if (groups.length === 0) { + return

该版本没有任何依赖

; + } + + return ( +
+ {groups.map((group) => { + const deps = versionData[group.key]!; + const entries = Object.entries(deps).sort(([a], [b]) => a.localeCompare(b)); + return ( +
+

+ {group.label} + {entries.length} +

+ + + + 名称 + 版本范围 + + + + {entries.map(([pkg, spec]) => ( + + + + {pkg} + + + {spec} + + ))} + +
+
+ ); + })} +
+ ); +} diff --git a/apps/web/app/components/cnpm/DownloadCard.tsx b/apps/web/app/components/cnpm/DownloadCard.tsx new file mode 100644 index 0000000..86f8b1d --- /dev/null +++ b/apps/web/app/components/cnpm/DownloadCard.tsx @@ -0,0 +1,132 @@ +import { getDownloads, useRegistryQuery } from "~/lib/registry/client"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "~/components/ui/chart"; +import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"; +import { Skeleton } from "~/components/ui/skeleton"; +import { Empty, EmptyDescription, EmptyTitle } from "~/components/ui/empty"; +import { Alert, AlertDescription } from "~/components/ui/alert"; +import { Button } from "~/components/ui/button"; + +const chartConfig = { + downloads: { + label: "下载量", + color: "var(--primary)", + }, +} satisfies ChartConfig; + +export function DownloadCard({ + pkgName, + range = 7, +}: { + pkgName: string; + range?: number; +}) { + const { data, error, loading, retry } = useRegistryQuery( + () => getDownloads(pkgName, range), + [pkgName, range], + ); + + if (loading) { + return ( + + + 下载量 + + + + + + + ); + } + + if (error) { + return ( + + + 下载量 + + + + + 下载数据加载失败 + + + + + + ); + } + + const points = data?.downloads || []; + const total = points.reduce((sum, point) => sum + point.downloads, 0); + + if (points.length === 0) { + return ( + + + 下载量 + + + + 暂无数据 + 该包暂无下载数据 + + + + ); + } + + return ( + + + + 近 {range} 天下载 + + {total.toLocaleString("en-US")} + + + + + + + + + + + + + + value.slice(5)} + /> + } /> + + + + + + ); +} diff --git a/apps/web/app/components/cnpm/FilesView.tsx b/apps/web/app/components/cnpm/FilesView.tsx new file mode 100644 index 0000000..723c912 --- /dev/null +++ b/apps/web/app/components/cnpm/FilesView.tsx @@ -0,0 +1,284 @@ +import { useCallback, useState } from "react"; +import { useSearchParams } from "react-router"; +import { ChevronDown, ChevronRight, File as FileIcon, Folder } from "lucide-react"; +import { getDir, getFileContent, useRegistryQuery } from "~/lib/registry/client"; +import type { RegistryFile } from "~/lib/registry/types"; +import { cn } from "~/lib/utils"; +import { Skeleton } from "~/components/ui/skeleton"; +import { Empty, EmptyDescription, EmptyTitle } from "~/components/ui/empty"; +import { Alert, AlertDescription } from "~/components/ui/alert"; +import { Button } from "~/components/ui/button"; +import hljs from "highlight.js/lib/common"; + +export function FilesView({ pkgName, spec }: { pkgName: string; spec: string }) { + const [params, setParams] = useSearchParams(); + const selectedPath = params.get("path") || ""; + const [dirChildren, setDirChildren] = useState>({}); + const [expanded, setExpanded] = useState>({}); + const [dirLoading, setDirLoading] = useState>({}); + const [dirError, setDirError] = useState(null); + + const { + data: root, + error: rootError, + loading: rootLoading, + retry, + } = useRegistryQuery(() => getDir(pkgName, spec, ""), [pkgName, spec]); + + const loadDir = useCallback( + async (path: string) => { + if (dirChildren[path] || dirLoading[path]) return; + setDirLoading((prev) => ({ ...prev, [path]: true })); + setDirError(null); + try { + const res = await getDir(pkgName, spec, path); + setDirChildren((prev) => ({ ...prev, [path]: res.files || [] })); + } catch (err) { + setDirChildren((prev) => { + const next = { ...prev }; + delete next[path]; + return next; + }); + setExpanded((prev) => ({ ...prev, [path]: false })); + setDirError(err instanceof Error ? err.message : "目录加载失败"); + } finally { + setDirLoading((prev) => ({ ...prev, [path]: false })); + } + }, + [pkgName, spec, dirChildren, dirLoading], + ); + + const toggleDir = useCallback( + (path: string) => { + setExpanded((prev) => { + const next = !prev[path]; + if (next) void loadDir(path); + return { ...prev, [path]: next }; + }); + }, + [loadDir], + ); + + const selectFile = useCallback( + (path: string) => { + const next = new URLSearchParams(params); + if (path) next.set("path", path); + else next.delete("path"); + setParams(next, { replace: true }); + }, + [params, setParams], + ); + + if (rootLoading) { + return ( +
+ + +
+ ); + } + + if (rootError) { + return ( + + + 产物预览失败 + + + + ); + } + + if (!root?.files || root.files.length === 0) { + return ( + + 没有文件 + 该版本没有可浏览的文件 + + ); + } + + return ( +
+
+ +
+
+ {selectedPath ? ( + + ) : ( +
+

选择一个文件查看内容

+
+ )} +
+ {dirError && ( +

+ 目录加载失败:{dirError},请重新展开重试 +

+ )} +
+ ); +} + +function FileNode({ + entries, + depth, + selectedPath, + onSelect, + onToggleDir, + dirChildren, + expanded, + dirLoading, +}: { + entries: RegistryFile[]; + depth: number; + selectedPath: string; + onSelect: (path: string) => void; + onToggleDir: (path: string) => void; + dirChildren: Record; + expanded: Record; + dirLoading: Record; +}) { + const sorted = [...entries].sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1; + return a.path.localeCompare(b.path); + }); + + return ( +
    + {sorted.map((entry) => { + if (entry.type === "directory") { + const isExpanded = !!expanded[entry.path]; + const children = dirChildren[entry.path] ?? entry.files ?? []; + const isLoading = !!dirLoading[entry.path]; + return ( +
  • + + {isExpanded && children.length > 0 && ( + + )} +
  • + ); + } + const isSelected = entry.path === selectedPath; + return ( +
  • + +
  • + ); + })} +
+ ); +} + +function FileViewer({ pkgName, spec, path }: { pkgName: string; spec: string; path: string }) { + const { data: content, error, loading, retry } = useRegistryQuery( + () => getFileContent(pkgName, spec, path), + [pkgName, spec, path], + ); + + if (loading) { + return ; + } + + if (error) { + return ( + + + 文件加载失败 + + + + ); + } + + return ( +
+
+        
+      
+
+ ); +} + +const HIGHLIGHT_MAX_LENGTH = 256 * 1024; + +function highlighted(code: string) { + if (code.length > HIGHLIGHT_MAX_LENGTH) return escapeHtml(code); + try { + return hljs.highlightAuto(code).value; + } catch { + return escapeHtml(code); + } +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function basename(path: string) { + const normalized = path.replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + return index >= 0 ? normalized.slice(index + 1) : normalized; +} diff --git a/apps/web/app/components/cnpm/MaintainersCard.tsx b/apps/web/app/components/cnpm/MaintainersCard.tsx new file mode 100644 index 0000000..cdc561f --- /dev/null +++ b/apps/web/app/components/cnpm/MaintainersCard.tsx @@ -0,0 +1,46 @@ +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; +import type { RegistryManifest } from "~/lib/registry/types"; +import { gravatarHash, gravatarUrl, initials } from "~/lib/registry/gravatar"; + +type Maintainer = NonNullable[number]; + +export function MaintainersCard({ maintainers }: { maintainers: Maintainer[] }) { + return ( + + + 维护者 + + + {maintainers.map((maintainer) => ( + + ))} + + + ); +} + +function MaintainerRow({ maintainer }: { maintainer: Maintainer }) { + const hash = gravatarHash(maintainer.email); + + return ( +
+ + + {initials(maintainer.name)} + +
+
{maintainer.name}
+
+
+ ); +} diff --git a/apps/web/app/components/cnpm/NpmSearchForm.tsx b/apps/web/app/components/cnpm/NpmSearchForm.tsx new file mode 100644 index 0000000..ddd2f34 --- /dev/null +++ b/apps/web/app/components/cnpm/NpmSearchForm.tsx @@ -0,0 +1,77 @@ +import { useNavigate } from "react-router"; +import { useEffect, useRef, useState } from "react"; +import { Search as SearchIcon } from "lucide-react"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "~/components/ui/input-group"; +import { cn } from "~/lib/utils"; + +export function NpmSearchForm({ + initialValue = "", + autoFocus = false, + size = "default", + className, +}: { + initialValue?: string; + autoFocus?: boolean; + size?: "default" | "lg"; + className?: string; +}) { + const navigate = useNavigate(); + const [value, setValue] = useState(initialValue); + const inputRef = useRef(null); + const large = size === "lg"; + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + useEffect(() => { + if (autoFocus) inputRef.current?.focus(); + }, [autoFocus]); + + return ( +
{ + event.preventDefault(); + const keyword = value.trim(); + if (keyword) void navigate(`/cnpm/search?q=${encodeURIComponent(keyword)}`); + }} + > + + + + + setValue(event.target.value)} + placeholder="搜索 npm 包,如 react、@babel/core…" + aria-label="搜索 npm 包" + className={cn("h-full", large && "text-base")} + /> + + + {large && } + 搜索 + + + +
+ ); +} diff --git a/apps/web/app/components/cnpm/PkgHeader.tsx b/apps/web/app/components/cnpm/PkgHeader.tsx new file mode 100644 index 0000000..329e6fa --- /dev/null +++ b/apps/web/app/components/cnpm/PkgHeader.tsx @@ -0,0 +1,127 @@ +import { useState } from "react"; +import { Check, Copy, ExternalLink, GitFork, Globe, Package as PackageIcon } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { sortVersions, getVersionTags, repoUrl, safeExternalUrl, type PkgTab } from "~/lib/registry/parse"; +import type { RegistryManifest } from "~/lib/registry/types"; +import { PkgTabs } from "./PkgTabs"; + +export function PkgHeader({ + manifest, + version, + active, + onVersionChange, +}: { + manifest: RegistryManifest; + version: string; + active: PkgTab; + onVersionChange: (version: string) => void; +}) { + const versions = sortVersions(manifest.versions); + const tags = getVersionTags(manifest); + const [copied, setCopied] = useState(false); + const installCommand = `npm install ${manifest.name}@${version}`; + const repo = repoUrl(manifest.repository); + + const handleVersionChange = (value: string | null) => { + if (value) onVersionChange(value); + }; + + const copy = async () => { + try { + await navigator.clipboard.writeText(installCommand); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } catch { + // clipboard unavailable + } + }; + + return ( +
+
+
+
+ +
+
+
+

+ {manifest.name} + {version} +

+ {tags[version]?.length ? ( + + {tags[version].join(", ")} + + ) : null} +
+ {manifest.description && ( +

{manifest.description}

+ )} +
+ {manifest.license && ( + {manifest.license} + )} + {repo && ( + + 源码 + + )} + {safeExternalUrl(manifest.homepage) && ( + + 主页 + + + )} +
+
+
+
+ + +
+
+ +
+ ); +} diff --git a/apps/web/app/components/cnpm/PkgSidebar.tsx b/apps/web/app/components/cnpm/PkgSidebar.tsx new file mode 100644 index 0000000..a29e5fc --- /dev/null +++ b/apps/web/app/components/cnpm/PkgSidebar.tsx @@ -0,0 +1,56 @@ +import { Download, ExternalLink, GitFork, Globe, Package as PackageIcon } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { DownloadCard } from "./DownloadCard"; +import { MaintainersCard } from "./MaintainersCard"; +import { formatBytes, repoUrl, safeExternalUrl } from "~/lib/registry/parse"; +import type { RegistryManifest } from "~/lib/registry/types"; + +export function PkgSidebar({ manifest, version }: { manifest: RegistryManifest; version: string }) { + const repo = repoUrl(manifest.repository); + const dist = manifest.versions?.[version]?.dist; + const links: Array<{ label: string; href?: string; icon: React.ReactNode } | null> = [ + { label: "仓库", href: repo, icon: }, + { label: "主页", href: safeExternalUrl(manifest.homepage), icon: }, + { label: "npmjs.com", href: `https://www.npmjs.com/package/${manifest.name}`, icon: }, + { label: "unpkg", href: `https://unpkg.com/${manifest.name}@${version}`, icon: }, + dist?.tarball + ? { + label: `tarball${dist.size !== undefined ? ` · ${formatBytes(dist.size)}` : ""}`, + href: safeExternalUrl(dist.tarball), + icon: , + } + : null, + ]; + const visibleLinks = links.filter( + (link): link is { label: string; href: string; icon: React.ReactNode } => Boolean(link && link.href), + ); + + return ( +
+ + {manifest.maintainers && manifest.maintainers.length > 0 && ( + + )} + + + 资源信息 + + + {visibleLinks.map((link) => ( + + {link.icon} + {link.label} + + + ))} + + +
+ ); +} diff --git a/apps/web/app/components/cnpm/PkgTabs.tsx b/apps/web/app/components/cnpm/PkgTabs.tsx new file mode 100644 index 0000000..714e500 --- /dev/null +++ b/apps/web/app/components/cnpm/PkgTabs.tsx @@ -0,0 +1,48 @@ +import { useNavigate } from "react-router"; +import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs"; +import type { PkgTab } from "~/lib/registry/parse"; + +const TABS: Array<{ key: PkgTab; label: string }> = [ + { key: "home", label: "首页" }, + { key: "versions", label: "版本" }, + { key: "files", label: "文件" }, + { key: "deps", label: "依赖" }, + { key: "trends", label: "趋势" }, +]; + +export function PkgTabs({ + name, + active, + version, +}: { + name: string; + active: PkgTab; + version?: string; +}) { + const navigate = useNavigate(); + const versionQuery = version ? `?version=${encodeURIComponent(version)}` : ""; + + const handleChange = (value: string | number) => { + const tab = String(value) as PkgTab; + void navigate( + tab === "home" + ? `/cnpm/pkg/${name}${versionQuery}` + : `/cnpm/pkg/${name}/${tab}${versionQuery}`, + ); + }; + + return ( + + + {TABS.map((tab) => ( + + {tab.label} + + ))} + + + ); +} diff --git a/apps/web/app/components/cnpm/RecentVisited.tsx b/apps/web/app/components/cnpm/RecentVisited.tsx new file mode 100644 index 0000000..7d2dceb --- /dev/null +++ b/apps/web/app/components/cnpm/RecentVisited.tsx @@ -0,0 +1,38 @@ +import { Link } from "react-router"; +import { X } from "lucide-react"; +import { useRecentVisited } from "~/lib/registry/use-recent"; +import { Button } from "~/components/ui/button"; + +export function RecentVisited() { + const { recent, removeRecent } = useRecentVisited(); + if (recent.length === 0) return null; + + return ( +
+ 最近访问 + {recent.map((name) => ( + + + {name} + + + + ))} +
+ ); +} diff --git a/apps/web/app/components/cnpm/RegistryGuide.tsx b/apps/web/app/components/cnpm/RegistryGuide.tsx new file mode 100644 index 0000000..cc53f5b --- /dev/null +++ b/apps/web/app/components/cnpm/RegistryGuide.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { Check, Copy, ExternalLink } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; + +const STEPS = [ + { + title: "临时使用", + description: "单次安装时指定镜像源,不影响全局配置", + command: "npm install react --registry=https://registry.npmmirror.com", + }, + { + title: "全局配置", + description: "将默认 registry 指向 npmmirror,之后安装自动走镜像", + command: "npm config set registry https://registry.npmmirror.com", + }, + { + title: "验证配置", + description: "确认当前 registry 是否为镜像地址", + command: "npm config get registry", + }, +]; + +const REGISTRY_URL = "https://registry.npmmirror.com"; + +function CopyCommand({ command }: { command: string }) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + try { + await navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } catch { + // clipboard unavailable + } + }; + + return ( +
+ + {command} + + +
+ ); +} + +export function RegistryGuide() { + return ( + + + 使用 npmmirror 镜像 + + 通过国内镜像源加速 npm 安装,官方镜像地址: + + {REGISTRY_URL} + + + + + + {STEPS.map((step, index) => ( +
+
+ {index + 1}. +

{step.title}

+
+

{step.description}

+ +
+ ))} +
+
+ ); +} diff --git a/apps/web/app/components/cnpm/RegistryStats.tsx b/apps/web/app/components/cnpm/RegistryStats.tsx new file mode 100644 index 0000000..fd2a752 --- /dev/null +++ b/apps/web/app/components/cnpm/RegistryStats.tsx @@ -0,0 +1,43 @@ +import { getRegistryStats, useRegistryQuery } from "~/lib/registry/client"; +import { formatCompactNumber } from "~/lib/registry/parse"; +import { Skeleton } from "~/components/ui/skeleton"; + +export function RegistryStats() { + const { data, loading } = useRegistryQuery(getRegistryStats, []); + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (!data) { + return null; + } + if (typeof data.doc_count !== "number") { + return null; + } + + const items = [ + { label: "包数量", value: formatCompactNumber(data.doc_count) }, + { label: "本周下载", value: formatCompactNumber(data.download?.thisweek) }, + { label: "今日下载", value: formatCompactNumber(data.download?.today) }, + ]; + + return ( +
+ {items.map((item) => ( +
+ + {item.value} + + {item.label} +
+ ))} +
+ ); +} diff --git a/apps/web/app/components/cnpm/VersionTable.tsx b/apps/web/app/components/cnpm/VersionTable.tsx new file mode 100644 index 0000000..4884602 --- /dev/null +++ b/apps/web/app/components/cnpm/VersionTable.tsx @@ -0,0 +1,73 @@ +import { Link } from "react-router"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import { Badge } from "~/components/ui/badge"; +import { formatBytes, getVersionTags, sortVersions } from "~/lib/registry/parse"; +import type { RegistryManifest } from "~/lib/registry/types"; + +const dateFormatter = new Intl.DateTimeFormat("en-CA", { + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +function formatDate(value: number | string | undefined) { + if (value === undefined || value === null || value === "") return "-"; + const numeric = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value; + const date = new Date(numeric); + if (Number.isNaN(date.getTime())) return "-"; + return dateFormatter.format(date); +} + +export function VersionTable({ manifest, version }: { manifest: RegistryManifest; version: string }) { + const versions = sortVersions(manifest.versions); + const tags = getVersionTags(manifest); + + return ( + + + + 版本 + 标签 + 发布时间 + 大小 + + + + {versions.map((item) => ( + + + + {item.version} + + + +
+ {tags[item.version]?.map((tag) => ( + + {tag} + + ))} +
+
+ + {formatDate(item.publish_time ?? item._cnpmcore_publish_time)} + + + {formatBytes(item.dist?.size ?? item.dist?.unpackedSize)} + +
+ ))} +
+
+ ); +} diff --git a/apps/web/app/components/ui/chart.tsx b/apps/web/app/components/ui/chart.tsx new file mode 100644 index 0000000..0c8f983 --- /dev/null +++ b/apps/web/app/components/ui/chart.tsx @@ -0,0 +1,371 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" +import type { TooltipValueType } from "recharts" + +import { cn } from "~/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const +type TooltipNameType = number | string + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +> + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + initialDimension?: { + width: number + height: number + } +}) { + const uniqueId = React.useId() + const chartId = `chart-${(id ?? uniqueId).replace(/[^a-zA-Z0-9-]/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +