feat: CNPM registry browser - #8
Conversation
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" | ||
| > |
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);
}
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";
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,
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>
)}
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>
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],
);
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/routes/cnpm.pkg.tsx:108
versionfallback usesObject.keys(manifest.versions)[0], which is not guaranteed to be the newest version and can vary depending on JSON/object insertion order. This can cause the page to default to an arbitrary version whendist-tags.latestis missing (or whenversionsis empty). Consider selecting the most recently published version deterministically instead.
const requestedVersion = params.get("version") || "";
const version =
requestedVersion && manifest.versions[requestedVersion]
? requestedVersion
: manifest["dist-tags"]?.latest || Object.keys(manifest.versions || {})[0];
apps/web/app/components/ui/chart.tsx:58
ChartContainerallows a caller-providedidto be interpolated into a CSS selector viadata-chart. Currently only:is stripped from the generated id, so a craftedidcontaining selector-breaking characters could lead to CSS injection. Sanitizing to a safe character set avoids this class of issues.
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
apps/web/app/lib/registry/parse.ts:17
repoUrl()currently handlesgit://andgit+ssh://, but common npm manifests often usegit+https://...forrepository.url. In that case this function returnsundefined, so the UI will incorrectly omit the repo link for many packages.
if (/^git(\+ssh)?:\/\//.test(url)) {
return url.replace(/^git(\+ssh)?:\/\//, "https://").replace(/\.git$/, "");
}
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/parse.test.ts:100
- The
repoUrltest currently asserts thegit+ssh://git@github.com/...normalization ashttps://git@github.com/..., which keeps thegit@userinfo and will break repo links. Update the expectation to match the corrected normalization.
expect(repoUrl("git+https://github.com/foo/bar.git")).toBe("https://github.com/foo/bar");
expect(repoUrl("git+ssh://git@github.com/foo/bar.git")).toBe(
"https://git@github.com/foo/bar",
);
expect(repoUrl("git://github.com/foo/bar.git")).toBe("https://github.com/foo/bar");
apps/web/app/routes/cnpm.pkg.tsx:101
- Returning
nullwhen the manifest payload is missingnameresults in a blank page with no recovery path. It should render an explicit error/empty state (similar to other error branches) so users aren’t stuck with an empty screen.
if (!manifest?.name) return null;
apps/web/app/components/cnpm/MaintainersCard.tsx:31
- This renders maintainer avatars by hashing
maintainer.emailand loading fromwww.gravatar.com, which introduces third‑party requests and uses an identifier derived from an email address (PII). If the project aims to minimize tracking/privacy exposure, consider removing Gravatar requests (fallback to initials only) or gating this behind an explicit opt-in.
function MaintainerRow({ maintainer }: { maintainer: Maintainer }) {
const hash = gravatarHash(maintainer.email);
return (
<div className="flex items-center gap-2.5">
<Avatar className="size-8">
<AvatarImage src={gravatarUrl(hash)} alt={maintainer.name} />
<AvatarFallback>{initials(maintainer.name)}</AvatarFallback>
</Avatar>
apps/web/app/lib/registry/parse.ts:17
repoUrlconvertsgit+ssh://git@github.com/...intohttps://git@github.com/..., which includes thegit@userinfo and results in an invalid/undesired GitHub URL in the UI. Normalize this variant tohttps://github.com/<owner>/<repo>(consistent with thegit@github.com:...branch).
if (/^git(\+ssh)?:\/\//.test(url)) {
return url.replace(/^git(\+ssh)?:\/\//, "https://").replace(/\.git$/, "");
}
Done — merged main into this branch (only conflict was |
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.
- 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
- 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
- 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
- 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
- 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
- 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
- Let the download chart fill its container: override the chart's aspect-video with aspect-auto so the fixed height no longer forces a narrow 16:9 width - Rename HIGHLIGHT_MAX_BYTES to HIGHLIGHT_MAX_LENGTH since it compares string length, not byte size
…ch flash - Initialize loading from enabled so disabled queries never flash a skeleton; /cnpm/search empty state renders without a loading frame - Give DownloadCard an explicit error state with a retry action instead of conflating failures with empty data - Disable the manifest query when the package name is unparseable via the enabled flag instead of rejecting with a synthetic 404
…ttps repos - Fall back to the most recently published version (via sortVersions) instead of the first object key when dist-tags.latest is missing - Strip non [a-zA-Z0-9-] characters from the chart container id before interpolating it into a CSS selector - Recognize git+https:// repository URLs so the repo link is not omitted for packages like lodash that use that format; add unit tests
…anifest - Normalize git+ssh://git@github.com/... to https://github.com/... so the git@ userinfo no longer leaks into the repo link - Replace the bare return null for a nameless manifest with an explicit error alert and retry button
pkgPath now URL-encodes the package name so @babel/core becomes a single %40babel%2Fcore segment in manifest, files and downloads endpoints. Equivalent on npmmirror today and robust against registries that expect the scoped name to be encoded.
- Use a proper ellipsis character in the search placeholder - Give maintainer avatars explicit width/height and lazy loading to prevent layout shift for non-critical images
- 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
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.
- 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
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.
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.
…ection; merge main Co-authored-by: thonatos <958063+thonatos@users.noreply.github.com>
- Import from vite-plus/test in tests instead of vitest - Extract URL strings from fetch mocks without Object stringification - Await/void navigate promises in NpmSearchForm and PkgTabs - Stringify tooltip keys in chart.tsx template literals
5c0d8fa to
d1a2c14
Compare
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/.