diff --git a/.env.example b/.env.example index a5b63ba..b29bc0d 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,14 @@ FORWARDED_TRUSTED_NETWORKS=172.28.0.0/16 # Empty = unauthenticated GitHub API calls (60 requests/hour limit). GITHUB_PAT= +# --------------------------------------------------------------------------- +# Server rendering +# --------------------------------------------------------------------------- + +# Optional. Gives API calls from the render server their own rate limit. +# Empty = they count against the visitor's limit. Generate: openssl rand -hex 32 +RENDER_SERVER_KEY= + # --------------------------------------------------------------------------- # Monitoring # --------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 7ddfb7c..254c81b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: - AdminSettings__ViewerPassword=${VIEWER_PASSWORD:-} - ForwardedHeaders__TrustedNetworks=${FORWARDED_TRUSTED_NETWORKS:-172.28.0.0/16} - GitHubSettings__PersonalAccessToken=${GITHUB_PAT:-} + - RateLimiting__RenderServerKey=${RENDER_SERVER_KEY:-} cap_drop: - ALL security_opt: @@ -72,6 +73,7 @@ services: - NODE_ENV=production - PORT=4000 - SSR_INTERNAL_ORIGIN=http://frontend + - RENDER_SERVER_KEY=${RENDER_SERVER_KEY:-} mem_limit: 512m cpus: 1.0 pids_limit: 256 diff --git a/src/backend/PkgDiff.API/Controllers/PackageController.cs b/src/backend/PkgDiff.API/Controllers/PackageController.cs index f4fdb0d..5592135 100644 --- a/src/backend/PkgDiff.API/Controllers/PackageController.cs +++ b/src/backend/PkgDiff.API/Controllers/PackageController.cs @@ -15,6 +15,7 @@ namespace PkgDiff.API.Controllers; public class PackageController : ControllerBase { private const int MaxConcurrentAnalyses = 10; + private const int MaxVersionLimit = 100; private const int MaxConcurrentAnalysesPerAddress = 2; private static readonly System.Collections.Concurrent.ConcurrentDictionary AnalysesPerAddress = new(); @@ -198,7 +199,11 @@ private async Task MayQueueScrapeAsync() [HttpGet("{registry}/{packageId}")] [ValidatePackageRoute] - public async Task> GetPackageMetadata(string registry, string packageId) + public async Task> GetPackageMetadata( + string registry, + string packageId, + [FromQuery] int? versionLimit = null, + [FromQuery] string? includeVersion = null) { try { @@ -222,6 +227,16 @@ public async Task> GetPackageMetadata(string regis } } + if (versionLimit.HasValue) + { + var limit = Math.Clamp(versionLimit.Value, 1, MaxVersionLimit); + metadata.Versions = metadata.Versions + .Where((version, index) => index < limit + || version.Version == metadata.LatestVersion + || string.Equals(version.Version, includeVersion, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + return Ok(metadata); } catch (ArgumentException ex) @@ -379,6 +394,7 @@ public async Task ExportDiffToPdf([FromBody] AnalyzeRequest reque ProjectUrl = metadata.ProjectUrl, GitHubRepositoryUrl = metadata.GitHubRepositoryUrl, Versions = metadata.Versions, + TotalVersions = metadata.Versions.Count, Trust = trust }; } diff --git a/src/backend/PkgDiff.API/Controllers/SitemapController.cs b/src/backend/PkgDiff.API/Controllers/SitemapController.cs index 51078a1..568a8bb 100644 --- a/src/backend/PkgDiff.API/Controllers/SitemapController.cs +++ b/src/backend/PkgDiff.API/Controllers/SitemapController.cs @@ -1,5 +1,8 @@ +using System.Security; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; using PkgDiff.Application.Interfaces; +using PkgDiff.Shared.Constants; namespace PkgDiff.API.Controllers; @@ -7,12 +10,32 @@ namespace PkgDiff.API.Controllers; [Route("api")] public class SitemapController : ControllerBase { + private const string BaseUrl = "https://pkgdiff.dev"; + private const string CacheKey = "sitemap-xml"; + + private static readonly string[] StaticPaths = + { + "/", + "/packages", + "/audit", + "/about", + "/contributors", + "/terms", + "/privacy", + "/cookies", + }; + private readonly IAdminPackageService _adminService; + private readonly IMemoryCache _memory; private readonly ILogger _logger; - public SitemapController(IAdminPackageService adminService, ILogger logger) + public SitemapController( + IAdminPackageService adminService, + IMemoryCache memory, + ILogger logger) { _adminService = adminService; + _memory = memory; _logger = logger; } @@ -22,76 +45,57 @@ public async Task GetDynamicSitemap() { try { - var packages = await _adminService.GetPackagesForSitemapAsync(); - - var xml = new System.Text.StringBuilder(); - xml.AppendLine(""); - xml.AppendLine(""); - - xml.AppendLine(" "); - xml.AppendLine(" https://pkgdiff.dev/"); - xml.AppendLine(" daily"); - xml.AppendLine(" 1.0"); - xml.AppendLine($" {DateTime.UtcNow:yyyy-MM-dd}"); - xml.AppendLine(" "); - - xml.AppendLine(" "); - xml.AppendLine(" https://pkgdiff.dev/packages"); - xml.AppendLine(" daily"); - xml.AppendLine(" 0.9"); - xml.AppendLine($" {DateTime.UtcNow:yyyy-MM-dd}"); - xml.AppendLine(" "); + if (!_memory.TryGetValue(CacheKey, out string? sitemap) || sitemap is null) + { + sitemap = await BuildSitemapAsync(); + _memory.Set(CacheKey, sitemap, TimeSpan.FromHours(1)); + } - xml.AppendLine(" "); - xml.AppendLine(" https://pkgdiff.dev/audit"); - xml.AppendLine(" monthly"); - xml.AppendLine(" 0.8"); - xml.AppendLine(" "); + return Content(sitemap, "application/xml"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating dynamic sitemap"); + return StatusCode(500, new { error = "An error occurred while generating sitemap" }); + } + } + private async Task BuildSitemapAsync() + { + var packages = await _adminService.GetPackagesForSitemapAsync(); - xml.AppendLine(" "); - xml.AppendLine(" https://pkgdiff.dev/terms"); - xml.AppendLine(" yearly"); - xml.AppendLine(" 0.3"); - xml.AppendLine($" {DateTime.UtcNow:yyyy-MM-dd}"); - xml.AppendLine(" "); + var xml = new System.Text.StringBuilder(); + xml.AppendLine(""); + xml.AppendLine(""); - xml.AppendLine(" "); - xml.AppendLine(" https://pkgdiff.dev/privacy"); - xml.AppendLine(" yearly"); - xml.AppendLine(" 0.3"); - xml.AppendLine($" {DateTime.UtcNow:yyyy-MM-dd}"); - xml.AppendLine(" "); + foreach (var path in StaticPaths) + { + AppendUrl(xml, path, null); + } - xml.AppendLine(" "); - xml.AppendLine(" https://pkgdiff.dev/cookies"); - xml.AppendLine(" yearly"); - xml.AppendLine(" 0.3"); - xml.AppendLine($" {DateTime.UtcNow:yyyy-MM-dd}"); - xml.AppendLine(" "); + foreach (var registry in RegistryNames.All) + { + AppendUrl(xml, $"/packages?registry={registry}", null); + } - foreach (var package in packages) - { - var encodedPackageId = Uri.EscapeDataString(package.PackageId); - xml.AppendLine(" "); - xml.AppendLine($" https://pkgdiff.dev/package/{package.Registry}/{encodedPackageId}"); - xml.AppendLine($" {package.ChangeFreq}"); - xml.AppendLine($" {package.Priority:0.0}"); - if (package.LastModified.HasValue) - { - xml.AppendLine($" {package.LastModified.Value:yyyy-MM-dd}"); - } - xml.AppendLine(" "); - } + foreach (var package in packages) + { + AppendUrl(xml, $"/package/{package.Registry}/{package.PackageId}", package.LastModified); + } - xml.AppendLine(""); + xml.AppendLine(""); + return xml.ToString(); + } - return Content(xml.ToString(), "application/xml"); - } - catch (Exception ex) + // Must match the page's canonical URL, so the path is written as is and only escaped for XML + private static void AppendUrl(System.Text.StringBuilder xml, string path, DateTime? lastModified) + { + xml.AppendLine(" "); + xml.AppendLine($" {SecurityElement.Escape(BaseUrl + path)}"); + if (lastModified.HasValue) { - _logger.LogError(ex, "Error generating dynamic sitemap"); - return StatusCode(500, new { error = "An error occurred while generating sitemap" }); + xml.AppendLine($" {lastModified.Value:yyyy-MM-dd}"); } + xml.AppendLine(" "); } } diff --git a/src/backend/PkgDiff.API/Middleware/RateLimitingMiddleware.cs b/src/backend/PkgDiff.API/Middleware/RateLimitingMiddleware.cs index c733a95..720ec3a 100644 --- a/src/backend/PkgDiff.API/Middleware/RateLimitingMiddleware.cs +++ b/src/backend/PkgDiff.API/Middleware/RateLimitingMiddleware.cs @@ -3,14 +3,19 @@ using PkgDiff.API.Options; using PkgDiff.API.Services; using System.Net; +using System.Security.Cryptography; +using System.Text; namespace PkgDiff.API.Middleware; public class RateLimitingMiddleware { + private const string RenderServerHeader = "X-Render-Server-Key"; + private readonly RequestDelegate _next; private readonly IRateLimitCounterStore _counters; private readonly InboundRateLimitOptions _options; + private readonly byte[]? _renderServerKeyHash; public RateLimitingMiddleware( RequestDelegate next, @@ -20,13 +25,25 @@ public RateLimitingMiddleware( _next = next; _counters = counters; _options = options.Value; + _renderServerKeyHash = string.IsNullOrEmpty(_options.RenderServerKey) + ? null + : SHA256.HashData(Encoding.UTF8.GetBytes(_options.RenderServerKey)); } public async Task InvokeAsync(HttpContext context) { - var ip = context.GetClientIpAddress(); var path = context.Request.Path; + // Served from a short cache, so a crawler polling it costs next to nothing + if (path.StartsWithSegments("/api/sitemap", StringComparison.OrdinalIgnoreCase)) + { + await _next(context); + return; + } + + var ip = context.GetClientIpAddress(); + var isRenderServer = IsRenderServer(context); + // Runs before authentication: only callers with credentials get the admin budget if (IsAdminPath(path) && HasCredentials(context)) { @@ -36,7 +53,9 @@ public async Task InvokeAsync(HttpContext context) return; } } - else if (await IsOverLimitAsync("rl", ip, _options.General)) + else if (isRenderServer + ? await IsOverLimitAsync("rl:render", ip, _options.RenderServer) + : await IsOverLimitAsync("rl", ip, _options.General)) { await ReturnRateLimitError(context, "Too many requests. Please slow down."); return; @@ -61,6 +80,16 @@ public async Task InvokeAsync(HttpContext context) await _next(context); } + private bool IsRenderServer(HttpContext context) + { + if (_renderServerKeyHash is null) + return false; + + var sent = context.Request.Headers[RenderServerHeader].ToString(); + return sent.Length > 0 + && CryptographicOperations.FixedTimeEquals(SHA256.HashData(Encoding.UTF8.GetBytes(sent)), _renderServerKeyHash); + } + private static bool IsAdminPath(PathString path) => path.StartsWithSegments("/api/admin", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/hangfire", StringComparison.OrdinalIgnoreCase); diff --git a/src/backend/PkgDiff.API/Options/InboundRateLimitOptions.cs b/src/backend/PkgDiff.API/Options/InboundRateLimitOptions.cs index f8899a6..7edf045 100644 --- a/src/backend/PkgDiff.API/Options/InboundRateLimitOptions.cs +++ b/src/backend/PkgDiff.API/Options/InboundRateLimitOptions.cs @@ -15,8 +15,15 @@ public sealed class InboundRateLimitOptions public RateLimitTier AuditStart { get; set; } = new() { PerMinute = 3, PerFiveMinutes = 6 }; + // One rendered page makes several API calls, so a crawler would otherwise run out of the visitor budget + public RateLimitTier RenderServer { get; set; } = new() { PerMinute = 600, PerFiveMinutes = 2000 }; + + // Empty: render server calls count as ordinary visitor calls + public string RenderServerKey { get; set; } = string.Empty; + public bool IsValid() => - General.IsValid() && PdfExport.IsValid() && Admin.IsValid() && Login.IsValid() && AuditStart.IsValid(); + General.IsValid() && PdfExport.IsValid() && Admin.IsValid() && Login.IsValid() && AuditStart.IsValid() + && RenderServer.IsValid(); } public sealed class RateLimitTier diff --git a/src/backend/PkgDiff.API/appsettings.json b/src/backend/PkgDiff.API/appsettings.json index 5751511..f72f4b6 100644 --- a/src/backend/PkgDiff.API/appsettings.json +++ b/src/backend/PkgDiff.API/appsettings.json @@ -45,6 +45,10 @@ "AuditStart": { "PerMinute": 3, "PerFiveMinutes": 6 + }, + "RenderServer": { + "PerMinute": 600, + "PerFiveMinutes": 2000 } }, "RateLimitSettings": { diff --git a/src/backend/PkgDiff.Shared/DTOs/AnalysisModels.cs b/src/backend/PkgDiff.Shared/DTOs/AnalysisModels.cs index fc14471..c9f2c6e 100644 --- a/src/backend/PkgDiff.Shared/DTOs/AnalysisModels.cs +++ b/src/backend/PkgDiff.Shared/DTOs/AnalysisModels.cs @@ -69,6 +69,7 @@ public class PackageMetadata public string? GitHubRepositoryUrl { get; set; } public string? SourceDirectory { get; set; } public List Versions { get; set; } = new(); + public int TotalVersions { get; set; } public PackageTrust? Trust { get; set; } } diff --git a/src/frontend/public/llms.txt b/src/frontend/public/llms.txt index 680e167..bfd9d5a 100644 --- a/src/frontend/public/llms.txt +++ b/src/frontend/public/llms.txt @@ -1,107 +1,39 @@ # pkgdiff -> Compare and analyze package versions across NuGet, npm, PyPI and Pub registries. Track breaking changes, view detailed release notes, and understand version differences with our free online diff tool. +> Shows every release note between two versions of a package across NuGet, npm, PyPI, Maven, pub.dev and Packagist in one categorized timeline, with a security picture of the upgrade. -## Overview +pkgdiff helps developers decide whether a dependency upgrade is safe. It combines release notes from the registries and from GitHub, and adds vulnerability and project health data. It is free to use and needs no account. -pkgdiff is a web-based tool that helps developers compare package versions across different registries (NuGet, npm, PyPI and Pub). It aggregates release notes from multiple sources including registry pages and GitHub Releases, providing a consolidated view of changes between any two versions. Perfect for developers upgrading dependencies and managing package updates. +## Main pages -## Core Features +- [Home](https://pkgdiff.dev/): Search for a package in any supported registry +- [Browse packages](https://pkgdiff.dev/packages): All packages pkgdiff has analyzed +- [Dependency audit](https://pkgdiff.dev/audit): Check a manifest's dependencies for known problems +- [About](https://pkgdiff.dev/about): Who runs pkgdiff, its data sources and their limits -- **Multi-Registry Support**: Search packages across NuGet, npm, and other registries -- **Release Notes Aggregation**: Combines release notes from registry pages and GitHub Releases -- **Version Diff**: Compare any two versions to see consolidated changelogs -- **Real-Time Updates**: Background jobs continuously fetch and update package data -- **Categorized Changes**: Release notes are automatically categorized by type (features, fixes, breaking changes, etc.) -- **Export Functionality**: Export release notes in multiple formats (Markdown, JSON, Text) +## Packages by registry -## Main Pages +- [NuGet packages](https://pkgdiff.dev/packages?registry=nuget) +- [npm packages](https://pkgdiff.dev/packages?registry=npm) +- [PyPI packages](https://pkgdiff.dev/packages?registry=pypi) +- [Maven packages](https://pkgdiff.dev/packages?registry=maven) +- [pub.dev packages](https://pkgdiff.dev/packages?registry=pub.dev) +- [Packagist packages](https://pkgdiff.dev/packages?registry=packagist) -### Home (/) -The landing page features a search interface where users can: -- Select a package registry from the dropdown (NuGet, npm, etc.) -- Search for any package by name -- View live updates of recently analyzed packages -- Access example searches with popular packages +## Package pages -### Package Page (/package/:registry/:packageId) -Displays detailed information about a specific package: -- Package metadata (description, authors, license, repository) -- Complete version history with release dates -- Tabbed interface for viewing releases and comparing versions -- Direct links to package registry and source repository -- Categorized release notes for each version +Package pages follow the pattern `https://pkgdiff.dev/package/{registry}/{packageId}`, for example [react on npm](https://pkgdiff.dev/package/npm/react). Each one lists the package's versions with categorized release notes, and can compare two versions. -### Browse Packages (/packages) -List view of all packages in the system with: -- Filtering by registry -- Search functionality -- Sorting options -- Pagination +## Data sources -## API Endpoints +- Registries: NuGet, npm, PyPI, Maven Central, pub.dev and Packagist +- Release notes: registry pages, GitHub releases and changelogs +- Security: OSV.dev, deps.dev, OpenSSF Scorecard and Socket -The service exposes a REST API: +Changes are categorized by rules that match release note headings and keywords, so a category can be wrong. The release digests under `/blog` are AI-generated and not reviewed by a person; do not treat them as a primary source. -- `GET /api/package/{registry}/{packageId}` - Get package metadata and versions -- `POST /api/package/analyze` - Trigger package analysis -- `GET /api/package/registries` - List supported registries -- `GET /api/package/recent` - Get recent package searches -- `GET /health` - Service health check +## Optional -## Data Sources - -pkgdiff retrieves data from: -1. **NuGet.org**: For .NET packages -2. **npmjs.com**: For Node.js packages -3. **GitHub Releases**: Fallback for release notes when registry data is incomplete -4. **Package repositories**: Direct links to source code and issue trackers - -## Technical Stack - -- **Frontend**: Angular with TypeScript, Tailwind CSS -- **Backend**: ASP.NET Core Web API -- **Database**: PostgreSQL with Entity Framework Core -- **Real-time Communication**: SignalR for live analysis updates -- **Background Jobs**: Hangfire for scheduled scraping tasks -- **Caching**: 30-day cache for package data - -## Search Capabilities - -Users can search for: -- Package names (e.g., "Newtonsoft.Json", "react") -- View all versions with release dates -- Compare any two versions -- Filter by registry type -- Access recent and popular searches - -## Content Organization - -- Release notes are scraped from registry pages and GitHub -- Changes are categorized automatically (New Features, Bug Fixes, Breaking Changes, etc.) -- Version comparisons show consolidated changelogs -- All changes are timestamped and linked to sources - -## Privacy & Terms - -- Minimal data collection (search history, IP for rate limiting) -- No third-party tracking or advertising -- Open-source project -- Free to use -- See /privacy and /terms for detailed policies - -## Use Cases - -pkgdiff is designed for developers who need to: -- Understand what changed between package versions -- Review breaking changes before upgrading dependencies -- Analyze release notes from multiple sources in one place -- Export changelogs for documentation -- Track package evolution over time - -## Links - -- Home: https://pkgdiff.com -- GitHub: https://github.com/pkgdiff-dev -- Privacy Policy: https://pkgdiff.com/privacy -- Terms of Service: https://pkgdiff.com/terms +- [Source code](https://github.com/pkgdiff-dev/pkgdiff) +- [Privacy policy](https://pkgdiff.dev/privacy) +- [Terms of service](https://pkgdiff.dev/terms) diff --git a/src/frontend/src/app/app.config.server.ts b/src/frontend/src/app/app.config.server.ts index 35926e4..08040cf 100644 --- a/src/frontend/src/app/app.config.server.ts +++ b/src/frontend/src/app/app.config.server.ts @@ -2,9 +2,13 @@ import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; import { provideServerRendering, withRoutes } from '@angular/ssr'; import { appConfig } from './app.config'; import { serverRoutes } from './app.routes.server'; +import { RENDER_SERVER_KEY } from './core/interceptors/forwarded-for.interceptor'; const serverConfig: ApplicationConfig = { - providers: [provideServerRendering(withRoutes(serverRoutes))], + providers: [ + provideServerRendering(withRoutes(serverRoutes)), + { provide: RENDER_SERVER_KEY, useFactory: () => process.env['RENDER_SERVER_KEY'] ?? '' }, + ], }; export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/src/frontend/src/app/app.routes.server.ts b/src/frontend/src/app/app.routes.server.ts index 83c1fad..a7ad314 100644 --- a/src/frontend/src/app/app.routes.server.ts +++ b/src/frontend/src/app/app.routes.server.ts @@ -7,6 +7,7 @@ export const serverRoutes: ServerRoute[] = [ { path: 'package/:registry/:packageId', renderMode: RenderMode.Server }, { path: 'package/:registry/:packageId/:version', renderMode: RenderMode.Server }, { path: 'package/:registry/:scope/:name/:version', renderMode: RenderMode.Server }, + { path: 'about', renderMode: RenderMode.Server }, { path: 'terms', renderMode: RenderMode.Server }, { path: 'privacy', renderMode: RenderMode.Server }, { path: 'cookies', renderMode: RenderMode.Server }, diff --git a/src/frontend/src/app/app.routes.ts b/src/frontend/src/app/app.routes.ts index 41e2b2f..6cee74e 100644 --- a/src/frontend/src/app/app.routes.ts +++ b/src/frontend/src/app/app.routes.ts @@ -30,6 +30,10 @@ export const routes: Routes = [ path: 'package/:registry/:scope/:name/:version', loadComponent: () => import('./features/package/package').then(m => m.PackageComponent) }, + { + path: 'about', + loadComponent: () => import('./features/about/about').then(m => m.AboutComponent) + }, { path: 'terms', loadComponent: () => import('./features/terms/terms').then(m => m.TermsComponent) diff --git a/src/frontend/src/app/core/interceptors/forwarded-for.interceptor.ts b/src/frontend/src/app/core/interceptors/forwarded-for.interceptor.ts index 117d246..0930ec0 100644 --- a/src/frontend/src/app/core/interceptors/forwarded-for.interceptor.ts +++ b/src/frontend/src/app/core/interceptors/forwarded-for.interceptor.ts @@ -1,14 +1,22 @@ import { HttpInterceptorFn } from '@angular/common/http'; -import { REQUEST, inject } from '@angular/core'; +import { InjectionToken, REQUEST, inject } from '@angular/core'; import { environment } from '../../../environments/environment'; +// Provided only on the render server, so the key never reaches a browser bundle +export const RENDER_SERVER_KEY = new InjectionToken('RENDER_SERVER_KEY'); + // Keeps rate limits and request logs per visitor when the render server calls the API export const forwardedForInterceptor: HttpInterceptorFn = (req, next) => { const visitor = inject(REQUEST, { optional: true })?.headers.get('x-forwarded-for'); + const renderServerKey = inject(RENDER_SERVER_KEY, { optional: true }); - if (!visitor || !req.url.startsWith(environment.apiUrl)) { + if (!req.url.startsWith(environment.apiUrl)) { return next(req); } - return next(req.clone({ setHeaders: { 'X-Forwarded-For': visitor } })); + const headers: Record = {}; + if (visitor) headers['X-Forwarded-For'] = visitor; + if (renderServerKey) headers['X-Render-Server-Key'] = renderServerKey; + + return Object.keys(headers).length > 0 ? next(req.clone({ setHeaders: headers })) : next(req); }; diff --git a/src/frontend/src/app/core/models/package.models.ts b/src/frontend/src/app/core/models/package.models.ts index 3c0c0b2..27de2a0 100644 --- a/src/frontend/src/app/core/models/package.models.ts +++ b/src/frontend/src/app/core/models/package.models.ts @@ -38,6 +38,7 @@ export interface PackageMetadata { gitHubRepositoryUrl: string | null; sourceDirectory?: string | null; versions: VersionSummary[]; + totalVersions?: number; trust?: PackageTrust | null; } diff --git a/src/frontend/src/app/core/services/http-status.service.ts b/src/frontend/src/app/core/services/http-status.service.ts index 00ccae6..8b6f731 100644 --- a/src/frontend/src/app/core/services/http-status.service.ts +++ b/src/frontend/src/app/core/services/http-status.service.ts @@ -1,6 +1,8 @@ import { Injectable, RESPONSE_INIT, inject } from '@angular/core'; -// Lets a server render answer a missing page with a real 404 instead of a 200 +const RETRY_AFTER_SECONDS = '120'; + +// Lets a server render answer a missing or failed page with its real status instead of a 200 @Injectable({ providedIn: 'root' }) export class HttpStatusService { private response = inject(RESPONSE_INIT, { optional: true }); @@ -10,4 +12,13 @@ export class HttpStatusService { this.response.status = 404; } } + + serviceUnavailable(): void { + if (this.response) { + const headers = new Headers(this.response.headers); + headers.set('Retry-After', RETRY_AFTER_SECONDS); + this.response.status = 503; + this.response.headers = headers; + } + } } diff --git a/src/frontend/src/app/core/services/package.service.ts b/src/frontend/src/app/core/services/package.service.ts index 5632800..09c75f6 100644 --- a/src/frontend/src/app/core/services/package.service.ts +++ b/src/frontend/src/app/core/services/package.service.ts @@ -15,8 +15,12 @@ export class PackageService { return this.api.get('Package/registries'); } - getPackageMetadata(registry: string, packageId: string): Observable { - return this.api.get(`Package/${registry}/${encodeURIComponent(packageId)}`); + getPackageMetadata( + registry: string, + packageId: string, + versions?: { versionLimit: number; includeVersion?: string }, + ): Observable { + return this.api.get(`Package/${registry}/${encodeURIComponent(packageId)}`, versions); } analyze(request: AnalyzeRequest): Observable { diff --git a/src/frontend/src/app/core/services/seo.service.ts b/src/frontend/src/app/core/services/seo.service.ts index 4107a55..4dd161d 100644 --- a/src/frontend/src/app/core/services/seo.service.ts +++ b/src/frontend/src/app/core/services/seo.service.ts @@ -84,6 +84,16 @@ export class SeoService { this.clearPageData(); } + // The page failed to load; the next successful updateMeta makes it indexable again + setUnavailable(): void { + this.setNoIndex(); + this.clearPageData(); + } + + setNoIndex(): void { + this.meta.updateTag({ name: 'robots', content: 'noindex, follow' }); + } + setStructuredData(data: StructuredData): void { this.setJsonLd('page', data); } @@ -128,7 +138,7 @@ export class SeoService { } setPackage(metadata: PackageMetadata, registry: string, version?: string): void { - const versionCount = metadata.versions.length; + const versionCount = metadata.totalVersions ?? metadata.versions.length; const latestVersion = metadata.latestVersion; let description = metadata.packageId; diff --git a/src/frontend/src/app/core/utils/pagination.utils.ts b/src/frontend/src/app/core/utils/pagination.utils.ts new file mode 100644 index 0000000..a02b38f --- /dev/null +++ b/src/frontend/src/app/core/utils/pagination.utils.ts @@ -0,0 +1,17 @@ +export type PageLink = { kind: 'page'; page: number } | { kind: 'gap'; key: string }; + +const SIBLINGS = 1; + +export function pageWindow(current: number, total: number): PageLink[] { + let start = Math.max(2, current - SIBLINGS); + let end = Math.min(total - 1, current + SIBLINGS); + if (start === 3) start = 2; + if (end === total - 2) end = total - 1; + + const links: PageLink[] = [{ kind: 'page', page: 1 }]; + if (start > 2) links.push({ kind: 'gap', key: 'gap-start' }); + for (let page = start; page <= end; page++) links.push({ kind: 'page', page }); + if (end < total - 1) links.push({ kind: 'gap', key: 'gap-end' }); + if (total > 1) links.push({ kind: 'page', page: total }); + return links; +} diff --git a/src/frontend/src/app/features/about/about.html b/src/frontend/src/app/features/about/about.html new file mode 100644 index 0000000..db9689e --- /dev/null +++ b/src/frontend/src/app/features/about/about.html @@ -0,0 +1,70 @@ +
+
+
+

About {{ appName }}

+

+ Every release note between two versions of a package, with a security picture of the upgrade. +

+
+ +
+ +
+

Who runs it

+

+ {{ appName }} is built and operated by {{ operatorName }}, an individual developer. It is free to use and + needs no account. The code is on + GitHub, + where you can report a problem. You can also write to + {{ contactEmail }} or find + {{ operatorName }} on + GitHub. +

+
+ +
+

Where the data comes from

+

+ Versions and package details come from the registries: NuGet, npm, PyPI, Maven Central, pub.dev and Packagist. + Release notes come from those registries and from each project's GitHub releases and changelogs. Every note + links back to its source, and it still belongs to its authors. +

+

The security information comes from these services:

+ +
+ +
+

How changes are categorised

+

+ Changes are sorted into groups such as breaking changes, new features and bug fixes by matching the section + headings and keywords in the release notes. These are rules, not a reading of the code, so a change can land + in the wrong group, and a breaking change its authors did not mention will not appear. +

+
+ +
+

How fresh the data is

+

+ Package data is stored and refreshed by background jobs, so a very recent release or advisory can take time + to appear. Security checks run in the background too, and a page shows what was last retrieved. +

+
+ +
+

What it cannot tell you

+

+ No listed vulnerability does not prove a package is safe. It means the sources above report nothing for it + right now. Use {{ appName }} to prepare an upgrade, and still test the upgrade yourself. The + release digests are written by AI and not + reviewed by a person, so they are labelled as such and kept out of search results. +

+
+ +
+
+
diff --git a/src/frontend/src/app/features/about/about.ts b/src/frontend/src/app/features/about/about.ts new file mode 100644 index 0000000..6992844 --- /dev/null +++ b/src/frontend/src/app/features/about/about.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { APP_CONFIG } from '../../core/config/app.config'; +import { SeoService } from '../../core/services/seo.service'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'app-about', + standalone: true, + imports: [RouterLink], + templateUrl: './about.html', +}) +export class AboutComponent { + appName = APP_CONFIG.name; + contactEmail = APP_CONFIG.contact.email; + operatorName = 'Enes Yağız'; + + constructor() { + inject(SeoService).updateMeta({ + title: 'About pkgdiff: Who Runs It and Where the Data Comes From', + description: 'Who operates pkgdiff, which sources its release notes and security data come from, how changes are categorised, and what the data cannot tell you.', + url: '/about', + }); + } +} diff --git a/src/frontend/src/app/features/blog/blog-detail/blog-detail.html b/src/frontend/src/app/features/blog/blog-detail/blog-detail.html index c6c542e..ea3212d 100644 --- a/src/frontend/src/app/features/blog/blog-detail/blog-detail.html +++ b/src/frontend/src/app/features/blog/blog-detail/blog-detail.html @@ -1,7 +1,7 @@
@@ -61,7 +61,7 @@

Article Not F published yet.

- Return to Blog List + Return to Release Digests

} diff --git a/src/frontend/src/app/features/blog/blog-detail/blog-detail.ts b/src/frontend/src/app/features/blog/blog-detail/blog-detail.ts index c18a602..7276292 100644 --- a/src/frontend/src/app/features/blog/blog-detail/blog-detail.ts +++ b/src/frontend/src/app/features/blog/blog-detail/blog-detail.ts @@ -70,7 +70,10 @@ export class BlogDetailComponent { const failure = loadFailureOf(loaded.failure); if (failure === 'not-found') { this.httpStatus.notFound(); - this.seo.setNotFound('Article Not Found — pkgdiff Blog', 'This blog post does not exist or has not been published yet.'); + this.seo.setNotFound('Article Not Found — pkgdiff', 'This release digest does not exist or has not been published yet.'); + } else { + this.httpStatus.serviceUnavailable(); + this.seo.setUnavailable(); } this.state.set(failure); return; @@ -82,12 +85,14 @@ export class BlogDetailComponent { const excerpt = excerptOf(post.content); this.seo.updateMeta({ - title: `${post.title} — pkgdiff Blog`, + title: `${post.title} — pkgdiff`, description: excerpt, url: `/blog/${post.slug}`, type: 'article', image: '/og-image/blog.png' }); + // Posts are generated and unreviewed, so they stay out of search results + this.seo.setNoIndex(); this.seo.setStructuredData({ '@context': 'https://schema.org', @@ -104,7 +109,7 @@ export class BlogDetailComponent { this.seo.setBreadcrumb([ { name: 'Home', url: '/' }, - { name: 'Blog', url: '/blog' }, + { name: 'Release digests', url: '/blog' }, { name: post.title, url: `/blog/${post.slug}` }, ]); } diff --git a/src/frontend/src/app/features/blog/blog-list/blog-list.html b/src/frontend/src/app/features/blog/blog-list/blog-list.html index 9a8625a..3b08fc2 100644 --- a/src/frontend/src/app/features/blog/blog-list/blog-list.html +++ b/src/frontend/src/app/features/blog/blog-list/blog-list.html @@ -1,8 +1,8 @@
-

Engineering Blog

-

Deep dives into package analysis, security research, and - infrastructure.

+

Release Digests

+

AI-generated summaries of recent package releases, not + reviewed by a person. Check the release notes before relying on one.

@switch (state()) { diff --git a/src/frontend/src/app/features/blog/blog-list/blog-list.ts b/src/frontend/src/app/features/blog/blog-list/blog-list.ts index c3798d7..ec9737e 100644 --- a/src/frontend/src/app/features/blog/blog-list/blog-list.ts +++ b/src/frontend/src/app/features/blog/blog-list/blog-list.ts @@ -9,28 +9,13 @@ import { SeoService } from '../../../core/services/seo.service'; import { BlogPostSnippet } from '../../../core/models/blog.models'; import { PaginatedResult } from '../../../core/models/package.models'; import { stripMarkdown } from '../../../core/utils/markdown.utils'; +import { pageWindow } from '../../../core/utils/pagination.utils'; +import { HttpStatusService } from '../../../core/services/http-status.service'; type PageState = 'loading' | 'loaded' | 'error'; type Loaded = { page: PaginatedResult } | { failure: unknown }; -type PageLink = { kind: 'page'; page: number } | { kind: 'gap'; key: string }; - const PAGE_SIZE = 10; -const SIBLINGS = 1; - -function pageWindow(current: number, total: number): PageLink[] { - let start = Math.max(2, current - SIBLINGS); - let end = Math.min(total - 1, current + SIBLINGS); - if (start === 3) start = 2; - if (end === total - 2) end = total - 1; - - const links: PageLink[] = [{ kind: 'page', page: 1 }]; - if (start > 2) links.push({ kind: 'gap', key: 'gap-start' }); - for (let page = start; page <= end; page++) links.push({ kind: 'page', page }); - if (end < total - 1) links.push({ kind: 'gap', key: 'gap-end' }); - if (total > 1) links.push({ kind: 'page', page: total }); - return links; -} @Component({ selector: 'app-blog-list', @@ -43,6 +28,7 @@ export class BlogListComponent { private api = inject(ApiService); private route = inject(ActivatedRoute); private seo = inject(SeoService); + private httpStatus = inject(HttpStatusService); readonly BookOpenIcon = BookOpen; readonly AlertIcon = AlertCircle; @@ -79,9 +65,12 @@ export class BlogListComponent { takeUntilDestroyed(), ).subscribe(loaded => { if ('failure' in loaded) { + this.httpStatus.serviceUnavailable(); + this.seo.setUnavailable(); this.state.set('error'); return; } + this.setMeta(this.currentPage()); this.posts.set(loaded.page.data); this.totalPages.set(loaded.page.totalPages); this.state.set('loaded'); @@ -96,8 +85,8 @@ export class BlogListComponent { private setMeta(page: number): void { this.seo.updateMeta({ - title: page > 1 ? `Engineering Blog — Page ${page} — pkgdiff` : 'Engineering Blog — pkgdiff', - description: 'Read deep dives into package analysis, dependency security, software versioning trends, and developer infrastructure.', + title: page > 1 ? `Release Digests — Page ${page} — pkgdiff` : 'Release Digests — pkgdiff', + description: 'AI-generated summaries of recent package releases. Check the linked release notes before relying on a digest.', url: page > 1 ? `/blog?page=${page}` : '/blog', image: '/og-image/blog.png' }); diff --git a/src/frontend/src/app/features/contributors/contributors.ts b/src/frontend/src/app/features/contributors/contributors.ts index 8385285..49a5879 100644 --- a/src/frontend/src/app/features/contributors/contributors.ts +++ b/src/frontend/src/app/features/contributors/contributors.ts @@ -5,6 +5,7 @@ import { RouterModule } from '@angular/router'; import { LucideAngularModule, Github, Users, RefreshCw } from 'lucide-angular'; import { ApiService } from '../../core/services/api'; import { SeoService } from '../../core/services/seo.service'; +import { HttpStatusService } from '../../core/services/http-status.service'; interface ContributorDto { gitHubUsername: string; @@ -23,6 +24,7 @@ interface ContributorDto { export class ContributorsComponent { private api = inject(ApiService); private seo = inject(SeoService); + private httpStatus = inject(HttpStatusService); private destroyRef = inject(DestroyRef); readonly GithubIcon = Github; @@ -63,7 +65,11 @@ export class ContributorsComponent { this.contributors.set(data); this.loading.set(false); }, - error: () => this.loading.set(false), + error: () => { + this.httpStatus.serviceUnavailable(); + this.seo.setNoIndex(); + this.loading.set(false); + }, }); } diff --git a/src/frontend/src/app/features/home/home.html b/src/frontend/src/app/features/home/home.html index 5fb86ef..ce3ceea 100644 --- a/src/frontend/src/app/features/home/home.html +++ b/src/frontend/src/app/features/home/home.html @@ -155,7 +155,7 @@

Track Breaking

-

4 Registries Supported

+

6 Registries Supported

Works with NuGet (.NET), npm (JavaScript), PyPI (Python), Maven (Java), Pub (Dart / Flutter), and Packagist (PHP).

diff --git a/src/frontend/src/app/features/package/components/releases-tab/releases-tab.html b/src/frontend/src/app/features/package/components/releases-tab/releases-tab.html index 029005a..9e8da1d 100644 --- a/src/frontend/src/app/features/package/components/releases-tab/releases-tab.html +++ b/src/frontend/src/app/features/package/components/releases-tab/releases-tab.html @@ -3,7 +3,7 @@

Release History

- {{ versions().length }} total + {{ totalCount() ?? versions().length }} total
@if (selectedVersion()) { diff --git a/src/frontend/src/app/features/package/components/releases-tab/releases-tab.ts b/src/frontend/src/app/features/package/components/releases-tab/releases-tab.ts index 3501433..d77bae6 100644 --- a/src/frontend/src/app/features/package/components/releases-tab/releases-tab.ts +++ b/src/frontend/src/app/features/package/components/releases-tab/releases-tab.ts @@ -20,6 +20,7 @@ export class ReleasesTabComponent { registry = input('nuget'); gitHubUrl = input(null); selectedVersion = input(undefined); + totalCount = input(null); readonly GithubIcon = Github; readonly ExternalLinkIcon = ExternalLink; diff --git a/src/frontend/src/app/features/package/package.html b/src/frontend/src/app/features/package/package.html index a551b79..8119507 100644 --- a/src/frontend/src/app/features/package/package.html +++ b/src/frontend/src/app/features/package/package.html @@ -195,7 +195,8 @@

{{ @if (activeTab() === 'releases') { + [gitHubUrl]="metadata()!.gitHubRepositoryUrl" [selectedVersion]="version()" + [totalCount]="version() ? null : (metadata()!.totalVersions ?? null)" /> } @if (activeTab() === 'diff') { { startConnection: () => Promise; resetProgress: ReturnType; }; - let httpStatus: { notFound: ReturnType }; + let httpStatus: { notFound: ReturnType; serviceUnavailable: ReturnType }; let fixture: ComponentFixture; let page: PackageComponent; @@ -79,7 +79,7 @@ describe('PackageComponent', () => { startConnection: () => Promise.resolve(), resetProgress: vi.fn(), }; - httpStatus = { notFound: vi.fn() }; + httpStatus = { notFound: vi.fn(), serviceUnavailable: vi.fn() }; const packages: Partial = { getPackageMetadata: (registry: string, packageId: string): Observable => { diff --git a/src/frontend/src/app/features/package/package.ts b/src/frontend/src/app/features/package/package.ts index ade7aac..1bfdbf6 100644 --- a/src/frontend/src/app/features/package/package.ts +++ b/src/frontend/src/app/features/package/package.ts @@ -1,7 +1,8 @@ -import { ChangeDetectionStrategy, Component, DestroyRef, computed, effect, inject, signal, untracked } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, PLATFORM_ID, computed, effect, inject, signal, untracked } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, ParamMap, Router } from '@angular/router'; -import { Subject, catchError, distinctUntilChanged, map, of, startWith, switchMap, tap } from 'rxjs'; +import { EMPTY, Observable, Subject, catchError, concat, distinctUntilChanged, map, of, startWith, switchMap, tap } from 'rxjs'; import { LucideAngularModule, Clock, Link, Scale, Github, Info, Share2, Download } from 'lucide-angular'; import { ReleasesTabComponent } from './components/releases-tab/releases-tab'; import { DiffTabComponent } from './components/diff-tab/diff-tab'; @@ -57,6 +58,9 @@ type Loaded = { value: T } | { failure: unknown }; const TABS: readonly TabType[] = ['releases', 'diff', 'versions', 'metadata']; +// The releases tab shows this many at first; a longer list would only inflate the rendered page +const INITIAL_VERSIONS = 10; + @Component({ selector: 'app-package', standalone: true, @@ -85,6 +89,7 @@ export class PackageComponent { private toastService = inject(ToastService); private seo = inject(SeoService); private httpStatus = inject(HttpStatusService); + private isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); readonly ClockIcon = Clock; readonly LinkIcon = Link; @@ -252,15 +257,35 @@ export class PackageComponent { tap(() => this.resetForNewPackage()), switchMap(target => this.reload$.pipe( startWith(undefined), - switchMap(() => this.packageService.getPackageMetadata(target.registry, target.packageId).pipe( - map((value): Loaded => ({ value })), - catchError((failure: unknown) => of>({ failure })), - )), + switchMap(() => this.loadMetadata(target)), )), takeUntilDestroyed(), ).subscribe(loaded => this.showMetadata(loaded)); } + // The first load is short and reused from the server render; the browser then fetches the rest + private loadMetadata(target: PackageTarget): Observable> { + const { registry, packageId, version } = target; + const first$ = this.metadata() + ? this.packageService.getPackageMetadata(registry, packageId) + : this.packageService.getPackageMetadata(registry, packageId, { versionLimit: INITIAL_VERSIONS, includeVersion: version }); + + return first$.pipe( + switchMap(metadata => concat(of(metadata), this.remainingVersions(metadata, target))), + map((value): Loaded => ({ value })), + catchError((failure: unknown) => of>({ failure })), + ); + } + + private remainingVersions(metadata: PackageMetadata, target: PackageTarget): Observable { + const isComplete = (metadata.totalVersions ?? metadata.versions.length) <= metadata.versions.length; + if (!this.isBrowser || isComplete) { + return EMPTY; + } + // The short list stays on screen if this fails + return this.packageService.getPackageMetadata(target.registry, target.packageId).pipe(catchError(() => EMPTY)); + } + private followFragment(): void { this.route.fragment.pipe(takeUntilDestroyed()).subscribe(fragment => { if (TABS.includes(fragment as TabType)) { @@ -307,6 +332,9 @@ export class PackageComponent { this.seo.setNotFound( `${this.packageId()} — Package Not Found | pkgdiff`, `No package named ${this.packageId()} was found on ${this.registry()}.`); + } else { + this.httpStatus.serviceUnavailable(); + this.seo.setUnavailable(); } this.state.set(failure); return; diff --git a/src/frontend/src/app/features/packages/packages-list.html b/src/frontend/src/app/features/packages/packages-list.html index d7c6615..85ddf18 100644 --- a/src/frontend/src/app/features/packages/packages-list.html +++ b/src/frontend/src/app/features/packages/packages-list.html @@ -1,6 +1,11 @@
+
+

{{ heading() }}

+

{{ intro() }}

+
+
@@ -23,18 +28,26 @@ class="flex flex-wrap items-center gap-3 mb-6 pb-5 border-b border-gray-100 dark:border-gray-800/60">
@for (reg of registries(); track reg.value) { - + + } }
@@ -116,13 +129,35 @@

The package list cou }

- @if (hasMore()) { -
- -
+ @if (totalPages() > 1) { + } } @else {
= { + nuget: 'NuGet', + npm: 'npm', + pypi: 'PyPI', + maven: 'Maven', + 'pub.dev': 'pub.dev', + packagist: 'Packagist', +}; + +const REGISTRY_ALIASES: Record = { + pub: 'pub.dev', + composer: 'packagist', +}; + +function registryOf(value: string | null): string | null { + const registry = value?.toLowerCase() ?? ''; + const canonical = Object.hasOwn(REGISTRY_ALIASES, registry) ? REGISTRY_ALIASES[registry] : registry; + return Object.hasOwn(REGISTRY_LABELS, canonical) ? canonical : null; +} @Component({ selector: 'app-packages-list', @@ -22,6 +44,9 @@ import { SeoService } from '../../core/services/seo.service'; export class PackagesListComponent { private packageService = inject(PackageService); private seo = inject(SeoService); + private httpStatus = inject(HttpStatusService); + private route = inject(ActivatedRoute); + private router = inject(Router); private destroyRef = inject(DestroyRef); readonly SearchIcon = Search; @@ -39,10 +64,10 @@ export class PackagesListComponent { isLoading = signal(true); loadFailed = signal(false); searchQuery = signal(''); - selectedRegistries = signal>(new Set()); + selectedRegistry = signal(null); + requestedPage = signal(1); sortBy = signal<'latest' | 'alphabetical'>('latest'); sortOpen = signal(false); - visibleCount = signal(20); readonly PAGE_SIZE = 20; @@ -51,14 +76,14 @@ export class PackagesListComponent { { value: 'alphabetical', label: 'Alphabetical' }, ]; + registryPackages = computed(() => { + const registry = this.selectedRegistry(); + return registry ? this.packages().filter(p => p.registry === registry) : this.packages(); + }); + filteredPackages = computed(() => { const query = this.searchQuery().toLowerCase(); - const activeRegistries = this.selectedRegistries(); - let result = this.packages(); - - if (activeRegistries.size > 0) { - result = result.filter(p => activeRegistries.has(p.registry)); - } + let result = this.registryPackages(); if (query) { result = result.filter(p => @@ -75,16 +100,29 @@ export class PackagesListComponent { return result; }); + totalPages = computed(() => Math.max(1, Math.ceil(this.filteredPackages().length / this.PAGE_SIZE))); + + currentPage = computed(() => Math.min(this.requestedPage(), this.totalPages())); + + pages = computed(() => pageWindow(this.currentPage(), this.totalPages())); + visiblePackages = computed(() => { - return this.filteredPackages().slice(0, this.visibleCount()); + const start = (this.currentPage() - 1) * this.PAGE_SIZE; + return this.filteredPackages().slice(start, start + this.PAGE_SIZE); }); - hasMore = computed(() => { - return this.visibleCount() < this.filteredPackages().length; + heading = computed(() => { + const registry = this.selectedRegistry(); + return registry ? `${REGISTRY_LABELS[registry]} packages` : 'Browse packages'; }); - remainingCount = computed(() => { - return this.filteredPackages().length - this.visibleCount(); + intro = computed(() => { + const registry = this.selectedRegistry(); + const scope = registry ? `${REGISTRY_LABELS[registry]} packages` : 'packages across NuGet, npm, PyPI, Maven, pub.dev and Packagist'; + const count = this.registryPackages().length; + return count > 0 + ? `Release notes and version history for ${count} ${scope}.` + : `Release notes and version history for ${scope}.`; }); currentSortLabel = computed(() => { @@ -92,10 +130,11 @@ export class PackagesListComponent { }); constructor() { - this.seo.updateMeta({ - title: 'Browse Packages — Compare Versions Across NuGet, npm, PyPI, Maven, Pub & Packagist | pkgdiff', - description: 'Browse and compare packages from NuGet, npm, PyPI, Maven, Pub and Packagist registries. Search through thousands of packages, view detailed version history, track breaking changes, and analyze comprehensive release notes.', - url: '/packages', + // Registry and page live in the URL so every list page can be linked and crawled + this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe(params => { + this.selectedRegistry.set(registryOf(params.get('registry'))); + this.requestedPage.set(Math.max(1, Number.parseInt(params.get('page') ?? '1', 10) || 1)); + this.updateSeo(); }); this.loadPackages(); @@ -113,8 +152,11 @@ export class PackagesListComponent { next: (data) => { this.packages.set(data); this.isLoading.set(false); + this.updateSeo(); }, error: () => { + this.httpStatus.serviceUnavailable(); + this.seo.setUnavailable(); this.loadFailed.set(true); this.isLoading.set(false); } @@ -132,25 +174,29 @@ export class PackagesListComponent { onSearch(event: Event) { const input = event.target as HTMLInputElement; this.searchQuery.set(input.value); + this.returnToFirstPage(); } - toggleRegistry(value: string) { - const current = new Set(this.selectedRegistries()); - if (current.has(value)) { - current.delete(value); - } else { - current.add(value); - } - this.selectedRegistries.set(current); + isRegistrySelected(value: string): boolean { + return this.selectedRegistry() === value; } - isRegistrySelected(value: string): boolean { - return this.selectedRegistries().has(value); + registryParams(value: string): Params { + return this.isRegistrySelected(value) ? {} : { registry: value }; + } + + pageParams(page: number): Params { + const params: Params = {}; + const registry = this.selectedRegistry(); + if (registry) params['registry'] = registry; + if (page > 1) params['page'] = page; + return params; } onSortChange(value: string) { this.sortBy.set(value as 'latest' | 'alphabetical'); this.sortOpen.set(false); + this.returnToFirstPage(); } toggleSortDropdown(event: Event) { @@ -158,10 +204,6 @@ export class PackagesListComponent { this.sortOpen.update(v => !v); } - loadMore() { - this.visibleCount.update(c => c + this.PAGE_SIZE); - } - registryBadgeClass(registry: string): string { switch (registry.toLowerCase()) { case 'npm': @@ -182,4 +224,40 @@ export class PackagesListComponent { return 'border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400'; } } + + private returnToFirstPage() { + if (this.requestedPage() > 1) { + this.router.navigate([], { relativeTo: this.route, queryParams: this.pageParams(1), replaceUrl: true }); + } + } + + private updateSeo() { + if (this.loadFailed()) { + return; + } + + const page = this.requestedPage(); + const lastPage = Math.max(1, Math.ceil(this.registryPackages().length / this.PAGE_SIZE)); + + if (!this.isLoading() && page > lastPage) { + this.httpStatus.notFound(); + this.seo.setNotFound('Page Not Found | pkgdiff', 'This page of the package list does not exist.'); + return; + } + + const registry = this.selectedRegistry(); + const label = registry ? REGISTRY_LABELS[registry] : null; + const pageSuffix = page > 1 ? ` — Page ${page}` : ''; + const query = new URLSearchParams(this.pageParams(page)).toString(); + + this.seo.updateMeta({ + title: label + ? `${label} Packages: Release Notes and Versions${pageSuffix} | pkgdiff` + : `Browse Packages: Release Notes and Versions${pageSuffix} | pkgdiff`, + description: label + ? `Browse ${label} packages on pkgdiff. See version history, breaking changes and categorized release notes, and compare any two versions.` + : 'Browse packages from NuGet, npm, PyPI, Maven, pub.dev and Packagist. See version history, breaking changes and categorized release notes.', + url: query ? `/packages?${query}` : '/packages', + }); + } } diff --git a/src/frontend/src/app/shared/components/footer/footer.ts b/src/frontend/src/app/shared/components/footer/footer.ts index a4b47d7..588346f 100644 --- a/src/frontend/src/app/shared/components/footer/footer.ts +++ b/src/frontend/src/app/shared/components/footer/footer.ts @@ -20,8 +20,9 @@ export class FooterComponent { readonly exploreLinks = [ { path: '/packages', label: 'Packages' }, { path: '/audit', label: 'Dependency audit' }, - { path: '/blog', label: 'Blog' }, + { path: '/blog', label: 'Release digests' }, { path: '/contributors', label: 'Contributors' }, + { path: '/about', label: 'About' }, ]; appName = APP_CONFIG.name; diff --git a/src/frontend/src/app/shared/components/header/header.ts b/src/frontend/src/app/shared/components/header/header.ts index 3d94865..6b327fd 100644 --- a/src/frontend/src/app/shared/components/header/header.ts +++ b/src/frontend/src/app/shared/components/header/header.ts @@ -38,8 +38,9 @@ export class HeaderComponent { ]; readonly moreLinks = [ - { path: '/blog', label: 'Blog' }, + { path: '/blog', label: 'Release digests' }, { path: '/contributors', label: 'Contributors' }, + { path: '/about', label: 'About' }, ]; menuOpen = signal(false); diff --git a/src/frontend/src/index.html b/src/frontend/src/index.html index 6d94933..a597f90 100644 --- a/src/frontend/src/index.html +++ b/src/frontend/src/index.html @@ -112,6 +112,11 @@ "https://github.com/pkgdiff-dev", "https://twitter.com/pkgdiff" ], + "founder": { + "@type": "Person", + "name": "Enes Yağız", + "url": "https://github.com/fleizean" + }, "contactPoint": { "@type": "ContactPoint", "contactType": "Support", diff --git a/src/frontend/ssr-smoke.test.mjs b/src/frontend/ssr-smoke.test.mjs index d6a92eb..bbe518a 100644 --- a/src/frontend/ssr-smoke.test.mjs +++ b/src/frontend/ssr-smoke.test.mjs @@ -151,7 +151,7 @@ test('a package page carries the package, its structured data once, and the visi assert.equal(application.description, 'Pads strings'); assert.doesNotMatch(html, /