From 51e5e19bd7856fe6cd91f44d8e3dd68892e0119d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enes=20Ya=C4=9F=C4=B1z?= Date: Mon, 2 Nov 2026 22:11:39 +0300 Subject: [PATCH 1/4] fix(seo): give server rendering its own rate limit and correct the sitemap The sitemap no longer counts against any limit and is cached for an hour. Its URLs now match the canonicals, and static pages no longer claim to change every day. Co-Authored-By: Claude Opus 5.5 --- .env.example | 8 ++ docker-compose.yml | 2 + .../Controllers/SitemapController.cs | 128 +++++++++--------- .../Middleware/RateLimitingMiddleware.cs | 33 ++++- .../Options/InboundRateLimitOptions.cs | 9 +- src/backend/PkgDiff.API/appsettings.json | 4 + src/frontend/src/app/app.config.server.ts | 6 +- .../interceptors/forwarded-for.interceptor.ts | 14 +- 8 files changed, 135 insertions(+), 69 deletions(-) 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/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/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/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); }; From 902c3736f4b2e25c49b888cdd5461e168cead53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enes=20Ya=C4=9F=C4=B1z?= Date: Mon, 2 Nov 2026 22:11:39 +0300 Subject: [PATCH 2/4] fix(seo): answer failed renders with 503 and shorten package page state A page whose API call failed now answers 503 with Retry-After and noindex. A package page embeds its newest versions, and the browser fetches the rest after it loads. Co-Authored-By: Claude Opus 5.5 --- .../Controllers/PackageController.cs | 18 ++++++++- .../PkgDiff.Shared/DTOs/AnalysisModels.cs | 1 + .../src/app/core/models/package.models.ts | 1 + .../app/core/services/http-status.service.ts | 13 +++++- .../src/app/core/services/package.service.ts | 8 +++- .../src/app/core/services/seo.service.ts | 12 +++++- .../app/features/contributors/contributors.ts | 8 +++- .../components/releases-tab/releases-tab.html | 2 +- .../components/releases-tab/releases-tab.ts | 1 + .../src/app/features/package/package.html | 3 +- .../src/app/features/package/package.spec.ts | 4 +- .../src/app/features/package/package.ts | 40 ++++++++++++++++--- 12 files changed, 95 insertions(+), 16 deletions(-) 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.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/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/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/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; From 421be524cfa3e3e3d3386bf97dd84d2942a9f9d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enes=20Ya=C4=9F=C4=B1z?= Date: Mon, 2 Nov 2026 22:11:39 +0300 Subject: [PATCH 3/4] feat(seo): add crawlable package list pages and registry hubs The registry and page are now in the /packages URL, so every package page is reachable by a link. The blog list also becomes Release Digests and answers 503 when it cannot load. Co-Authored-By: Claude Opus 5.5 --- .../src/app/core/utils/pagination.utils.ts | 17 +++ .../features/blog/blog-list/blog-list.html | 6 +- .../app/features/blog/blog-list/blog-list.ts | 27 +--- .../app/features/packages/packages-list.html | 59 ++++++-- .../app/features/packages/packages-list.ts | 142 ++++++++++++++---- 5 files changed, 185 insertions(+), 66 deletions(-) create mode 100644 src/frontend/src/app/core/utils/pagination.utils.ts 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/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/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', + }); + } } From 7d31ffde71676d3bc87504624fd1a3de38259651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enes=20Ya=C4=9F=C4=B1z?= Date: Mon, 2 Nov 2026 22:11:39 +0300 Subject: [PATCH 4/4] feat(seo): add an about page and keep generated posts out of search Generated posts are unreviewed, so they are now noindex. Also corrects llms.txt and the homepage registry count. Co-Authored-By: Claude Opus 5.5 --- src/frontend/public/llms.txt | 118 ++++-------------- src/frontend/src/app/app.routes.server.ts | 1 + src/frontend/src/app/app.routes.ts | 4 + .../src/app/features/about/about.html | 70 +++++++++++ src/frontend/src/app/features/about/about.ts | 25 ++++ .../blog/blog-detail/blog-detail.html | 4 +- .../features/blog/blog-detail/blog-detail.ts | 11 +- src/frontend/src/app/features/home/home.html | 2 +- .../app/shared/components/footer/footer.ts | 3 +- .../app/shared/components/header/header.ts | 3 +- src/frontend/src/index.html | 5 + src/frontend/ssr-smoke.test.mjs | 4 +- 12 files changed, 147 insertions(+), 103 deletions(-) create mode 100644 src/frontend/src/app/features/about/about.html create mode 100644 src/frontend/src/app/features/about/about.ts 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.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/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/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/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, /