Skip to content

feat: CNPM registry browser - #8

Merged
thonatos merged 24 commits into
mainfrom
feat/cnpm-registry-browser
Aug 6, 2026
Merged

feat: CNPM registry browser#8
thonatos merged 24 commits into
mainfrom
feat/cnpm-registry-browser

Conversation

@thonatos

@thonatos thonatos commented Aug 5, 2026

Copy link
Copy Markdown
Member

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).

49a5b55aafb4c1c0b72eb1a09e99c1ea

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/.

Copilot AI lite review requested due to automatic review settings August 5, 2026 15:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /cnpm landing, /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 recharts dependency 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

  • getFileContent concatenates .../files${path} without ensuring path starts 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 handleVersionChange to rely on setParams, the navigate variable 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

  • depth is passed through recursively but wasn’t being applied to file rows. Indenting file entries as well keeps the tree readable and prevents depth from 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.

Comment on lines +67 to +72
export function getDir(pkg: string, spec: string, path: string) {
const dirPath = path && path !== "/" ? `${path}/` : "";
return registryJson<RegistryFilesResponse>(
`/${pkgPath(pkg)}/${encodeURIComponent(spec)}/files${dirPath}?meta`,
);
}
Comment on lines +104 to +109
const handleVersionChange = (next: string) => {
const nextParams = new URLSearchParams(params);
nextParams.set("version", next);
navigate(`${location.pathname}?${nextParams.toString()}`, { replace: true });
setParams(nextParams, { replace: true });
};
Comment thread apps/web/app/lib/registry/client.ts
Comment thread apps/web/app/routes/cnpm.pkg.tsx Outdated
Comment on lines +61 to +66
<Link
to={`/cnpm/pkg/${pkg}?version=${encodeURIComponent(spec)}`}
className="text-foreground hover:text-primary"
>
{pkg}
</Link>
Comment thread apps/web/app/lib/registry/gravatar.ts Outdated
Comment on lines +1 to +5
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);
Comment on lines +163 to +167
<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"
>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/core and /: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.repository is typed as an object only, but repoUrl() (used in both PkgHeader/PkgSidebar) handles the common case where repository is 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

  • NpmSearchForm initializes its internal state from initialValue once, but doesn’t update when initialValue changes (e.g. navigating from /cnpm/search?q=react to 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() uses new Date(Number(value) || String(value)), which treats 0 (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);
}

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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; use waitFor to 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

  • useVersionTags is a pure helper (it doesn't call React hooks) but its name looks like a hook. Renaming to something like getVersionTags would 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 name and use type="search". Adding autoComplete="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 waitFor for resilient async assertions (see the setTimeout-based wait below). Import it from @testing-library/react so 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";

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 raw path value. 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 returning RegistryManifest, but the /:pkg/:version endpoint returns a single version payload (matching RegistryVersion). 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

  • DownloadCard refetches downloads whenever version changes 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 fallback q = "npm 包" when the URL has no q parameter, but still bakes that fallback into the canonical path (/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 sets path to /cnpm/pkg/${name} even when the user is on a tab route like /cnpm/pkg/:name/versions or /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,

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 formatSize helper duplicates formatBytes behavior 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

  • loadDir caches 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.highlightAuto can 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

  • VersionTable duplicates byte formatting logic even though ~/lib/registry/parse already exports formatBytes. 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

  • repoUrl is duplicated here and in PkgSidebar.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

  • dirError is currently rendered as sr-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>
      )}

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • pkgPath currently returns the raw package name. For scoped packages (e.g. @babel/core), this leaks a / into registry endpoints like /downloads/range/.../:pkg and 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 sets loading=true briefly even when there is no query. With the enabled flag, the empty state can render immediately when q is 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 q is empty. This causes a brief “正在搜索 ” skeleton flash on /cnpm/search without a query, and the cast bypasses type-safety. Use an enabled flag to skip real fetching when q is blank and remove the unknown cast.
  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

  • dirError is rendered as a third flex child of the main container. On md (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 dirError message 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • useRegistryQuery initializes loading to true even when enabled is false, which causes callers like /cnpm/search (no q param) to briefly render the loading skeleton before switching to the empty state. Initialize the loading state from enabled to 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

  • DownloadCard ignores useRegistryQuery errors, 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

  • CnpmPkgInner triggers useRegistryQuery even when name cannot 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 when name is falsy.
  const { data: manifest, error, loading, retry } = useRegistryQuery(
    () => (name ? getManifest(name) : Promise.reject(new RegistryError("Missing package name", 404))),
    [name],
  );

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • version fallback uses Object.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 when dist-tags.latest is missing (or when versions is 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

  • ChartContainer allows a caller-provided id to be interpolated into a CSS selector via data-chart. Currently only : is stripped from the generated id, so a crafted id containing 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 handles git:// and git+ssh://, but common npm manifests often use git+https://... for repository.url. In that case this function returns undefined, 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$/, "");
  }

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 repoUrl test currently asserts the git+ssh://git@github.com/... normalization as https://git@github.com/..., which keeps the git@ 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 null when the manifest payload is missing name results 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.email and loading from www.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

  • repoUrl converts git+ssh://git@github.com/... into https://git@github.com/..., which includes the git@ userinfo and results in an invalid/undesired GitHub URL in the UI. Normalize this variant to https://github.com/<owner>/<repo> (consistent with the git@github.com:... branch).
  if (/^git(\+ssh)?:\/\//.test(url)) {
    return url.replace(/^git(\+ssh)?:\/\//, "https://").replace(/\.git$/, "");
  }

Copilot AI commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Done — merged main into this branch (only conflict was pnpm-lock.yaml, regenerated cleanly). Also addressed the two security review comments while here: added a safeExternalUrl helper that restricts href values to http:/https: schemes, applied it to manifest.homepage in both PkgHeader and PkgSidebar, and to dist.tarball in PkgSidebar. Covered by new unit tests (all 17 parse tests passing).

thonatos and others added 23 commits August 6, 2026 21:35
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
@thonatos
thonatos force-pushed the feat/cnpm-registry-browser branch from 5c0d8fa to d1a2c14 Compare August 6, 2026 13:45
@thonatos
thonatos merged commit 1558f53 into main Aug 6, 2026
2 checks passed
@thonatos
thonatos deleted the feat/cnpm-registry-browser branch August 6, 2026 13:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants