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