Skip to content

Commit 5ba24df

Browse files
committed
Paginate archive listings while preserving legacy routes
1 parent 700a877 commit 5ba24df

3 files changed

Lines changed: 156 additions & 12 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
---
2+
import { archivePageUrl } from '../lib/posts';
3+
4+
interface Props {
5+
currentPage: number;
6+
totalPages: number;
7+
baseUrl: string;
8+
}
9+
10+
const { currentPage, totalPages, baseUrl } = Astro.props;
11+
const visiblePages = [...new Set([
12+
1,
13+
totalPages,
14+
currentPage - 2,
15+
currentPage - 1,
16+
currentPage,
17+
currentPage + 1,
18+
currentPage + 2,
19+
].filter(page => page >= 1 && page <= totalPages))].sort((a, b) => a - b);
20+
21+
const pageItems: (number | 'ellipsis')[] = [];
22+
visiblePages.forEach((page, index) => {
23+
if (index > 0) {
24+
const previous = visiblePages[index - 1];
25+
if (page - previous === 2) pageItems.push(previous + 1);
26+
else if (page - previous > 2) pageItems.push('ellipsis');
27+
}
28+
pageItems.push(page);
29+
});
30+
---
31+
32+
{totalPages > 1 && (
33+
<nav class="archive-pagination" aria-label="归档分页">
34+
{currentPage > 1 ? (
35+
<a class="pagination-control" href={archivePageUrl(baseUrl, currentPage - 1)} rel="prev">上一页</a>
36+
) : (
37+
<span class="pagination-control pagination-disabled" aria-disabled="true">上一页</span>
38+
)}
39+
{pageItems.map(page => page === 'ellipsis' ? (
40+
<span class="pagination-ellipsis" aria-hidden="true">…</span>
41+
) : page === currentPage ? (
42+
<span class="pagination-number pagination-current" aria-current="page" aria-label={`第 ${page} 页,当前页`}>{page}</span>
43+
) : (
44+
<a class="pagination-number" href={archivePageUrl(baseUrl, page)} aria-label={`第 ${page} 页`}>{page}</a>
45+
))}
46+
{currentPage < totalPages ? (
47+
<a class="pagination-control" href={archivePageUrl(baseUrl, currentPage + 1)} rel="next">下一页</a>
48+
) : (
49+
<span class="pagination-control pagination-disabled" aria-disabled="true">下一页</span>
50+
)}
51+
</nav>
52+
)}
53+
54+
<style>
55+
.archive-pagination {
56+
display: flex;
57+
flex-wrap: wrap;
58+
align-items: center;
59+
justify-content: center;
60+
gap: 0.5rem;
61+
margin: 2rem 0;
62+
}
63+
64+
.pagination-control,
65+
.pagination-number {
66+
display: inline-flex;
67+
align-items: center;
68+
justify-content: center;
69+
box-sizing: border-box;
70+
min-width: 40px;
71+
min-height: 40px;
72+
padding: 0.4rem 0.65rem;
73+
border: 1px solid var(--line);
74+
border-radius: 0.35rem;
75+
background: var(--surface);
76+
color: var(--text);
77+
font-size: 15px;
78+
font-weight: 500;
79+
line-height: 1.4;
80+
text-decoration: none;
81+
white-space: nowrap;
82+
}
83+
84+
a.pagination-control:hover,
85+
a.pagination-number:hover {
86+
border-color: var(--accent);
87+
color: var(--accent);
88+
}
89+
90+
a.pagination-control:focus-visible,
91+
a.pagination-number:focus-visible {
92+
outline: 2px solid var(--accent);
93+
outline-offset: 2px;
94+
}
95+
96+
.pagination-current {
97+
border-color: var(--accent);
98+
background: var(--accent);
99+
color: var(--surface);
100+
font-weight: 700;
101+
}
102+
103+
.pagination-disabled {
104+
opacity: 0.45;
105+
}
106+
107+
.pagination-ellipsis {
108+
display: inline-flex;
109+
align-items: center;
110+
justify-content: center;
111+
min-width: 24px;
112+
min-height: 40px;
113+
color: var(--text);
114+
font-size: 15px;
115+
}
116+
</style>

‎src/lib/posts.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import legacyRoutes from '../../migration/legacy-routes.json';
33
import courses from '../data/courses.json';
44
const byCourse = new Map(courses.map(course => [course.slug, course]));
55

6+
export const ARCHIVE_PAGE_SIZE = 10;
7+
export function archivePageUrl(baseUrl: string, page: number) {
8+
return page === 1 ? baseUrl : `${baseUrl}page/${page}/`;
9+
}
10+
611
export async function allPosts() {
712
const entries = await getCollection('posts');
813
return entries.map(entry => ({ id: entry.id, ...entry.data, ...(entry.data.course ? { courseName: byCourse.get(entry.data.course)?.name } : {}) })).sort((a, b) => b.date.localeCompare(a.date) || a.title.localeCompare(b.title, 'zh-CN'));

‎src/pages/[...path].astro‎

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
---
22
import SiteLayout from '../layouts/SiteLayout.astro';
3-
import { allPosts, dateLabel, seriesSlug, taxonomyUrl, courseOrder } from '../lib/posts';
3+
import ArchivePagination from '../components/ArchivePagination.astro';
4+
import { allPosts, dateLabel, seriesSlug, taxonomyUrl, courseOrder, ARCHIVE_PAGE_SIZE, archivePageUrl } from '../lib/posts';
45
import legacyRoutes from '../../migration/legacy-routes.json';
56
import baseline from '../../migration/baseline.json';
67
export async function getStaticPaths() {
78
const posts = await allPosts();
89
const paths = posts.map(post => ({ params: { path: post.url.replace(/^\/+|\/+$/g, '') }, props: { post, posts } }));
910
const occupied = new Set(posts.map(post => post.url));
1011
for (const listing of legacyRoutes) {
11-
if (listing.kind === 'links') continue;
12+
if (listing.kind === 'links' || listing.kind === 'archives') continue;
1213
if (!occupied.has(listing.url)) paths.push({ params: { path: listing.url.replace(/^\/+|\/+$/g, '') }, props: { listing, posts } });
1314
}
14-
for (const kind of ['tags', 'categories', 'archives']) {
15-
if (!legacyRoutes.some(route => route.url === `/${kind}/`)) paths.push({ params: { path: kind }, props: { listing: { url: `/${kind}/`, kind, title: kind === 'tags' ? '标签' : kind === 'categories' ? '分类' : '文章归档' }, posts } });
15+
for (const kind of ['tags', 'categories']) {
16+
if (!legacyRoutes.some(route => route.url === `/${kind}/`)) paths.push({ params: { path: kind }, props: { listing: { url: `/${kind}/`, kind, title: kind === 'tags' ? '标签' : '分类' }, posts } });
1617
}
1718
const existing = new Set(paths.map(item => item.params.path));
1819
for (const kind of ['tags', 'categories'] as const) {
@@ -26,10 +27,35 @@ export async function getStaticPaths() {
2627
}
2728
}
2829
}
30+
const archiveGroups = new Map([['/archives/', posts]]);
31+
for (const post of posts) {
32+
const date = dateLabel(post.date);
33+
for (const prefix of [date.slice(0, 4), date.slice(0, 7).replace('-', '/')]) {
34+
const baseUrl = `/archives/${prefix}/`;
35+
if (!archiveGroups.has(baseUrl)) archiveGroups.set(baseUrl, []);
36+
archiveGroups.get(baseUrl).push(post);
37+
}
38+
}
39+
for (const [baseUrl, members] of archiveGroups) {
40+
const totalPages = Math.max(1, Math.ceil(members.length / ARCHIVE_PAGE_SIZE));
41+
const period = baseUrl.replace(/^\/archives\//, '').replace(/\/$/, '').replace('/', '-');
42+
for (let page = 1; page <= totalPages; page++) {
43+
const url = archivePageUrl(baseUrl, page);
44+
const route = url.replace(/^\/+|\/+$/g, '');
45+
if (!existing.has(route)) {
46+
paths.push({ params: { path: route }, props: {
47+
listing: { url, kind: 'archives', title: period ? `${period} · 文章归档` : '归档' },
48+
posts,
49+
archive: { baseUrl, currentPage: page, totalPages, totalItems: members.length, items: members.slice((page - 1) * ARCHIVE_PAGE_SIZE, page * ARCHIVE_PAGE_SIZE) },
50+
} });
51+
existing.add(route);
52+
}
53+
}
54+
}
2955
return paths;
3056
}
31-
const { post, listing, posts } = Astro.props;
32-
let selected = posts;
57+
const { post, listing, posts, archive } = Astro.props;
58+
let selected = archive?.items ?? posts;
3359
let listingTitle = listing?.title;
3460
if (listing?.kind === 'tags' || listing?.kind === 'categories') {
3561
const prefix = listing.kind === 'tags' ? '标签' : '分类';
@@ -42,10 +68,7 @@ if (listing?.kind === 'tags' || listing?.kind === 'categories') {
4268
}
4369
}
4470
}
45-
if (listing?.kind === 'archives') {
46-
const match = listing.url.match(/^\/archives\/(\d{4})(?:\/(\d{2}))?/);
47-
if (match) { const prefix = match[1] + (match[2] ? `-${match[2]}` : ''); selected = posts.filter(item => dateLabel(item.date).startsWith(prefix)); listingTitle = `${prefix} · 文章归档`; }
48-
}
71+
const pageTitle = archive?.currentPage > 1 ? `${listingTitle} · 第 ${archive.currentPage} 页` : listingTitle;
4972
const taxonomy = listing?.url === '/tags/' ? 'tags' : listing?.url === '/categories/' ? 'categories' : null;
5073
const labels = taxonomy ? [...new Set(posts.flatMap(item => item[taxonomy]))].sort((a,b)=>a.localeCompare(b,'zh-CN')) : [];
5174
const inSeries = post?.series ? posts.filter(item => item.series === post.series).sort((a,b)=>(a.series_order??999)-(b.series_order??999)||a.title.localeCompare(b.title,'zh-CN')) : [];
@@ -57,15 +80,15 @@ const courseIndex = post ? coursePosts.findIndex(item => item.url === post.url)
5780
const previousCoursePost = courseIndex > 0 ? coursePosts[courseIndex - 1] : null;
5881
const nextCoursePost = courseIndex >= 0 ? coursePosts[courseIndex + 1] : null;
5982
---
60-
<SiteLayout title={post?.title || listingTitle} description={post?.description || 'SpriCoder 的文章归档与分类。'} article={Boolean(post)}>
83+
<SiteLayout title={post?.title || pageTitle} description={post?.description || 'SpriCoder 的文章归档与分类。'} article={Boolean(post)}>
6184
{post ? <div class="container article-container">
6285
<header class="article-header"><a class="breadcrumb" href={post.course ? `/courses/${post.course}/` : post.series ? `/series/${seriesSlug(post.series)}/` : '/archives/'}>← {post.courseName || post.series || '文章归档'}</a><h1>{post.title}</h1><div class="article-meta"><time datetime={post.date}>{dateLabel(post.date)}</time><span>约 {post.readingMinutes} 分钟阅读</span><span id="leancloud-page-views-container" data-counter-path={post.counterPath}>阅读 <span id="leancloud-page-views">—</span> 次</span></div></header>
6386
<div class="article-layout"><article class="article-content" data-pagefind-body data-pagefind-meta={`title:${post.title}`} set:html={post.html}></article>{post.headings.length > 0 && <aside class="article-toc" aria-label="文章目录"><details><summary>本文目录</summary><nav>{post.headings.map(heading=><a class:list={[{'toc-sub':heading.level===3}]} href={`#${heading.id}`}>{heading.text}</a>)}</nav></details></aside>}</div>
6487
{post.tags.length > 0 && <div class="tags">{post.tags.map(tag=><a href={taxonomyUrl('tags',tag)}>#{tag}</a>)}</div>}
6588
{post.course && <section class="series-navigation"><a class="text-link" href={`/courses/${post.course}/`}>返回「{post.courseName}」课程目录 →</a><div class="post-grid">{previousCoursePost && <div><p class="eyebrow">上一篇课程笔记</p><a href={previousCoursePost.url}>{previousCoursePost.title}</a></div>}{nextCoursePost && <div><p class="eyebrow">下一篇课程笔记</p><a href={nextCoursePost.url}>{nextCoursePost.title}</a></div>}</div></section>}
6689
{post.series && <section class="series-navigation"><a class="text-link" href={`/series/${seriesSlug(post.series)}/`}>查看「{post.series}」完整目录 →</a><div class="post-grid">{previous && <div><p class="eyebrow">上一篇</p><a href={previous.url}>{previous.title}</a></div>}{next && <div><p class="eyebrow">下一篇</p><a href={next.url}>{next.title}</a></div>}</div></section>}
6790
<section class="comments" aria-label="文章评论"><h2>评论</h2><script is:inline src="https://utteranc.es/client.js" repo="SpriCoder/spricoder-blog-comment" issue-term={post.counterPath !== post.url ? encodeURI(post.counterPath) : 'pathname'} theme="preferred-color-scheme" crossorigin="anonymous" async></script></section>
68-
</div> : <div class="container"><header class="page-header"><h1>{listingTitle}</h1><p>{taxonomy ? `${labels.length} 个${taxonomy === 'tags' ? '标签' : '分类'}` : `${selected.length} 篇文章`}</p><div class="archive-nav"><a href="/archives/">全部文章</a><a href="/categories/">按分类</a><a href="/tags/">按标签</a></div></header>{taxonomy ? <div class="tags taxonomy-tags">{labels.map(label=><a href={taxonomyUrl(taxonomy,label)}>{label} <span>{posts.filter(item=>item[taxonomy].includes(label)).length}</span></a>)}</div> : <ul class="archive-list">{selected.map(item=><li><time datetime={item.date}>{dateLabel(item.date)}</time><a href={item.url}>{item.title}</a></li>)}</ul>}</div>}
91+
</div> : <div class="container"><header class="page-header"><h1>{listingTitle}</h1><p>{taxonomy ? `${labels.length} 个${taxonomy === 'tags' ? '标签' : '分类'}` : `${archive?.totalItems ?? selected.length} 篇文章`}{archive && <span> · 第 {archive.currentPage} / {archive.totalPages} 页</span>}</p><div class="archive-nav"><a href="/archives/">全部文章</a><a href="/categories/">按分类</a><a href="/tags/">按标签</a></div></header>{taxonomy ? <div class="tags taxonomy-tags">{labels.map(label=><a href={taxonomyUrl(taxonomy,label)}>{label} <span>{posts.filter(item=>item[taxonomy].includes(label)).length}</span></a>)}</div> : <ul class="archive-list">{selected.map(item=><li><time datetime={item.date}>{dateLabel(item.date)}</time><a href={item.url}>{item.title}</a></li>)}</ul>}{archive && <ArchivePagination currentPage={archive.currentPage} totalPages={archive.totalPages} baseUrl={archive.baseUrl} />}</div>}
6992
</SiteLayout>
7093
<script>
7194
const content = document.querySelector('.article-content');

0 commit comments

Comments
 (0)