diff --git a/src/app/[...parts]/_page/catalog/CatalogPage.tsx b/src/app/[...parts]/_page/catalog/CatalogPage.tsx
new file mode 100644
index 00000000..0843243d
--- /dev/null
+++ b/src/app/[...parts]/_page/catalog/CatalogPage.tsx
@@ -0,0 +1,76 @@
+import { cacheLife } from "next/cache";
+import { Suspense } from "react";
+import Skeleton from "^/components/ui/Skeleton";
+import getVersionData from "^/lib/api/npm/getVersionData";
+import packument from "^/lib/api/npm/packument";
+import { generateComparisons } from "^/lib/utils/generateComparisons";
+import { getCatalogPackageName } from "^/lib/utils/isCatalogPage";
+import ComparisonList from "./ComparisonList";
+import PackageMeta from "./PackageMeta";
+
+export interface CatalogPageProps {
+ specs: string[];
+}
+
+async function CatalogPageInner({ specs }: CatalogPageProps) {
+ "use cache";
+
+ cacheLife("hours");
+
+ const packageName = getCatalogPackageName(specs);
+
+ if (!packageName) {
+ throw new Error("Invalid catalog page specs");
+ }
+
+ // Fetch package data
+ const [pack, versionMap] = await Promise.all([
+ packument(packageName),
+ getVersionData(packageName),
+ ]);
+
+ // Get all versions
+ const versions = Object.keys(versionMap);
+
+ // Generate comparisons
+ const comparisons = generateComparisons(versions);
+
+ return (
+
+ );
+}
+
+function CatalogPageFallback() {
+ return (
+
+ );
+}
+
+export default function CatalogPage(props: CatalogPageProps) {
+ return (
+ }>
+
+
+ );
+}
diff --git a/src/app/[...parts]/_page/catalog/ComparisonList.tsx b/src/app/[...parts]/_page/catalog/ComparisonList.tsx
new file mode 100644
index 00000000..2112ed77
--- /dev/null
+++ b/src/app/[...parts]/_page/catalog/ComparisonList.tsx
@@ -0,0 +1,104 @@
+import Link from "next/link";
+import BorderBox from "^/components/ui/BorderBox";
+import Heading from "^/components/ui/Heading";
+import Stack from "^/components/ui/Stack";
+import type { Comparison } from "^/lib/utils/generateComparisons";
+import specsToDiff from "^/lib/utils/specsToDiff";
+import PackageNamePrefix from "./PackageNamePrefix";
+import VersionWithHighlight from "./VersionWithHighlight";
+
+export interface ComparisonListProps {
+ packageName: string;
+ comparisons: Comparison[];
+}
+
+/**
+ * Returns which part of the semver changed based on comparison type
+ */
+function getHighlightIndex(type: "major" | "minor" | "patch"): number {
+ switch (type) {
+ case "major":
+ return 0;
+ case "minor":
+ return 1;
+ case "patch":
+ return 2;
+ }
+}
+
+/**
+ * Threshold for omitting the package name in the "to" spec
+ * When package name is 24+ characters, we show "pkg@1.0.0...2.0.0" instead of "pkg@1.0.0...pkg@2.0.0"
+ */
+const PACKAGE_NAME_LENGTH_THRESHOLD = 24;
+
+/**
+ * Right column showing the list of comparisons
+ */
+export default function ComparisonList({
+ packageName,
+ comparisons,
+}: ComparisonListProps) {
+ const showSecondPackageName =
+ packageName.length < PACKAGE_NAME_LENGTH_THRESHOLD;
+
+ if (comparisons.length === 0) {
+ return (
+
+
+ No comparisons available for this package.
+
+
+ );
+ }
+
+ return (
+
+
+ Suggested Diffs
+
+
+
+ {comparisons.map((comparison) => {
+ const highlightIndex = getHighlightIndex(comparison.type);
+ const fromSpec = `${packageName}@${comparison.from}`;
+ const toSpec = `${packageName}@${comparison.to}`;
+ const diffPath = specsToDiff([fromSpec, toSpec]);
+
+ return (
+
+
+
+
+
+ ...
+
+ {showSecondPackageName ? (
+
+ ) : null}
+
+
+
+ {comparison.type}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/[...parts]/_page/catalog/PackageMeta.tsx b/src/app/[...parts]/_page/catalog/PackageMeta.tsx
new file mode 100644
index 00000000..6fa5235a
--- /dev/null
+++ b/src/app/[...parts]/_page/catalog/PackageMeta.tsx
@@ -0,0 +1,142 @@
+import ClientDate from "^/components/ClientDate";
+import ExternalLink from "^/components/ExternalLink";
+import BorderBox from "^/components/ui/BorderBox";
+import Heading from "^/components/ui/Heading";
+import Stack from "^/components/ui/Stack";
+import type { Packument } from "^/lib/api/npm/packument";
+
+export interface PackageMetaProps {
+ packument: Packument;
+}
+
+/**
+ * Left column showing package metadata
+ */
+export default function PackageMeta({ packument }: PackageMetaProps) {
+ const latestVersion = packument["dist-tags"].latest;
+ const latestManifest = packument.versions[latestVersion];
+
+ if (!latestManifest) {
+ return null;
+ }
+
+ const latestTime = packument.time[latestVersion];
+ const npmUrl = `https://www.npmjs.com/package/${packument.name}`;
+ const repositoryUrl =
+ typeof latestManifest.repository === "string"
+ ? latestManifest.repository
+ : latestManifest.repository?.url
+ ?.replace(/^git\+/, "")
+ .replace(/\.git$/, "");
+ const homepageUrl = latestManifest.homepage;
+
+ // Calculate total versions
+ const totalVersions = Object.keys(packument.versions).length;
+
+ // Get keywords
+ const keywords = latestManifest.keywords;
+
+ return (
+
+
+
+ {packument.name}
+
+
+ {latestVersion}
+ {latestTime ? (
+ <>
+ •
+
+ >
+ ) : null}
+
+
+
+ {latestManifest.description ? (
+
+ {latestManifest.description}
+
+ ) : null}
+
+
+ {latestManifest.license ? (
+
+ License:
+ {latestManifest.license}
+
+ ) : null}
+
+ {latestManifest.author ? (
+
+ Author:
+
+ {typeof latestManifest.author === "string"
+ ? latestManifest.author
+ : latestManifest.author.name}
+
+
+ ) : null}
+
+ {totalVersions > 0 && (
+
+
+ Versions:{" "}
+
+ {totalVersions}
+
+ )}
+
+ {latestManifest.maintainers &&
+ latestManifest.maintainers.length > 0 ? (
+
+
+ Maintainers:{" "}
+
+ {latestManifest.maintainers.length}
+
+ ) : null}
+
+
+ {keywords && keywords.length > 0 ? (
+
+ {keywords.slice(0, 10).map((keyword) => (
+
+ {keyword}
+
+ ))}
+
+ ) : null}
+
+
+
+ View on npm →
+
+
+ {repositoryUrl ? (
+
+ Repository →
+
+ ) : null}
+
+ {homepageUrl && homepageUrl !== repositoryUrl ? (
+
+ Homepage →
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/app/[...parts]/_page/catalog/PackageNamePrefix.tsx b/src/app/[...parts]/_page/catalog/PackageNamePrefix.tsx
new file mode 100644
index 00000000..12b0333f
--- /dev/null
+++ b/src/app/[...parts]/_page/catalog/PackageNamePrefix.tsx
@@ -0,0 +1,12 @@
+/**
+ * Displays a faded package name prefix (e.g., "react@")
+ * Used in comparison displays to de-emphasize the package name
+ * and focus attention on the version number
+ */
+export default function PackageNamePrefix({
+ packageName,
+}: {
+ packageName: string;
+}) {
+ return {packageName}@;
+}
diff --git a/src/app/[...parts]/_page/catalog/VersionWithHighlight.tsx b/src/app/[...parts]/_page/catalog/VersionWithHighlight.tsx
new file mode 100644
index 00000000..79f68a04
--- /dev/null
+++ b/src/app/[...parts]/_page/catalog/VersionWithHighlight.tsx
@@ -0,0 +1,38 @@
+import semver from "semver";
+
+export interface VersionWithHighlightProps {
+ version: string;
+ highlightIndex: number;
+}
+
+/**
+ * Highlights the specific semver digit that changed
+ */
+export default function VersionWithHighlight({
+ version,
+ highlightIndex,
+}: VersionWithHighlightProps) {
+ const parsed = semver.parse(version);
+ if (!parsed) {
+ return {version};
+ }
+
+ const parts = [parsed.major, parsed.minor, parsed.patch];
+
+ return (
+
+ {parts.map((part, index) => (
+
+ {index === highlightIndex ? (
+
+ {part}
+
+ ) : (
+ {part}
+ )}
+ {index < 2 && "."}
+
+ ))}
+
+ );
+}
diff --git a/src/app/[...parts]/_page/catalog/generateCatalogMetadata.ts b/src/app/[...parts]/_page/catalog/generateCatalogMetadata.ts
new file mode 100644
index 00000000..75bff8be
--- /dev/null
+++ b/src/app/[...parts]/_page/catalog/generateCatalogMetadata.ts
@@ -0,0 +1,32 @@
+import { type Metadata } from "next";
+import { getCatalogPackageName } from "^/lib/utils/isCatalogPage";
+
+/**
+ * Generate metadata for catalog pages
+ */
+export function generateCatalogMetadata(specs: string[]): Metadata {
+ const packageName = getCatalogPackageName(specs);
+
+ if (!packageName) {
+ throw new Error("Invalid catalog page specs");
+ }
+
+ return {
+ title: `${packageName} - npm Package Catalog`,
+ description: `Browse and compare different versions of the ${packageName} npm package. View suggested version comparisons including major, minor, and patch updates.`,
+ keywords: [
+ packageName,
+ "npm",
+ "package",
+ "version",
+ "diff",
+ "comparison",
+ "changelog",
+ ],
+ openGraph: {
+ title: `${packageName} - npm Package Catalog`,
+ description: `Browse and compare different versions of the ${packageName} npm package`,
+ type: "website",
+ },
+ };
+}
diff --git a/src/app/[...parts]/page.tsx b/src/app/[...parts]/page.tsx
index e7f113c7..043b4fa0 100644
--- a/src/app/[...parts]/page.tsx
+++ b/src/app/[...parts]/page.tsx
@@ -8,9 +8,12 @@ import destination from "^/lib/destination";
import { parseQuery, type QueryParams } from "^/lib/query";
import { simplePackageSpecToString } from "^/lib/SimplePackageSpec";
import decodeParts from "^/lib/utils/decodeParts";
+import { isCatalogPage } from "^/lib/utils/isCatalogPage";
import specsToDiff from "^/lib/utils/specsToDiff";
import splitParts from "^/lib/utils/splitParts";
import BundlephobiaDiff from "./_page/BundlephobiaDiff";
+import CatalogPage from "./_page/catalog/CatalogPage";
+import { generateCatalogMetadata } from "./_page/catalog/generateCatalogMetadata";
import DiffIntro from "./_page/DiffIntro";
import NpmDiff from "./_page/NpmDiff";
import PackagephobiaDiff from "./_page/PackagephobiaDiff";
@@ -28,6 +31,11 @@ export async function generateMetadata({
const { parts } = await params;
const specs = splitParts(decodeParts(parts));
+ // Check if this is a catalog page
+ if (isCatalogPage(specs)) {
+ return generateCatalogMetadata(specs);
+ }
+
const [a, b] = specs.map((spec) => createSimplePackageSpec(spec));
return {
@@ -44,6 +52,12 @@ const DiffPageInner = async ({
const { diffFiles, ...optionsQuery } = await searchParams;
const specsOrVersions = splitParts(decodeParts(parts));
+
+ // Check if this is a catalog page
+ if (isCatalogPage(specsOrVersions)) {
+ return ;
+ }
+
const { redirect: redirectTarget, canonicalSpecs } =
await destination(specsOrVersions);
diff --git a/src/lib/utils/generateComparisons.test.ts b/src/lib/utils/generateComparisons.test.ts
new file mode 100644
index 00000000..8244492a
--- /dev/null
+++ b/src/lib/utils/generateComparisons.test.ts
@@ -0,0 +1,182 @@
+import semver from "semver";
+import { generateComparisons } from "./generateComparisons";
+
+describe("generateComparisons", () => {
+ it("returns empty array for package with 0 versions", () => {
+ const result = generateComparisons([]);
+ expect(result).toEqual([]);
+ });
+
+ it("returns empty array for package with 1 version", () => {
+ const versions = ["1.0.0"];
+ const result = generateComparisons(versions);
+ expect(result).toEqual([]);
+ });
+
+ it("generates exactly 10 comparisons for a package with many versions", () => {
+ const versions = [
+ "1.0.0",
+ "1.0.1",
+ "1.1.0",
+ "1.1.1",
+ "2.0.0",
+ "2.0.1",
+ "2.1.0",
+ "2.1.1",
+ "3.0.0",
+ "3.0.1",
+ "3.1.0",
+ "3.1.1",
+ "3.1.2",
+ "3.1.3",
+ "3.1.4",
+ "3.1.5",
+ ];
+
+ const result = generateComparisons(versions);
+ expect(result).toHaveLength(10);
+ });
+
+ it("identifies major bumps correctly", () => {
+ const versions = ["1.0.0", "1.9.4", "2.0.0", "2.5.0", "3.0.0", "3.1.0"];
+
+ const result = generateComparisons(versions);
+
+ // Should include major bumps: 3.0.0 vs 2.5.0, 2.0.0 vs 1.9.4
+ const majorBumps = result.filter((c) => c.type === "major");
+ expect(majorBumps.length).toBeGreaterThan(0);
+ expect(majorBumps).toContainEqual({
+ from: "2.5.0",
+ to: "3.0.0",
+ type: "major",
+ });
+ expect(majorBumps).toContainEqual({
+ from: "1.9.4",
+ to: "2.0.0",
+ type: "major",
+ });
+ });
+
+ it("identifies minor bumps correctly", () => {
+ const versions = ["2.0.0", "2.0.12", "2.1.0", "2.1.3", "2.2.0"];
+
+ const result = generateComparisons(versions);
+
+ const minorBumps = result.filter((c) => c.type === "minor");
+ expect(minorBumps.length).toBeGreaterThan(0);
+ expect(minorBumps).toContainEqual({
+ from: "2.1.3",
+ to: "2.2.0",
+ type: "minor",
+ });
+ expect(minorBumps).toContainEqual({
+ from: "2.0.12",
+ to: "2.1.0",
+ type: "minor",
+ });
+ });
+
+ it("identifies patch bumps correctly", () => {
+ const versions = ["2.1.3", "2.1.4", "2.1.5", "2.1.6"];
+
+ const result = generateComparisons(versions);
+
+ const patchBumps = result.filter((c) => c.type === "patch");
+ expect(patchBumps.length).toBeGreaterThan(0);
+ expect(patchBumps).toContainEqual({
+ from: "2.1.5",
+ to: "2.1.6",
+ type: "patch",
+ });
+ });
+
+ it("returns fewer than 10 comparisons for packages with few versions", () => {
+ const versions = ["1.0.0", "1.0.1", "1.0.2"];
+
+ const result = generateComparisons(versions);
+ expect(result.length).toBeLessThanOrEqual(2);
+ });
+
+ it("backfills from majors when not enough minor/patch bumps", () => {
+ const versions = [
+ "1.0.0",
+ "2.0.0",
+ "3.0.0",
+ "4.0.0",
+ "5.0.0",
+ "6.0.0",
+ "7.0.0",
+ "8.0.0",
+ "9.0.0",
+ "10.0.0",
+ "11.0.0",
+ ];
+
+ const result = generateComparisons(versions);
+
+ // Should have 10 major bumps (since there are no minor/patch bumps)
+ expect(result).toHaveLength(10);
+ const majorBumps = result.filter((c) => c.type === "major");
+ expect(majorBumps).toHaveLength(10);
+ });
+
+ it("sorts comparisons by semver version (newest first)", () => {
+ const versions = ["1.0.0", "1.0.1", "2.0.0", "2.0.1"];
+
+ const result = generateComparisons(versions);
+
+ // Should be sorted by semver version (newest first), not by publish date
+ for (let i = 0; i < result.length - 1; i++) {
+ const versionA = result[i].to;
+ const versionB = result[i + 1].to;
+ // versionA should be greater than or equal to versionB (semver compare)
+ expect(
+ semver.gte(versionA, versionB) || versionA === versionB,
+ ).toBe(true);
+ }
+ });
+
+ it("filters out prerelease versions", () => {
+ const versions = [
+ "1.0.0",
+ "1.0.1",
+ "2.0.0-beta.1",
+ "2.0.0-rc.1",
+ "2.0.0",
+ "2.0.1",
+ ];
+
+ const result = generateComparisons(versions);
+
+ // Should not include any prerelease versions in comparisons
+ expect(result.length).toBeGreaterThan(0);
+ result.forEach((comparison) => {
+ expect(comparison.from).not.toMatch(/-/);
+ expect(comparison.to).not.toMatch(/-/);
+ });
+ });
+
+ it("does not create patch comparisons for versions with same major.minor.patch but different prerelease", () => {
+ const versions = [
+ "19.3.0-canary-f93b9fd4-20251217",
+ "19.3.0-canary-f6a48828-20251019",
+ "19.3.0-canary-fb2177c1-20251114",
+ "19.2.4",
+ "19.2.3",
+ ];
+
+ const result = generateComparisons(versions);
+
+ // Should not include any patch comparisons between 19.3.0-canary versions
+ // since they all have the same major.minor.patch
+ const patchBumps = result.filter((c) => c.type === "patch");
+ const invalidPatches = patchBumps.filter(
+ (c) =>
+ (c.from.startsWith("19.3.0-canary") &&
+ c.to.startsWith("19.3.0-canary")) ||
+ (c.from.includes("-canary") && c.to.includes("-canary")),
+ );
+
+ expect(invalidPatches).toHaveLength(0);
+ });
+});
diff --git a/src/lib/utils/generateComparisons.ts b/src/lib/utils/generateComparisons.ts
new file mode 100644
index 00000000..ede5a80f
--- /dev/null
+++ b/src/lib/utils/generateComparisons.ts
@@ -0,0 +1,190 @@
+import semver from "semver";
+
+export interface Comparison {
+ from: string;
+ to: string;
+ type: "major" | "minor" | "patch";
+}
+
+/**
+ * Generates exactly 10 comparison links for a package, following these rules:
+ * - 3 Major bumps: First release of major vs last release of previous major
+ * - 3 Minor bumps: First release of minor vs last release of previous minor
+ * - 4 Patch bumps: Most recent 4 patches sequentially
+ * - If any category has fewer than target, backfill from: Majors > Minors > Patches
+ * - Total must be 10 unless package has < 11 versions
+ */
+export function generateComparisons(versions: string[]): Comparison[] {
+ // Filter out invalid versions and prereleases
+ const validVersions = versions.filter(
+ (v) => semver.valid(v) && !semver.prerelease(v),
+ );
+
+ // Sort versions by semver in descending order (newest first)
+ const sortedVersions = validVersions.sort((a, b) => semver.rcompare(a, b));
+
+ if (sortedVersions.length < 2) {
+ return [];
+ }
+
+ // Find all bumps in a single pass
+ const bumps = findAllBumps(sortedVersions);
+
+ // Target counts: 3 majors, 3 minors, 4 patches
+ let selectedMajors = bumps.major.slice(0, 3);
+ let selectedMinors = bumps.minor.slice(0, 3);
+ let selectedPatches = bumps.patch.slice(0, 4);
+
+ // Backfill logic: Majors > Minors > Patches
+ const totalSelected =
+ selectedMajors.length + selectedMinors.length + selectedPatches.length;
+ const needed = Math.min(10, sortedVersions.length - 1) - totalSelected;
+
+ if (needed > 0) {
+ // Try to backfill from majors first
+ const extraMajors = bumps.major.slice(3, 3 + needed);
+ selectedMajors = [...selectedMajors, ...extraMajors];
+
+ const stillNeeded = needed - extraMajors.length;
+ if (stillNeeded > 0) {
+ // Then from minors
+ const extraMinors = bumps.minor.slice(3, 3 + stillNeeded);
+ selectedMinors = [...selectedMinors, ...extraMinors];
+
+ const finalNeeded = stillNeeded - extraMinors.length;
+ if (finalNeeded > 0) {
+ // Finally from patches
+ const extraPatches = bumps.patch.slice(4, 4 + finalNeeded);
+ selectedPatches = [...selectedPatches, ...extraPatches];
+ }
+ }
+ }
+
+ // Combine all comparisons
+ const allComparisons = [
+ ...selectedMajors,
+ ...selectedMinors,
+ ...selectedPatches,
+ ];
+
+ // Sort by from version (newest first)
+ return allComparisons.sort((a, b) => semver.rcompare(a.from, b.from));
+}
+
+/**
+ * Find all bumps (major, minor, patch) in a single pass through versions
+ */
+function findAllBumps(sortedVersions: string[]): {
+ major: Comparison[];
+ minor: Comparison[];
+ patch: Comparison[];
+} {
+ const majorBumps: Comparison[] = [];
+ const minorBumps: Comparison[] = [];
+ const patchBumps: Comparison[] = [];
+
+ // Group versions by major and minor
+ const majorGroups = new Map();
+ const minorGroups = new Map();
+
+ for (const version of sortedVersions) {
+ const major = semver.major(version);
+ const minor = semver.minor(version);
+ const minorKey = `${major}.${minor}`;
+
+ if (!majorGroups.has(major)) {
+ majorGroups.set(major, []);
+ }
+ majorGroups.get(major)!.push(version);
+
+ if (!minorGroups.has(minorKey)) {
+ minorGroups.set(minorKey, []);
+ }
+ minorGroups.get(minorKey)!.push(version);
+ }
+
+ // Find major bumps: first release of major vs last release of previous major
+ const majors = Array.from(majorGroups.keys()).sort((a, b) => b - a);
+ for (let i = 0; i < majors.length - 1; i++) {
+ const currentMajor = majors[i];
+ const previousMajor = majors[i + 1];
+
+ const currentVersions = majorGroups.get(currentMajor)!;
+ const previousVersions = majorGroups.get(previousMajor)!;
+
+ // First of current (last in sorted array) vs last of previous (first in sorted array)
+ const firstOfCurrent = currentVersions[currentVersions.length - 1];
+ const lastOfPrevious = previousVersions[0];
+
+ majorBumps.push({
+ from: lastOfPrevious,
+ to: firstOfCurrent,
+ type: "major",
+ });
+ }
+
+ // Find minor bumps: first release of minor vs last release of previous minor (within same major)
+ const minorKeys = Array.from(minorGroups.keys()).sort((a, b) => {
+ const [aMajor, aMinor] = a.split(".").map(Number);
+ const [bMajor, bMinor] = b.split(".").map(Number);
+ if (aMajor !== bMajor) return bMajor - aMajor;
+ return bMinor - aMinor;
+ });
+
+ for (let i = 0; i < minorKeys.length - 1; i++) {
+ const currentKey = minorKeys[i];
+ const nextKey = minorKeys[i + 1];
+
+ const [currentMajor, currentMinor] = currentKey.split(".").map(Number);
+ const [nextMajor, nextMinor] = nextKey.split(".").map(Number);
+
+ // Only compare if same major and sequential minors
+ if (currentMajor === nextMajor && currentMinor === nextMinor + 1) {
+ const currentVersions = minorGroups.get(currentKey)!;
+ const nextVersions = minorGroups.get(nextKey)!;
+
+ // First of current (last in sorted array) vs last of next (first in sorted array)
+ const firstOfCurrent = currentVersions[currentVersions.length - 1];
+ const lastOfPrevious = nextVersions[0];
+
+ minorBumps.push({
+ from: lastOfPrevious,
+ to: firstOfCurrent,
+ type: "minor",
+ });
+ }
+ }
+
+ // Find patch bumps: most recent sequential patches
+ for (let i = 0; i < sortedVersions.length - 1; i++) {
+ const to = sortedVersions[i];
+ const from = sortedVersions[i + 1];
+
+ const toMajor = semver.major(to);
+ const toMinor = semver.minor(to);
+ const toPatch = semver.patch(to);
+
+ const fromMajor = semver.major(from);
+ const fromMinor = semver.minor(from);
+ const fromPatch = semver.patch(from);
+
+ // Check if it's a sequential patch bump
+ if (
+ toMajor === fromMajor &&
+ toMinor === fromMinor &&
+ toPatch === fromPatch + 1
+ ) {
+ patchBumps.push({
+ from,
+ to,
+ type: "patch",
+ });
+ }
+ }
+
+ return {
+ major: majorBumps,
+ minor: minorBumps,
+ patch: patchBumps,
+ };
+}
diff --git a/src/lib/utils/isCatalogPage.ts b/src/lib/utils/isCatalogPage.ts
new file mode 100644
index 00000000..c1c0fc15
--- /dev/null
+++ b/src/lib/utils/isCatalogPage.ts
@@ -0,0 +1,24 @@
+import npa from "npm-package-arg";
+
+/**
+ * Check if the given specs represent a catalog page request
+ * (single package name without version specification)
+ */
+export function isCatalogPage(specs: string[]): boolean {
+ if (specs.length !== 1) {
+ return false;
+ }
+ const parsed = npa(specs[0]);
+ return parsed.rawSpec === "*" && Boolean(parsed.name);
+}
+
+/**
+ * Get the package name from catalog page specs
+ * Returns null if not a valid catalog page spec
+ */
+export function getCatalogPackageName(specs: string[]): string | null {
+ if (!isCatalogPage(specs)) {
+ return null;
+ }
+ return npa(specs[0]).name;
+}