Skip to content

feat: CNPM registry browser - #8

Open
thonatos wants to merge 22 commits into
mainfrom
feat/cnpm-registry-browser
Open

feat: CNPM registry browser#8
thonatos wants to merge 22 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/.

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

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);
}

- 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

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";

- 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

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,

- 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

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

- 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

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>

- 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

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],
  );

- 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

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 (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. Normalize path by trimming trailing slashes before building dirPath.
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.

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

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 (1)

apps/web/app/lib/registry/client.ts:71

  • getDownloads formats the from:to dates 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 generating YYYY-MM-DD in 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.

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 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. Prefer Intl.NumberFormat() (default locale) or toLocaleString() without a locale override.
            {total.toLocaleString("en-US")}

apps/web/app/routes/cnpm.pkg.tsx:129

  • When ?version= is present but invalid (not in manifest.versions), the UI falls back to dist-tags.latest/fallbackVersion but 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 a useEffect.
  const requestedVersion = params.get("version") || "";
  const fallbackVersion = sortVersions(manifest.versions)[0]?.version;
  const version =
    requestedVersion && manifest.versions[requestedVersion]
      ? requestedVersion
      : manifest["dist-tags"]?.latest || fallbackVersion;

Comment on lines +9 to +23
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,
];
Comment on lines +83 to +93
{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.

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

  • FilesView keeps dirChildren / expanded / dirLoading state when pkgName or spec changes (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>>({});

@thonatos

thonatos commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

@copilot resolve the merge conflicts in this pull request

Copilot AI and others added 2 commits August 6, 2026 03:58
…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>

Copilot AI commented Aug 6, 2026

Copy link
Copy Markdown

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

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