feat: CNPM registry browser - #8
Conversation
Add npmmirror registry browser at /cnpm with package search, landing page, and package detail views (README, versions, files, deps, download trends). Data is fetched browser-direct from registry.npmmirror.com.
Archive cnpm-registry-browser and simplify-deployment-documentation changes; sync their delta specs to the main specs tree.
There was a problem hiding this comment.
Pull request overview
This PR adds a new CNPM registry browsing experience to the web app (apps/web) that directly queries registry.npmmirror.com from the browser (no backend proxy), and archives/spec-syncs the related OpenSpec changes (including deployment documentation governance updates).
Changes:
- Add
/cnpmlanding,/cnpm/search, and/cnpm/pkg/*package browsing routes (README, versions, deps, files, trends placeholder) with a new CNPM nav entry in the shared header. - Introduce a browser-direct registry data layer (
apps/web/app/lib/registry/*) plus new CNPM UI components (search form, stats, recent visits, charts, file tree, etc.). - Add/lock
rechartsdependency and add smoke tests for the CNPM pages; sync and archive OpenSpec specs/changes.
Reviewed changes
Copilot reviewed 36 out of 45 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Locks new frontend dependency graph (notably recharts). |
| apps/web/package.json | Adds recharts dependency for charts. |
| apps/web/app/routes.ts | Wires new /cnpm routes into React Router. |
| apps/web/app/components/Layout.tsx | Adds CNPM navigation entry (desktop + mobile). |
| apps/web/app/routes/cnpm.tsx | Implements CNPM landing page (search, stats, popular, recent, guide). |
| apps/web/app/routes/cnpm.search.tsx | Implements CNPM search page with pagination and states. |
| apps/web/app/routes/cnpm.pkg.tsx | Implements package catch-all route with tabs and version switching. |
| apps/web/app/lib/registry/client.ts | Registry fetch wrappers + shared query hook. |
| apps/web/app/lib/registry/types.ts | Types for registry responses (manifest/search/files/downloads). |
| apps/web/app/lib/registry/parse.ts | URL parsing + version/date/number formatting helpers. |
| apps/web/app/lib/registry/parse.test.ts | Unit tests for parsing/sorting/formatters. |
| apps/web/app/lib/registry/use-recent.ts | localStorage-backed “recently visited packages” hook. |
| apps/web/app/lib/registry/gravatar.ts | Gravatar hashing/url helpers for maintainers UI. |
| apps/web/app/components/ui/chart.tsx | Adds shadcn chart wrapper (recharts-backed). |
| apps/web/app/components/cnpm/NpmSearchForm.tsx | Shared search form for landing + search page. |
| apps/web/app/components/cnpm/RegistryStats.tsx | Displays registry stats (doc count/downloads). |
| apps/web/app/components/cnpm/RegistryGuide.tsx | Copyable npm registry configuration guide. |
| apps/web/app/components/cnpm/RecentVisited.tsx | Displays and manages recently visited packages. |
| apps/web/app/components/cnpm/PkgHeader.tsx | Package header (version select, install command copy, tabs). |
| apps/web/app/components/cnpm/PkgTabs.tsx | Tab navigation for package sub-pages. |
| apps/web/app/components/cnpm/PkgSidebar.tsx | Sidebar with downloads, maintainers, resource links. |
| apps/web/app/components/cnpm/DownloadCard.tsx | Download totals + chart visualization. |
| apps/web/app/components/cnpm/VersionTable.tsx | Version list table with tags and publish dates. |
| apps/web/app/components/cnpm/DepsView.tsx | Dependency group tables with links to package pages. |
| apps/web/app/components/cnpm/FilesView.tsx | File tree + file preview for package artifacts. |
| apps/web/app/components/cnpm/MaintainersCard.tsx | Maintainers list with avatar/fallbacks. |
| apps/web/tests/CnpmRegistry.test.tsx | Smoke tests for landing/search/pkg routes with mocked fetch. |
| openspec/specs/production-ops/spec.md | Adds ops requirements about durable, repeatable constraints. |
| openspec/specs/production-deployment-governance/spec.md | Adds governance requirement for generic/versioned deploy assets. |
| openspec/specs/documentation-information-architecture/spec.md | Establishes deployment/README.md as the single long-term deploy entry. |
| openspec/specs/container-image-delivery/spec.md | Requires unified compose entrypoint + no local builds in deploy commands. |
| openspec/specs/cnpm-registry-browser/spec.md | New spec defining CNPM registry browser requirements. |
| openspec/changes/archive/2026-08-05-simplify-deployment-documentation/.openspec.yaml | Archives simplify-deployment-documentation change metadata. |
| openspec/changes/archive/2026-08-05-simplify-deployment-documentation/tasks.md | Archived task checklist for deployment doc simplification. |
| openspec/changes/archive/2026-08-05-simplify-deployment-documentation/proposal.md | Archived proposal for deployment doc simplification. |
| openspec/changes/archive/2026-08-05-simplify-deployment-documentation/design.md | Archived design notes for deployment doc simplification. |
| openspec/changes/archive/2026-08-05-simplify-deployment-documentation/specs/production-ops/spec.md | Archived delta for production-ops spec. |
| openspec/changes/archive/2026-08-05-simplify-deployment-documentation/specs/production-deployment-governance/spec.md | Archived delta for production-deployment-governance spec. |
| openspec/changes/archive/2026-08-05-simplify-deployment-documentation/specs/documentation-information-architecture/spec.md | Archived delta for documentation IA spec. |
| openspec/changes/archive/2026-08-05-simplify-deployment-documentation/specs/container-image-delivery/spec.md | Archived delta for container-image-delivery spec. |
| openspec/changes/archive/2026-08-05-cnpm-registry-browser/.openspec.yaml | Archives cnpm-registry-browser change metadata. |
| openspec/changes/archive/2026-08-05-cnpm-registry-browser/tasks.md | Archived task checklist for CNPM registry browser. |
| openspec/changes/archive/2026-08-05-cnpm-registry-browser/proposal.md | Archived proposal for CNPM registry browser. |
| openspec/changes/archive/2026-08-05-cnpm-registry-browser/design.md | Archived design notes for CNPM registry browser. |
| openspec/changes/archive/2026-08-05-cnpm-registry-browser/specs/cnpm-registry-browser/spec.md | Archived delta for cnpm-registry-browser spec. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (3)
apps/web/app/lib/registry/client.ts:77
getFileContentconcatenates.../files${path}without ensuringpathstarts with/. If the API returns paths without a leading slash, this will generate invalid URLs (e.g./filespackage.json). Normalizing to a leading slash avoids this class of failures.
export async function getFileContent(pkg: string, spec: string, path: string) {
const res = await fetch(
`${REGISTRY}/${pkgPath(pkg)}/${encodeURIComponent(spec)}/files${path}`,
);
apps/web/app/routes/cnpm.pkg.tsx:38
- After switching
handleVersionChangeto rely onsetParams, thenavigatevariable becomes unused and will fail lint/typecheck under common no-unused-vars rules. It should be removed.
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
apps/web/app/components/cnpm/FilesView.tsx:204
depthis passed through recursively but wasn’t being applied to file rows. Indenting file entries as well keeps the tree readable and preventsdepthfrom being an unused param under lint rules.
<button
type="button"
onClick={() => onSelect(entry.path)}
className={cn(
"flex w-full items-center gap-1 rounded-md px-2 py-1 text-left text-sm transition-colors",
isSelected
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function getDir(pkg: string, spec: string, path: string) { | ||
| const dirPath = path && path !== "/" ? `${path}/` : ""; | ||
| return registryJson<RegistryFilesResponse>( | ||
| `/${pkgPath(pkg)}/${encodeURIComponent(spec)}/files${dirPath}?meta`, | ||
| ); | ||
| } |
| const handleVersionChange = (next: string) => { | ||
| const nextParams = new URLSearchParams(params); | ||
| nextParams.set("version", next); | ||
| navigate(`${location.pathname}?${nextParams.toString()}`, { replace: true }); | ||
| setParams(nextParams, { replace: true }); | ||
| }; |
| <Link | ||
| to={`/cnpm/pkg/${pkg}?version=${encodeURIComponent(spec)}`} | ||
| className="text-foreground hover:text-primary" | ||
| > | ||
| {pkg} | ||
| </Link> |
| export async function gravatarHash(email: string | undefined) { | ||
| if (!email || typeof crypto === "undefined" || !crypto.subtle) return null; | ||
| try { | ||
| const data = new TextEncoder().encode(email.trim().toLowerCase()); | ||
| const digest = await crypto.subtle.digest("MD5", data); |
| <button | ||
| type="button" | ||
| onClick={() => onToggleDir(entry.path)} | ||
| className="flex w-full items-center gap-1 rounded-md px-2 py-1 text-left text-sm text-foreground transition-colors hover:bg-accent" | ||
| > |
- Use setParams for version switching (drop location/navigate globals) - Drop misleading ?version= range param on dependency links - Hash gravatar emails with @noble/hashes MD5 (WebCrypto lacks MD5) - Expose aria-expanded and depth-based indentation in the file tree - Normalize leading slashes when building registry file URLs
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 45 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (4)
apps/web/app/lib/registry/client.ts:35
pkgPath()currently returns the raw package name. For scoped packages (e.g.@babel/core), this makes registry URLs like/downloads/range/.../@babel/coreand/:pkg/:spec/files...treat the package name as multiple path segments, which will break those requests. Encode the package name so it stays a single URL segment.
function pkgPath(pkg: string) {
return pkg;
}
apps/web/app/lib/registry/types.ts:33
RegistryManifest.repositoryis typed as an object only, butrepoUrl()(used in bothPkgHeader/PkgSidebar) handles the common case whererepositoryis a string. With TS strict,typeof repository === "string"will be flagged as unreachable. Update the type to match actual registry data.
readme?: string;
homepage?: string;
repository?: { type?: string; url?: string };
maintainers?: Array<{ name: string; email?: string }>;
"dist-tags": Record<string, string>;
apps/web/app/components/cnpm/NpmSearchForm.tsx:25
NpmSearchForminitializes its internal state frominitialValueonce, but doesn’t update wheninitialValuechanges (e.g. navigating from/cnpm/search?q=reactto another keyword). This can leave the input showing a stale query that doesn’t match the current URL.
import { useNavigate } from "react-router";
import { useState } from "react";
import { Search as SearchIcon } from "lucide-react";
import {
InputGroup,
apps/web/app/components/cnpm/VersionTable.tsx:19
formatDate()usesnew Date(Number(value) || String(value)), which treats0(or the string "0") as falsy and falls back to the string parse. That can yield incorrect dates for epoch-based timestamps. Parse numeric timestamps without the||fallback.
function formatDate(value: number | string | undefined) {
if (!value) return "-";
const date = new Date(Number(value) || String(value));
if (Number.isNaN(date.getTime())) return "-";
return date.toISOString().slice(0, 10);
}
- Sync NpmSearchForm input when the initial keyword changes - Allow string form of package repository in the manifest type - Parse epoch-based publish dates without the falsy fallback
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 45 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (7)
apps/web/tests/CnpmRegistry.test.tsx:90
- Avoid sleeping in tests (
setTimeout) to wait for UI updates; usewaitForto make the assertion resilient to timing variance.
renderRoute(<CnpmLanding />, "/cnpm");
await new Promise((resolve) => setTimeout(resolve, 20));
expect(screen.queryByText("包数量")).not.toBeInTheDocument();
apps/web/app/components/cnpm/DownloadCard.tsx:73
- Hardcoding the number locale to "en-US" can render separators unexpectedly for zh-CN users and is inconsistent with the rest of the UI. Prefer using the runtime default locale (or an explicit app locale) here.
<span className="font-mono text-xl font-semibold tabular-nums text-foreground">
{total.toLocaleString("en-US")}
</span>
apps/web/app/components/cnpm/VersionTable.tsx:20
toISOString().slice(0, 10)formats the date in UTC, which can show the wrong calendar day for users in non-UTC timezones. Consider formatting using local date parts to avoid timezone shifts.
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 date.toISOString().slice(0, 10);
apps/web/app/components/cnpm/MaintainersCard.tsx:36
- Rendering maintainer email addresses from the npm manifest exposes third-party PII in the UI. Consider omitting emails by default (or gating behind an explicit user action) while still showing maintainer names/avatars.
<div className="truncate text-sm font-medium text-foreground">{maintainer.name}</div>
{maintainer.email && (
<div className="truncate text-xs text-muted-foreground">{maintainer.email}</div>
)}
apps/web/app/lib/registry/parse.ts:54
useVersionTagsis a pure helper (it doesn't call React hooks) but its name looks like a hook. Renaming to something likegetVersionTagswould avoid confusion and prevent accidental misuse.
export function useVersionTags(manifest: RegistryManifest): Record<string, string[]> {
const tagsMap = manifest["dist-tags"] || {};
const result: Record<string, string[]> = {};
for (const [tag, version] of Object.entries(tagsMap)) {
if (!result[version]) result[version] = [];
result[version].push(tag);
}
return result;
}
apps/web/app/components/cnpm/NpmSearchForm.tsx:53
- For better form semantics (and to align with common accessibility guidance), the search input should have a stable
nameand usetype="search". AddingautoComplete="off"also helps avoid password-manager/autofill noise for this non-auth field.
<InputGroupInput
type="text"
value={value}
autoFocus={autoFocus}
onChange={(event) => setValue(event.target.value)}
placeholder="搜索 npm 包,如 react、@babel/core..."
aria-label="搜索 npm 包"
className={cn("h-full", large && "text-base")}
/>
apps/web/tests/CnpmRegistry.test.tsx:1
- This test suite will need
waitForfor resilient async assertions (see the setTimeout-based wait below). Import it from@testing-library/reactso timing-sensitive assertions can retry until they pass.
This issue also appears on line 88 of the same file.
import { render, screen } from "@testing-library/react";
- Rename pure helper useVersionTags to getVersionTags - Format publish dates in local time instead of UTC - Drop maintainer email (PII) from the UI - Use type=search with a stable name and autocomplete off - Replace test setTimeout waits with waitFor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 45 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (6)
apps/web/app/lib/registry/client.ts:81
getFileContent()builds the file URL using the rawpathvalue. If the path contains reserved URL characters (spaces,?,#, etc.), the fetch will fail or request the wrong resource. Encode path segments before concatenating.
export async function getFileContent(pkg: string, spec: string, path: string) {
const res = await fetch(
`${REGISTRY}/${pkgPath(pkg)}/${encodeURIComponent(spec)}/files${ensureLeadingSlash(path)}`,
);
apps/web/app/lib/registry/client.ts:72
getDir()interpolates the directory path directly into the URL without encoding path segments. Package files can include spaces,?,#, etc., which will break the request (or be interpreted as query/fragment) and make the file browser unreliable.
This issue also appears on line 78 of the same file.
export function getDir(pkg: string, spec: string, path: string) {
const dirPath = path && path !== "/" ? `${ensureLeadingSlash(path)}/` : "";
return registryJson<RegistryFilesResponse>(
`/${pkgPath(pkg)}/${encodeURIComponent(spec)}/files${dirPath}?meta`,
);
apps/web/app/lib/registry/client.ts:47
getVersion()is typed as returningRegistryManifest, but the/:pkg/:versionendpoint returns a single version payload (matchingRegistryVersion). The current type will mislead callers and can hide real shape mismatches.
export function getVersion(pkg: string, version: string) {
return registryJson<RegistryManifest>(`/${pkgPath(pkg)}/${encodeURIComponent(version)}`);
}
apps/web/app/components/cnpm/DownloadCard.tsx:32
DownloadCardrefetches downloads wheneverversionchanges even though the request is package-wide (getDownloads(pkgName, range)). This causes unnecessary network traffic and UI loading states when users switch versions.
const { data, loading } = useRegistryQuery(
() => getDownloads(pkgName, range),
[pkgName, version, range],
);
apps/web/app/routes/cnpm.search.tsx:27
meta()uses a fallbackq = "npm 包"when the URL has noqparameter, but still bakes that fallback into the canonicalpath(/cnpm/search?q=...). This makes the generated canonical/OG URL diverge from the real route (/cnpm/search) and can cause duplicate indexing/share URLs.
export function meta({ location }: { location: { search: string } }) {
const q = new URLSearchParams(location.search).get("q") || "npm 包";
return seoMeta({
title: `搜索 ${q} · CNPM 镜像`,
description: `在 npmmirror 镜像搜索 npm 包「${q}」。`,
apps/web/app/routes/cnpm.pkg.tsx:29
meta()always setspathto/cnpm/pkg/${name}even when the user is on a tab route like/cnpm/pkg/:name/versionsor/files. That makes the canonical/OG URL inconsistent with the actual page being viewed and can cause incorrect shares and duplicate indexing across tabs.
export function meta({ params }: { params: { "*"?: string } }) {
const { name } = parsePkgPath(params["*"]);
return seoMeta({
title: name ? `${name} · CNPM 镜像` : "CNPM 包浏览器",
description: name ? `查看 npm 包 ${name} 的 README、版本、依赖与文件。` : undefined,
- URL-encode file path segments in the registry client - Drop unused getVersion and DownloadCard version prop (downloads are package-wide) - Keep canonical paths consistent with the real tab/search URLs
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 45 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (6)
apps/web/app/components/cnpm/VersionTable.tsx:66
- The local
formatSizehelper duplicatesformatBytesbehavior and can be removed to reduce duplication. Using a single formatter also centralizes edge-case handling (undefined/NaN/0).
<TableCell className="text-muted-foreground">
{item.dist?.size !== undefined
? formatSize(item.dist.size)
: item.dist?.unpackedSize !== undefined
? formatSize(item.dist.unpackedSize)
apps/web/app/components/cnpm/FilesView.tsx:42
loadDircaches an empty array on failure and uses a truthy check (dirChildren[path]) to short-circuit future loads. After a transient error, the directory cannot be reloaded, so users get stuck with an empty tree. Consider not caching failures and collapsing the directory on error so re-expanding retries the request.
async (path: string) => {
if (dirChildren[path] || dirLoading[path]) return;
setDirLoading((prev) => ({ ...prev, [path]: true }));
setDirError(null);
try {
apps/web/app/components/cnpm/FilesView.tsx:260
hljs.highlightAutocan be very expensive on large files (package artifacts can easily be hundreds of KB/MB), which may freeze the UI. Consider skipping syntax highlighting above a size threshold and just HTML-escaping the content.
function highlighted(code: string) {
try {
return hljs.highlightAuto(code).value;
} catch {
return escapeHtml(code);
}
apps/web/app/components/cnpm/VersionTable.tsx:11
VersionTableduplicates byte formatting logic even though~/lib/registry/parsealready exportsformatBytes. Reusing the shared formatter helps keep output consistent across the CNPM UI.
This issue also appears on line 62 of the same file.
import { sortVersions, getVersionTags } from "~/lib/registry/parse";
apps/web/app/components/cnpm/PkgHeader.tsx:20
repoUrlis duplicated here and inPkgSidebar.tsx, which makes future fixes (e.g. handling more git URL formats) easy to miss in one place. Consider extracting this to a shared helper under~/lib/registry/(or similar) and reusing it in both components.
function repoUrl(repository: RegistryManifest["repository"]) {
if (!repository) return undefined;
const url = typeof repository === "string" ? repository : repository.url;
if (!url) return undefined;
if (/^git(\+ssh)?:\/\//.test(url)) {
apps/web/app/components/cnpm/FilesView.tsx:125
dirErroris currently rendered assr-only, so sighted users get no feedback when a directory load fails. Consider rendering a visible inline status message (even a simple text block) so failures are discoverable without a screen reader.
{dirError && (
<p className="sr-only" role="status">
{dirError}
</p>
)}
- Reuse shared formatBytes and repoUrl helpers instead of local copies - Let directory loads retry after failure and show visible error feedback - Skip syntax highlighting for very large files
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 45 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (5)
apps/web/app/lib/registry/client.ts:35
pkgPathcurrently returns the raw package name. For scoped packages (e.g.@babel/core), this leaks a/into registry endpoints like/downloads/range/.../:pkgand will be interpreted as an extra path segment, breaking downloads/files requests for scoped packages. Encode the package name so it stays a single path segment.
function pkgPath(pkg: string) {
return pkg;
}
apps/web/app/routes/cnpm.search.tsx:115
- The empty-search state is gated by
!loading && !error, but the hook still setsloading=truebriefly even when there is no query. With theenabledflag, the empty state can render immediately whenqis blank.
{!loading && !error && !q && (
<Empty>
<EmptyHeader>
<EmptyTitle>输入关键词搜索</EmptyTitle>
<EmptyDescription>搜索 npm 包名、描述、关键词</EmptyDescription>
</EmptyHeader>
</Empty>
)}
apps/web/app/routes/cnpm.search.tsx:56
- The search route always enters a loading state (and uses an unsafe double-cast) even when
qis empty. This causes a brief “正在搜索 ” skeleton flash on/cnpm/searchwithout a query, and the cast bypasses type-safety. Use anenabledflag to skip real fetching whenqis blank and remove theunknowncast.
const { data, error, loading, retry } = useRegistryQuery(
() =>
q
? searchPackages(q, from, PAGE_SIZE)
: Promise.resolve({ objects: [], total: 0 } as unknown as Awaited<ReturnType<typeof searchPackages>>),
[q, from],
);
apps/web/app/components/cnpm/FilesView.tsx:105
dirErroris rendered as a third flex child of the main container. Onmd(row layout), this can place the error message as a third column instead of below the tree/viewer, which is likely unintended and hurts readability. Allow the row to wrap so the error message can drop below.
<div className="flex flex-col gap-4 md:flex-row">
<div className="max-h-[70vh] w-full overflow-auto rounded-lg border bg-muted/30 p-2 md:max-w-sm">
apps/web/app/components/cnpm/FilesView.tsx:132
- Even with wrapping enabled, the
dirErrormessage needs a full-width basis so it consistently renders below the two main panes on larger screens.
<p
role="status"
className="mt-2 rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs text-destructive"
>
目录加载失败:{dirError},请重新展开重试
</p>
- Add enabled flag to useRegistryQuery so the search route no longer briefly flashes a loading skeleton (and no unsafe double-cast) when q is empty; the empty state renders immediately - Give dirError a full-width basis and let the files row wrap so the error message drops below both panes instead of forming a third column
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 45 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (3)
apps/web/app/lib/registry/client.ts:102
useRegistryQueryinitializesloadingtotrueeven whenenabledisfalse, which causes callers like/cnpm/search(noqparam) to briefly render the loading skeleton before switching to the empty state. Initialize the loading state fromenabledto avoid this UI flash.
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<RegistryError | null>(null);
const [loading, setLoading] = useState(true);
const [attempt, setAttempt] = useState(0);
apps/web/app/components/cnpm/DownloadCard.tsx:47
DownloadCardignoresuseRegistryQueryerrors, so network/HTTP failures are currently rendered as "暂无数据" instead of an error state with a retry action. This makes real failures indistinguishable from legitimately empty download data.
const { data, loading } = useRegistryQuery(
() => getDownloads(pkgName, range),
[pkgName, range],
);
apps/web/app/routes/cnpm.pkg.tsx:49
CnpmPkgInnertriggersuseRegistryQueryeven whennamecannot be parsed (it rejects with a synthetic 404). Since the component immediately renders the "无效的包名" empty state, this extra async work is unnecessary and makes the hook usage harder to follow. Prefer disabling the query whennameis falsy.
const { data: manifest, error, loading, retry } = useRegistryQuery(
() => (name ? getManifest(name) : Promise.reject(new RegistryError("Missing package name", 404))),
[name],
);
- Clamp the download range to at least 1 so range<=0 no longer produces an inverted /downloads/range/:from::to/ request - Request the files root with a trailing slash (/files/?) consistent with subdirectory listings and getFileContent
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 45 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
apps/web/app/lib/registry/client.ts:80
getDir()appends a trailing slash unconditionally. If the registry returns directory paths that already end with/(common in file-tree APIs), this builds URLs like/files/lib//?meta, which can break directory loading or create redundant cache keys. Normalizepathby trimming trailing slashes before buildingdirPath.
export function getDir(pkg: string, spec: string, path: string) {
const dirPath =
path && path !== "/" ? `${encodeFilePath(ensureLeadingSlash(path))}/` : "/";
return registryJson<RegistryFilesResponse>(
`/${pkgPath(pkg)}/${encodeURIComponent(spec)}/files${dirPath}?meta`,
);
Trim trailing slashes before building the directory URL so a path that already ends with / cannot produce a double-slash /files/lib//?meta request or redundant cache keys.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 45 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (5)
apps/web/app/components/cnpm/RegistryStats.tsx:20
doc_count为 0 时也会被当成 falsy 直接隐藏统计区块;建议仅在doc_count为 null/undefined 时隐藏,以免出现合法 0 值时 UI 不显示。
if (!data || !data.doc_count) {
return null;
}
apps/web/app/routes/cnpm.tsx:40
- Landing 页默认
autoFocus会在移动端触发键盘弹出,影响首屏浏览;建议移除默认 autofocus,或仅在桌面端启用(例如基于 pointer/viewport 判断)。
搜索并浏览 npm 包信息,配合 npmmirror 国内镜像加速安装。
</p>
<NpmSearchForm autoFocus size="lg" />
</div>
apps/web/app/components/cnpm/VersionTable.tsx:23
- 发布时间格式手写
YYYY-MM-DD(且依赖本地时区字段)属于硬编码日期格式;建议使用Intl.DateTimeFormat以避免 locale/格式不一致问题。
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 "-";
apps/web/app/components/cnpm/MaintainersCard.tsx:17
maintainer.name作为 React key 可能不唯一(同名不同人/重复条目)会导致渲染警告和状态错位;建议 key 合并 email(若有)以提升唯一性。
{maintainers.map((maintainer) => (
<MaintainerRow key={maintainer.name} maintainer={maintainer} />
))}
apps/web/app/components/cnpm/DownloadCard.tsx:93
- 这里强制使用
toLocaleString("en-US")会固定数字格式为英文环境(逗号分组/小数符号),不符合 i18n/本地化最佳实践;建议用Intl.NumberFormat()(默认使用用户 locale)。
<span className="font-mono text-xl font-semibold tabular-nums text-foreground">
{total.toLocaleString("en-US")}
</span>
- Show registry stats when doc_count is 0 instead of treating it as falsy - Only auto-focus the landing search on pointer:fine devices; focus via ref so it works after the async matchMedia check - Format version publish dates with Intl.DateTimeFormat (en-CA keeps the existing YYYY-MM-DD output) - Use email when available to disambiguate maintainer React keys
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 45 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
apps/web/app/lib/registry/client.ts:71
getDownloadsformats thefrom:todates using the browser’s local timezone (getFullYear/getMonth/getDate). For users outside UTC this can shift the requested range by a day (especially around midnight), making the “近 N 天下载” total/chart inconsistent across timezones. Prefer generatingYYYY-MM-DDin UTC so the same range is requested everywhere.
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - (Math.max(1, range) - 1));
const fmt = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
d.getDate(),
).padStart(2, "0")}`;
Use UTC getters for the from:to range so the requested 7-day window is identical across timezones instead of shifting by a day near midnight.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 36 out of 45 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (2)
apps/web/app/components/cnpm/DownloadCard.tsx:93
total.toLocaleString("en-US")hardcodes an English locale, which can produce unexpected formatting for users in other locales. PreferIntl.NumberFormat()(default locale) ortoLocaleString()without a locale override.
{total.toLocaleString("en-US")}
apps/web/app/routes/cnpm.pkg.tsx:129
- When
?version=is present but invalid (not inmanifest.versions), the UI falls back todist-tags.latest/fallbackVersionbut leaves the URL unchanged. This breaks deep-link correctness (URL no longer reflects rendered state). Consider normalizing the query param to the resolved version via auseEffect.
const requestedVersion = params.get("version") || "";
const fallbackVersion = sortVersions(manifest.versions)[0]?.version;
const version =
requestedVersion && manifest.versions[requestedVersion]
? requestedVersion
: manifest["dist-tags"]?.latest || fallbackVersion;
| 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: <GitFork className="size-4 shrink-0" /> }, | ||
| { label: "主页", href: manifest.homepage, icon: <Globe className="size-4 shrink-0" /> }, | ||
| { label: "npmjs.com", href: `https://www.npmjs.com/package/${manifest.name}`, icon: <PackageIcon className="size-4 shrink-0" /> }, | ||
| { label: "unpkg", href: `https://unpkg.com/${manifest.name}@${version}`, icon: <ExternalLink className="size-4 shrink-0" /> }, | ||
| dist?.tarball | ||
| ? { | ||
| label: `tarball${dist.size !== undefined ? ` · ${formatBytes(dist.size)}` : ""}`, | ||
| href: dist.tarball, | ||
| icon: <Download className="size-4 shrink-0" />, | ||
| } | ||
| : null, | ||
| ]; |
| {manifest.homepage && ( | ||
| <a | ||
| href={manifest.homepage} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-primary" | ||
| > | ||
| <Globe className="size-3.5" /> 主页 | ||
| <ExternalLink className="size-3" /> | ||
| </a> | ||
| )} |
When ?version= points to a nonexistent version the URL now updates to the actually rendered version so deep links always reflect the page state. Moves version resolution above the early returns so hook order stays stable across loading states.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 37 out of 46 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
apps/web/app/components/cnpm/FilesView.tsx:20
FilesViewkeepsdirChildren/expanded/dirLoadingstate whenpkgNameorspecchanges (e.g. switching versions). This can show a stale directory tree from the previous version and prevent re-fetching because the cache key (entry.path) is reused.
export function FilesView({ pkgName, spec }: { pkgName: string; spec: string }) {
const [params, setParams] = useSearchParams();
const selectedPath = params.get("path") || "";
const [dirChildren, setDirChildren] = useState<Record<string, RegistryFile[]>>({});
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
@copilot resolve the merge conflicts in this pull request |
…yaml conflict Co-authored-by: thonatos <958063+thonatos@users.noreply.github.com>
…ection; merge main Co-authored-by: thonatos <958063+thonatos@users.noreply.github.com>
Done — merged main into this branch (only conflict was |
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).Changes
/cnpmlanding — 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.Tech
apps/web/app/routes.ts; sharedLayout/Header reused.recharts+ shadcnchart.tsxfor the download chart.apps/web/app/lib/registry/(manifest / search / downloads / files), handling scoped packages, version sorting by publish time, and 404/network error states.tests/CnpmRegistry.test.tsx.OpenSpec
cnpm-registry-browserandsimplify-deployment-documentation; syncs their delta specs intoopenspec/specs/.