From 8d215beec829182c81c6bd5a29d922c7852e6439 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 17 Sep 2026 09:20:44 +0100 Subject: [PATCH] feat: added homepage releases --- .../components/islands/PackageVersions.tsx | 206 ++++++++++++++++++ src/src/pages/index.astro | 41 ++++ 2 files changed, 247 insertions(+) create mode 100644 src/src/components/islands/PackageVersions.tsx diff --git a/src/src/components/islands/PackageVersions.tsx b/src/src/components/islands/PackageVersions.tsx new file mode 100644 index 0000000..27c0a07 --- /dev/null +++ b/src/src/components/islands/PackageVersions.tsx @@ -0,0 +1,206 @@ +import { useState } from 'preact/hooks'; +import { compare, valid } from 'semver'; + +export interface PackageRow { + projectId: string; + projectName: string; + packageId: string; + latestStable: string | null; + latestPrerelease: string | null; + totalDownloads: number | null; + nugetUrl: string; + projectUrl: string; +} + +type SortKey = 'package' | 'project' | 'stable' | 'prerelease' | 'downloads'; +type SortDir = 'asc' | 'desc'; + +interface Props { + packages: PackageRow[]; +} + +function normalizeVersion(version: string | null): string | null { + if (!version) { + return null; + } + return version.replace(/^v/i, '').replace(/reprelease/gi, 'prerelease'); +} + +function compareVersions(a: string | null, b: string | null): number { + const va = normalizeVersion(a); + const vb = normalizeVersion(b); + if (va && vb) { + const pa = valid(va); + const pb = valid(vb); + if (pa && pb) { + return compare(pa, pb); + } + } + return (va ?? '').localeCompare(vb ?? ''); +} + +const COLUMNS: { key: SortKey; label: string; title: string }[] = [ + { key: 'package', label: 'Package', title: 'Sort by package name' }, + { key: 'project', label: 'Project', title: 'Sort by project name' }, + { key: 'stable', label: 'Stable', title: 'Sort by latest stable version' }, + { key: 'prerelease', label: 'Prerelease', title: 'Sort by latest prerelease version' }, + { key: 'downloads', label: 'Downloads', title: 'Sort by total downloads' }, +]; + +export default function PackageVersions({ packages }: Props) { + const [search, setSearch] = useState(''); + const [sortKey, setSortKey] = useState('project'); + const [sortDir, setSortDir] = useState('asc'); + + const query = search.trim().toLowerCase(); + + const rows = packages + .filter( + (row) => + query === '' || + row.packageId.toLowerCase().includes(query) || + row.projectName.toLowerCase().includes(query) || + (row.latestStable?.toLowerCase().includes(query) ?? false) || + (row.latestPrerelease?.toLowerCase().includes(query) ?? false), + ) + .toSorted((a, b) => { + const dir = sortDir === 'asc' ? 1 : -1; + switch (sortKey) { + case 'package': + return a.packageId.localeCompare(b.packageId) * dir; + case 'project': + return ( + (a.projectName.localeCompare(b.projectName) || a.packageId.localeCompare(b.packageId)) * + dir + ); + case 'stable': { + const aNull = a.latestStable ? 0 : 1; + const bNull = b.latestStable ? 0 : 1; + return (aNull - bNull || compareVersions(a.latestStable, b.latestStable)) * dir; + } + case 'prerelease': { + const aNull = a.latestPrerelease ? 0 : 1; + const bNull = b.latestPrerelease ? 0 : 1; + return (aNull - bNull || compareVersions(a.latestPrerelease, b.latestPrerelease)) * dir; + } + case 'downloads': + return ((a.totalDownloads ?? -1) - (b.totalDownloads ?? -1)) * dir; + } + }); + + function toggleSort(key: SortKey) { + if (sortKey === key) { + setSortDir((current) => (current === 'asc' ? 'desc' : 'asc')); + return; + } + setSortKey(key); + setSortDir(key === 'downloads' ? 'desc' : 'asc'); + } + + return ( +
+
+ +

+ {rows.length} package{rows.length === 1 ? '' : 's'} +

+
+ +
+ + + + {COLUMNS.map((column) => ( + + ))} + + + + {rows.map((row) => ( + + + + + + + + ))} + +
+ +
+ + {row.packageId} + + + + {row.projectName} + + + {row.latestStable ?? } + + {row.latestPrerelease ?? } + + {row.totalDownloads == null ? '—' : row.totalDownloads.toLocaleString('en-US')} +
+
+ + {rows.length === 0 && ( +

+ No packages match your search. +

+ )} +
+ ); +} diff --git a/src/src/pages/index.astro b/src/src/pages/index.astro index b8aa0e2..c9fddc1 100644 --- a/src/src/pages/index.astro +++ b/src/src/pages/index.astro @@ -1,7 +1,10 @@ --- import SiteLayout from '~/layouts/SiteLayout.astro'; import ProjectCard from '~/components/ProjectCard.astro'; +import PackageVersions, { type PackageRow } from '~/components/islands/PackageVersions.tsx'; import { loadProjects } from '~/lib/manifest/load'; +import { projectReleaseSummary } from '~/lib/releases/transform'; +import { getReleaseIndex } from '~/lib/releases/runtime'; import { SITE } from '~/lib/site'; import { absoluteUrl, withBase } from '~/lib/urls'; @@ -9,6 +12,23 @@ const projects = loadProjects(); const featured = projects.filter((project) => project.featured); const active = projects.filter((project) => project.status !== 'archived'); +const releaseIndex = getReleaseIndex(); +const packageRows: PackageRow[] = projects + .filter((project) => project.status !== 'archived') + .flatMap((project) => { + const summary = projectReleaseSummary(project, releaseIndex.data, releaseIndex.source); + return summary.packages.map((pkg) => ({ + projectId: summary.projectId, + projectName: summary.projectName, + packageId: pkg.packageId, + latestStable: pkg.latestStable, + latestPrerelease: pkg.latestPrerelease, + totalDownloads: pkg.totalDownloads, + nugetUrl: pkg.nugetUrl, + projectUrl: withBase(`/projects/${summary.projectId}/`), + })); + }); + const organizationStructuredData = { '@context': 'https://schema.org', '@type': 'Organization', @@ -130,6 +150,27 @@ const organizationStructuredData = { +
+
+
+

Package status

+

+ The latest stable and prerelease version of every NuGet package at a + glance. Search, or click a column to sort. +

+
+ +
+
+ +
+

+ Release data retrieved on {releaseIndex.data.retrievedAt.slice(0, 10)} ({releaseIndex.source} source). +

+
+

Start building today