-
Notifications
You must be signed in to change notification settings - Fork 0
Add package catalog at /[name] route with curated version comparisons #1040
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
136fed4
Initial plan for /pkg/[name] catalog page implementation
Copilot 507fdff
Add comparison generation logic with tests
Copilot 8779917
Add /pkg/[name] catalog page with UI components
Copilot 43b5e64
Manual verification complete - catalog pages working perfectly
Copilot 2b7e2d4
Complete implementation of /pkg/[name] catalog page
Copilot 68840a0
Fix: Move catalog to /[name] route and fix prerelease version compari…
Copilot ba5c923
Refactor catalog components and optimize comparison generation
Copilot d6114f9
Fix type safety in PackageMeta external links
Copilot 017022c
Fix comparison logic and improve catalog page
Copilot 3e0326a
Add validation for pack.time entries
Copilot c27f223
Refactor catalog code per review feedback
Copilot 76f53d4
Refactor CatalogPage and metadata generation to use specs instead of …
oBusk c373032
Refactor ComparisonList to use specs for diff path generation and imp…
oBusk dabf32a
Refactor PackageMeta to simplify conditional rendering and improve ma…
oBusk 5861ec6
Fix bot review issues: remove unused parameter and reduce duplication
Copilot 8556f1d
Fix UI issues: reset next-env.d.ts and fix line breaks in version dis…
Copilot 6bc3392
Make catalog page accomodate longer package names
oBusk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div className="mx-auto w-full max-w-7xl py-8"> | ||
| <div className="grid grid-cols-1 gap-8 lg:grid-cols-[2fr,3fr]"> | ||
| <div className="flex flex-col"> | ||
| <PackageMeta packument={pack} /> | ||
| </div> | ||
| <div className="flex flex-col"> | ||
| <ComparisonList | ||
| packageName={packageName} | ||
| comparisons={comparisons} | ||
| /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function CatalogPageFallback() { | ||
| return ( | ||
| <div className="mx-auto w-full max-w-7xl py-8"> | ||
| <div className="grid grid-cols-1 gap-8 lg:grid-cols-[2fr,3fr]"> | ||
| <div className="flex flex-col"> | ||
| <Skeleton className="h-96 w-full rounded-md" /> | ||
| </div> | ||
| <div className="flex flex-col"> | ||
| <Skeleton className="h-96 w-full rounded-md" /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default function CatalogPage(props: CatalogPageProps) { | ||
| return ( | ||
| <Suspense fallback={<CatalogPageFallback />}> | ||
| <CatalogPageInner {...props} /> | ||
| </Suspense> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <BorderBox> | ||
| <p className="text-sm text-muted-foreground"> | ||
| No comparisons available for this package. | ||
| </p> | ||
| </BorderBox> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <BorderBox className="flex flex-col gap-4"> | ||
| <Heading h={2} className="text-2xl"> | ||
| Suggested Diffs | ||
| </Heading> | ||
|
|
||
| <Stack direction="v" gap={2}> | ||
| {comparisons.map((comparison) => { | ||
| const highlightIndex = getHighlightIndex(comparison.type); | ||
| const fromSpec = `${packageName}@${comparison.from}`; | ||
| const toSpec = `${packageName}@${comparison.to}`; | ||
| const diffPath = specsToDiff([fromSpec, toSpec]); | ||
|
|
||
| return ( | ||
| <Link | ||
| key={`${comparison.from}-${comparison.to}`} | ||
| href={`/${diffPath}`} | ||
| className="flex items-center justify-between rounded-md border border-input p-3 transition-colors hover:border-blue-500/50 hover:bg-blue-500/5" | ||
| prefetch={false} | ||
| aria-label={`Compare ${fromSpec}...${toSpec} (${comparison.type} version change)`} | ||
| > | ||
| <span className="font-mono"> | ||
| <PackageNamePrefix packageName={packageName} /> | ||
| <VersionWithHighlight | ||
| version={comparison.from} | ||
| highlightIndex={highlightIndex} | ||
| /> | ||
| <span className="text-muted-foreground"> | ||
| ... | ||
| </span> | ||
| {showSecondPackageName ? ( | ||
| <PackageNamePrefix | ||
| packageName={packageName} | ||
| /> | ||
| ) : null} | ||
| <VersionWithHighlight | ||
| version={comparison.to} | ||
| highlightIndex={highlightIndex} | ||
| /> | ||
| </span> | ||
| <span className="text-xs capitalize text-muted-foreground"> | ||
| {comparison.type} | ||
| </span> | ||
| </Link> | ||
| ); | ||
| })} | ||
| </Stack> | ||
| </BorderBox> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <BorderBox className="flex h-fit flex-col gap-4"> | ||
| <Stack direction="v" gap={2}> | ||
| <Heading h={2} className="text-2xl"> | ||
| {packument.name} | ||
| </Heading> | ||
| <div className="text-sm text-muted-foreground"> | ||
| <span className="font-mono">{latestVersion}</span> | ||
| {latestTime ? ( | ||
| <> | ||
| <span className="mx-2">•</span> | ||
| <ClientDate time={latestTime} /> | ||
| </> | ||
| ) : null} | ||
| </div> | ||
| </Stack> | ||
|
|
||
| {latestManifest.description ? ( | ||
| <p className="text-sm text-muted-foreground"> | ||
| {latestManifest.description} | ||
| </p> | ||
| ) : null} | ||
|
|
||
| <Stack direction="v" gap={2}> | ||
| {latestManifest.license ? ( | ||
| <div className="text-sm"> | ||
| <span className="text-muted-foreground">License: </span> | ||
| <span>{latestManifest.license}</span> | ||
| </div> | ||
| ) : null} | ||
|
|
||
| {latestManifest.author ? ( | ||
| <div className="text-sm"> | ||
| <span className="text-muted-foreground">Author: </span> | ||
| <span> | ||
| {typeof latestManifest.author === "string" | ||
| ? latestManifest.author | ||
| : latestManifest.author.name} | ||
| </span> | ||
| </div> | ||
| ) : null} | ||
|
|
||
| {totalVersions > 0 && ( | ||
| <div className="text-sm"> | ||
| <span className="text-muted-foreground"> | ||
| Versions:{" "} | ||
| </span> | ||
| <span>{totalVersions}</span> | ||
| </div> | ||
| )} | ||
|
|
||
| {latestManifest.maintainers && | ||
| latestManifest.maintainers.length > 0 ? ( | ||
| <div className="text-sm"> | ||
| <span className="text-muted-foreground"> | ||
| Maintainers:{" "} | ||
| </span> | ||
| <span>{latestManifest.maintainers.length}</span> | ||
| </div> | ||
| ) : null} | ||
| </Stack> | ||
|
|
||
| {keywords && keywords.length > 0 ? ( | ||
| <div className="flex flex-wrap gap-2"> | ||
| {keywords.slice(0, 10).map((keyword) => ( | ||
| <span | ||
| key={keyword} | ||
| className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground" | ||
| > | ||
| {keyword} | ||
| </span> | ||
| ))} | ||
| </div> | ||
| ) : null} | ||
|
|
||
| <Stack direction="v" gap={2}> | ||
| <ExternalLink | ||
| href={npmUrl} | ||
| className="text-sm text-blue-600 hover:underline dark:text-blue-400" | ||
| > | ||
| View on npm → | ||
| </ExternalLink> | ||
|
|
||
| {repositoryUrl ? ( | ||
| <ExternalLink | ||
| href={repositoryUrl} | ||
| className="text-sm text-blue-600 hover:underline dark:text-blue-400" | ||
| > | ||
| Repository → | ||
| </ExternalLink> | ||
| ) : null} | ||
|
|
||
| {homepageUrl && homepageUrl !== repositoryUrl ? ( | ||
| <ExternalLink | ||
| href={homepageUrl} | ||
| className="text-sm text-blue-600 hover:underline dark:text-blue-400" | ||
| > | ||
| Homepage → | ||
| </ExternalLink> | ||
| ) : null} | ||
| </Stack> | ||
| </BorderBox> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <span className="text-muted-foreground">{packageName}@</span>; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <span className="font-mono">{version}</span>; | ||
| } | ||
|
|
||
| const parts = [parsed.major, parsed.minor, parsed.patch]; | ||
|
|
||
| return ( | ||
| <span className="font-mono"> | ||
| {parts.map((part, index) => ( | ||
| <span key={index}> | ||
| {index === highlightIndex ? ( | ||
| <span className="rounded bg-blue-500/20 px-1 font-bold"> | ||
| {part} | ||
| </span> | ||
| ) : ( | ||
| <span>{part}</span> | ||
| )} | ||
| {index < 2 && "."} | ||
| </span> | ||
| ))} | ||
| </span> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.