Skip to content

Commit 421be52

Browse files
Enes Yağızclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 902c373 commit 421be52

5 files changed

Lines changed: 185 additions & 66 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export type PageLink = { kind: 'page'; page: number } | { kind: 'gap'; key: string };
2+
3+
const SIBLINGS = 1;
4+
5+
export function pageWindow(current: number, total: number): PageLink[] {
6+
let start = Math.max(2, current - SIBLINGS);
7+
let end = Math.min(total - 1, current + SIBLINGS);
8+
if (start === 3) start = 2;
9+
if (end === total - 2) end = total - 1;
10+
11+
const links: PageLink[] = [{ kind: 'page', page: 1 }];
12+
if (start > 2) links.push({ kind: 'gap', key: 'gap-start' });
13+
for (let page = start; page <= end; page++) links.push({ kind: 'page', page });
14+
if (end < total - 1) links.push({ kind: 'gap', key: 'gap-end' });
15+
if (total > 1) links.push({ kind: 'page', page: total });
16+
return links;
17+
}

‎src/frontend/src/app/features/blog/blog-list/blog-list.html‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<div class="flex-1 w-full max-w-5xl mx-auto px-6 py-12">
22
<header class="mb-12">
3-
<h1 class="text-3xl font-bold tracking-tight mb-2 text-slate-900 dark:text-white">Engineering Blog</h1>
4-
<p class="text-slate-500 dark:text-slate-400 text-sm">Deep dives into package analysis, security research, and
5-
infrastructure.</p>
3+
<h1 class="text-3xl font-bold tracking-tight mb-2 text-slate-900 dark:text-white">Release Digests</h1>
4+
<p class="text-slate-500 dark:text-slate-400 text-sm">AI-generated summaries of recent package releases, not
5+
reviewed by a person. Check the release notes before relying on one.</p>
66
</header>
77

88
@switch (state()) {

‎src/frontend/src/app/features/blog/blog-list/blog-list.ts‎

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,13 @@ import { SeoService } from '../../../core/services/seo.service';
99
import { BlogPostSnippet } from '../../../core/models/blog.models';
1010
import { PaginatedResult } from '../../../core/models/package.models';
1111
import { stripMarkdown } from '../../../core/utils/markdown.utils';
12+
import { pageWindow } from '../../../core/utils/pagination.utils';
13+
import { HttpStatusService } from '../../../core/services/http-status.service';
1214

1315
type PageState = 'loading' | 'loaded' | 'error';
1416
type Loaded = { page: PaginatedResult<BlogPostSnippet> } | { failure: unknown };
1517

16-
type PageLink = { kind: 'page'; page: number } | { kind: 'gap'; key: string };
17-
1818
const PAGE_SIZE = 10;
19-
const SIBLINGS = 1;
20-
21-
function pageWindow(current: number, total: number): PageLink[] {
22-
let start = Math.max(2, current - SIBLINGS);
23-
let end = Math.min(total - 1, current + SIBLINGS);
24-
if (start === 3) start = 2;
25-
if (end === total - 2) end = total - 1;
26-
27-
const links: PageLink[] = [{ kind: 'page', page: 1 }];
28-
if (start > 2) links.push({ kind: 'gap', key: 'gap-start' });
29-
for (let page = start; page <= end; page++) links.push({ kind: 'page', page });
30-
if (end < total - 1) links.push({ kind: 'gap', key: 'gap-end' });
31-
if (total > 1) links.push({ kind: 'page', page: total });
32-
return links;
33-
}
3419

3520
@Component({
3621
selector: 'app-blog-list',
@@ -43,6 +28,7 @@ export class BlogListComponent {
4328
private api = inject(ApiService);
4429
private route = inject(ActivatedRoute);
4530
private seo = inject(SeoService);
31+
private httpStatus = inject(HttpStatusService);
4632

4733
readonly BookOpenIcon = BookOpen;
4834
readonly AlertIcon = AlertCircle;
@@ -79,9 +65,12 @@ export class BlogListComponent {
7965
takeUntilDestroyed(),
8066
).subscribe(loaded => {
8167
if ('failure' in loaded) {
68+
this.httpStatus.serviceUnavailable();
69+
this.seo.setUnavailable();
8270
this.state.set('error');
8371
return;
8472
}
73+
this.setMeta(this.currentPage());
8574
this.posts.set(loaded.page.data);
8675
this.totalPages.set(loaded.page.totalPages);
8776
this.state.set('loaded');
@@ -96,8 +85,8 @@ export class BlogListComponent {
9685

9786
private setMeta(page: number): void {
9887
this.seo.updateMeta({
99-
title: page > 1 ? `Engineering Blog — Page ${page} — pkgdiff` : 'Engineering Blog — pkgdiff',
100-
description: 'Read deep dives into package analysis, dependency security, software versioning trends, and developer infrastructure.',
88+
title: page > 1 ? `Release Digests — Page ${page} — pkgdiff` : 'Release Digests — pkgdiff',
89+
description: 'AI-generated summaries of recent package releases. Check the linked release notes before relying on a digest.',
10190
url: page > 1 ? `/blog?page=${page}` : '/blog',
10291
image: '/og-image/blog.png'
10392
});

‎src/frontend/src/app/features/packages/packages-list.html‎

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
<div class="flex flex-col min-h-screen w-full font-display min-h-[calc(100vh-64px)]">
22

33
<main class="flex-1 max-w-5xl w-full mx-auto px-4 md:px-6 py-6 md:py-10 pb-20">
4+
<header class="mb-6">
5+
<h1 class="text-2xl md:text-3xl font-bold tracking-tight text-gray-900 dark:text-white mb-1">{{ heading() }}</h1>
6+
<p class="text-sm text-gray-500 dark:text-gray-400">{{ intro() }}</p>
7+
</header>
8+
49
<div
510
class="sticky top-0 z-40 bg-white/80 dark:bg-black/0 backdrop-blur-md pb-4 pt-2 -mt-2 mb-6 transition-colors duration-300">
611
<div class="flex items-center gap-4">
@@ -23,18 +28,26 @@
2328
class="flex flex-wrap items-center gap-3 mb-6 pb-5 border-b border-gray-100 dark:border-gray-800/60">
2429
<div class="flex flex-wrap items-center gap-2">
2530
@for (reg of registries(); track reg.value) {
26-
<button type="button" (click)="!reg.disabled && toggleRegistry(reg.value)" [class.opacity-40]="reg.disabled"
27-
[attr.aria-pressed]="isRegistrySelected(reg.value)" [attr.aria-disabled]="reg.disabled ? 'true' : null"
28-
[class.cursor-not-allowed]="reg.disabled"
31+
@if (reg.disabled) {
32+
<span aria-disabled="true"
33+
class="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full text-xs font-semibold border border-gray-200 dark:border-gray-700/60 opacity-40 cursor-not-allowed">
34+
<img [src]="reg.icon" alt="" width="14" height="14" loading="lazy" class="w-3.5 h-3.5 object-contain"
35+
(error)="hideBrokenIcon($event)" />
36+
<span>{{ reg.label }}</span>
37+
</span>
38+
} @else {
39+
<a routerLink="/packages" [queryParams]="registryParams(reg.value)"
40+
[attr.aria-current]="isRegistrySelected(reg.value) ? 'true' : null"
2941
class="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full text-xs font-semibold border transition-all duration-200"
3042
[ngClass]="{
3143
'bg-primary/10 text-primary border-primary/30 shadow-sm shadow-primary/5': isRegistrySelected(reg.value),
32-
'bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-300': !isRegistrySelected(reg.value) && !reg.disabled
44+
'bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-300': !isRegistrySelected(reg.value)
3345
}">
3446
<img [src]="reg.icon" alt="" width="14" height="14" loading="lazy" class="w-3.5 h-3.5 object-contain"
3547
(error)="hideBrokenIcon($event)" />
3648
<span>{{ reg.label }}</span>
37-
</button>
49+
</a>
50+
}
3851
}
3952
</div>
4053

@@ -116,13 +129,35 @@ <h3 class="text-lg font-bold text-gray-900 dark:text-white">The package list cou
116129
}
117130
</div>
118131

119-
@if (hasMore()) {
120-
<div class="mt-8 flex justify-center">
121-
<button type="button" (click)="loadMore()"
122-
class="w-full md:w-auto px-8 py-2.5 border border-gray-200 dark:border-gray-700 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors">
123-
Load more packages
124-
</button>
125-
</div>
132+
@if (totalPages() > 1) {
133+
<nav class="mt-8 flex flex-wrap justify-center gap-2" aria-label="Package list pages">
134+
@if (currentPage() > 1) {
135+
<a routerLink="/packages" [queryParams]="pageParams(currentPage() - 1)" rel="prev" aria-label="Previous page"
136+
class="flex items-center px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors">
137+
<lucide-icon [img]="ChevronLeftIcon" [size]="16"></lucide-icon>
138+
</a>
139+
}
140+
@for (link of pages(); track link.kind === 'page' ? link.page : link.key) {
141+
@if (link.kind === 'page') {
142+
<a routerLink="/packages" [queryParams]="pageParams(link.page)"
143+
[attr.aria-current]="currentPage() === link.page ? 'page' : null"
144+
class="px-3 md:px-4 py-2 border rounded-lg text-sm font-medium transition-colors"
145+
[ngClass]="currentPage() === link.page
146+
? 'bg-primary/10 text-primary border-primary/30'
147+
: 'border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-900'">
148+
{{ link.page }}
149+
</a>
150+
} @else {
151+
<span class="px-1 py-2 text-sm text-gray-400 dark:text-gray-600" aria-hidden="true">…</span>
152+
}
153+
}
154+
@if (currentPage() < totalPages()) {
155+
<a routerLink="/packages" [queryParams]="pageParams(currentPage() + 1)" rel="next" aria-label="Next page"
156+
class="flex items-center px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors">
157+
<lucide-icon [img]="ChevronRightIcon" [size]="16"></lucide-icon>
158+
</a>
159+
}
160+
</nav>
126161
}
127162
} @else {
128163
<div

0 commit comments

Comments
 (0)