Skip to content

Commit 77bcaa2

Browse files
committed
Refactor Tags page to use static pagination, older SSR pagination was incorrect
1 parent 26ef8ae commit 77bcaa2

6 files changed

Lines changed: 426 additions & 330 deletions

File tree

_TODO.md

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -320,41 +320,3 @@ So the honest answer is:
320320
The bigger problem on this homepage is still total shipped JS and chunk fan-out, not a deep chained graph. If you opened DevTools, I would expect "mostly stacked direct requests, plus a smaller second wave" rather than a long staircase.
321321

322322
If you want, I can next turn that into a plain-English takeaway for your _TODO.md, like: "fan-out is the main issue; dependency waterfall is present but shallow."
323-
324-
### Tags page
325-
326-
One caution:
327-
328-
src/pages/tags/[tag].astro is prerendered but also reads ?page=. That is not a reason to keep it dynamic, but it is a sign that query-param pagination there may not be doing what you expect in a prerendered route.
329-
330-
It's using page for plain old server-side pagination.
331-
332-
In src/pages/tags/[tag].astro, the route sets ITEMS_PER_PAGE = 12, then reads the query param here:
333-
334-
src/pages/tags/[tag].astro
335-
336-
const currentPage = parseInt(Astro.url.searchParams.get('page') || '1')
337-
It uses that value to:
338-
339-
Compute the slice boundaries:
340-
src/pages/tags/[tag].astro
341-
342-
const startIndex = (currentPage - 1) * ITEMS_PER_PAGEconst endIndex = startIndex + ITEMS_PER_PAGE
343-
Slice the sorted articles for that tag:
344-
src/pages/tags/[tag].astro
345-
346-
const paginatedContent = sortedContent.slice(startIndex, endIndex)
347-
Render the pagination UI and link targets:
348-
src/pages/tags/[tag].astro
349-
That block builds:
350-
351-
Previous / Next
352-
numbered page links
353-
ellipsis when there are many pages
354-
links like /tags/foo?page=2, /tags/foo?page=3, etc.
355-
So the intent is:
356-
357-
/tags/some-tag means page 1
358-
/tags/some-tag?page=2 means articles 13-24
359-
/tags/some-tag?page=3 means the next 12, and so on
360-
One important caveat: this route is also marked prerendered in src/pages/tags/[tag].astro. That means the code is written like SSR pagination, but because the route is static, the page query param may not actually produce distinct server-rendered HTML at runtime. In other words, the code is trying to use ?page= to choose which slice to render, but prerendering makes that suspicious.
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
---
2+
import type { CollectionEntry } from 'astro:content'
3+
import type { AstroComponentFactory } from 'astro/runtime/server/index.js'
4+
import { Picture } from 'astro:assets'
5+
import BaseLayout from '@layouts/BaseLayout.astro'
6+
import Icon from '@components/Icon/index.astro'
7+
import {
8+
buildTagPagePath,
9+
getSortedTagContent,
10+
getTagPageSlice,
11+
getTagTotalPages,
12+
} from '@lib/tags/pagination'
13+
14+
export interface Props {
15+
tagEntry: CollectionEntry<'tags'>
16+
content: Array<CollectionEntry<'articles'>>
17+
currentPage: number
18+
TagContent: AstroComponentFactory
19+
}
20+
21+
const { tagEntry, content: allTagContent, currentPage, TagContent } = Astro.props as Props
22+
const tag = tagEntry.data.slug
23+
const totalItems = allTagContent.length
24+
const totalPages = getTagTotalPages(totalItems)
25+
const sortedContent = getSortedTagContent(allTagContent)
26+
const paginatedContent = getTagPageSlice(sortedContent, currentPage)
27+
const path = buildTagPagePath(tag, currentPage)
28+
---
29+
30+
<BaseLayout
31+
pageTitle={`${tagEntry.data.displayName} - Tag`}
32+
pageDescription={tagEntry.data.description}
33+
path={path}
34+
breadcrumbTitle={tagEntry.data.displayName}
35+
section="Tags"
36+
>
37+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
38+
<header class="mb-6 sm:mb-8">
39+
<div class="grid grid-cols-[auto_1fr] items-start gap-4 sm:flex sm:gap-6 sm:items-start">
40+
<div class="shrink-0">
41+
<div
42+
class="relative w-28 sm:w-32 md:w-60 aspect-square overflow-hidden border border-trim rounded-2xl bg-page-offset p-4"
43+
>
44+
<Picture
45+
src={tagEntry.data.cover}
46+
alt={tagEntry.data.coverAlt}
47+
widths={[112, 128, 240]}
48+
sizes="(min-width: 768px) 240px, (min-width: 640px) 128px, 112px"
49+
formats={['avif', 'webp', 'png']}
50+
layout="constrained"
51+
fit="contain"
52+
position="center"
53+
class="absolute inset-0 h-full w-full object-contain"
54+
loading="eager"
55+
/>
56+
</div>
57+
</div>
58+
59+
<div class="min-w-0 sm:flex-1 sm:space-y-4 sm:ml-4 sm:mt-6">
60+
<div
61+
class="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-4 sm:gap-y-2"
62+
>
63+
<h1 class="text-page-inverse">
64+
{tagEntry.data.displayName}
65+
</h1>
66+
<span
67+
class="inline-flex w-fit rounded-full bg-page-inverse px-3 py-1 text-sm font-semibold text-page-base whitespace-nowrap ml-2 sm:mt-2"
68+
>
69+
{totalItems}
70+
{totalItems === 1 ? 'article' : 'articles'}
71+
</span>
72+
</div>
73+
74+
{
75+
tagEntry.data.intro && (
76+
<p class="hidden sm:block text-lg text-content leading-relaxed max-w-2xl">
77+
{tagEntry.data.intro}
78+
</p>
79+
)
80+
}
81+
82+
<div
83+
class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-content-offset mt-2 sm:mt-0 ml-3 sm:ml-0"
84+
>
85+
{
86+
sortedContent.length > 0 && (
87+
<span class="text-content-active">
88+
Latest:{' '}
89+
<time class="font-medium text-content">
90+
{sortedContent[0]!.data.publishDate.toLocaleDateString('en-US', {
91+
month: 'short',
92+
day: 'numeric',
93+
year: 'numeric',
94+
})}
95+
</time>
96+
</span>
97+
)
98+
}
99+
</div>
100+
</div>
101+
</div>
102+
</header>
103+
104+
<section
105+
class="border border-trim rounded-xl px-6 sm:px-8 pt-4 sm:pt-6 max-w-none mb-12"
106+
aria-label="Tag description"
107+
>
108+
<TagContent />
109+
</section>
110+
111+
{
112+
paginatedContent.length > 0 ? (
113+
<>
114+
<section
115+
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-12 mb-12"
116+
aria-labelledby="tagged-content-title"
117+
>
118+
<h2 class="sr-only" id="tagged-content-title">
119+
Tagged content
120+
</h2>
121+
{paginatedContent.map(item => {
122+
const href = `/articles/${item.id}`
123+
124+
return (
125+
<article class="group h-full relative after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent after:opacity-0 after:transition-opacity after:duration-150 after:ease-out focus-within:after:opacity-100 focus-within:after:-inset-1.5 focus-within:after:border-spotlight">
126+
<a
127+
href={href}
128+
class="block h-full bg-content-inverse rounded-xl shadow-lg transition-all duration-300 overflow-hidden border border-trim transform hover:-translate-y-2 no-underline hover:no-underline focus-visible:no-underline focus-visible:outline-none focus-visible:shadow-none"
129+
>
130+
{item.data.cover && (
131+
<div class="relative aspect-video overflow-hidden">
132+
<Picture
133+
src={item.data.cover}
134+
alt={item.data.coverAlt}
135+
widths={[320, 640, 960, 1280]}
136+
sizes="(min-width: 1280px) 33vw, (min-width: 768px) 50vw, 100vw"
137+
formats={['avif', 'webp', 'jpeg']}
138+
layout="constrained"
139+
fit="cover"
140+
position="center"
141+
class="absolute inset-0 h-full w-full"
142+
loading="lazy"
143+
/>
144+
</div>
145+
)}
146+
147+
<div class="p-6 space-y-4">
148+
<div class="flex items-center gap-3 text-sm">
149+
<span class="px-3 py-1 rounded-full text-white font-medium bg-primary">
150+
Article
151+
</span>
152+
<time datetime={item.data.publishDate.toISOString()}>
153+
{item.data.publishDate.toLocaleDateString('en-US', {
154+
year: 'numeric',
155+
month: 'long',
156+
day: 'numeric',
157+
})}
158+
</time>
159+
</div>
160+
161+
<h2 class="text-xl font-semibold mb-3 group-hover:text-primary transition-colors">
162+
{item.data.title}
163+
</h2>
164+
165+
{item.data.description && (
166+
<p class="text-content-offset leading-relaxed text-sm">
167+
{item.data.description}
168+
</p>
169+
)}
170+
<div class="mt-4 flex items-center text-primary text-sm font-medium opacity-0 group-hover:opacity-100 transition-opacity">
171+
<span>Learn more</span>
172+
<Icon
173+
icon="exit-right-thin"
174+
size={4}
175+
classes="ml-0.5 mb-0.5 transform group-hover:translate-x-1 transition-transform"
176+
/>
177+
</div>
178+
</div>
179+
</a>
180+
</article>
181+
)
182+
})}
183+
</section>
184+
185+
{totalPages > 1 && (
186+
<nav class="flex flex-wrap justify-center items-center gap-2" aria-label="Pagination">
187+
{currentPage > 1 && (
188+
<a
189+
href={buildTagPagePath(tag, currentPage - 1)}
190+
class="px-4 py-2 text-sm font-medium bg-content-inverse border border-trim rounded-lg hover:bg-page-offset transition-colors duration-200"
191+
>
192+
← Previous
193+
</a>
194+
)}
195+
196+
{currentPage > 3 && (
197+
<>
198+
<a
199+
href={buildTagPagePath(tag, 1)}
200+
class="px-3 py-2 text-sm font-medium bg-content-inverse border border-trim rounded-lg hover:bg-page-offset transition-colors duration-200"
201+
>
202+
1
203+
</a>
204+
{currentPage > 4 && <span class="px-3 py-2 text-sm">…</span>}
205+
</>
206+
)}
207+
208+
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
209+
const startPage = Math.max(1, Math.min(currentPage - 2, totalPages - 4))
210+
const page = startPage + i
211+
if (page > totalPages) return null
212+
213+
const isCurrentPage = page === currentPage
214+
return (
215+
<a
216+
href={buildTagPagePath(tag, page)}
217+
class={`px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200 ${
218+
isCurrentPage
219+
? 'bg-spotlight text-white'
220+
: 'bg-content-inverse border border-trim hover:bg-page-offset'
221+
}`}
222+
aria-current={isCurrentPage ? 'page' : undefined}
223+
>
224+
{page}
225+
</a>
226+
)
227+
})}
228+
229+
{currentPage < totalPages - 2 && (
230+
<>
231+
{currentPage < totalPages - 3 && <span class="px-3 py-2 text-sm">…</span>}
232+
<a
233+
href={buildTagPagePath(tag, totalPages)}
234+
class="px-3 py-2 text-sm font-medium bg-content-inverse border border-trim rounded-lg hover:bg-page-offset transition-colors duration-200"
235+
>
236+
{totalPages}
237+
</a>
238+
</>
239+
)}
240+
241+
{currentPage < totalPages && (
242+
<a
243+
href={buildTagPagePath(tag, currentPage + 1)}
244+
class="px-4 py-2 text-sm font-medium bg-content-inverse border border-trim rounded-lg hover:bg-page-offset transition-colors duration-200"
245+
>
246+
Next →
247+
</a>
248+
)}
249+
</nav>
250+
)}
251+
</>
252+
) : (
253+
<div class="text-center py-12">
254+
<p class="text-xl mb-4">No content found for this tag.</p>
255+
<a href="/tags" class="text-primary hover:underline">
256+
Browse other tags
257+
</a>
258+
</div>
259+
)
260+
}
261+
</div>
262+
</BaseLayout>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import {
4+
ITEMS_PER_PAGE,
5+
buildTagPagePath,
6+
getTagPageSlice,
7+
getTagTotalPages,
8+
} from '../pagination'
9+
10+
describe('tag pagination helpers', () => {
11+
it('builds static tag page paths without query params', () => {
12+
expect(buildTagPagePath('aws', 1)).toBe('/tags/aws')
13+
expect(buildTagPagePath('aws', 2)).toBe('/tags/aws/2')
14+
expect(buildTagPagePath('aws', 3)).toBe('/tags/aws/3')
15+
})
16+
17+
it('slices items by page number', () => {
18+
const items = Array.from({ length: ITEMS_PER_PAGE * 2 + 3 }, (_, index) => index + 1)
19+
20+
expect(getTagPageSlice(items, 1)).toEqual(items.slice(0, ITEMS_PER_PAGE))
21+
expect(getTagPageSlice(items, 2)).toEqual(items.slice(ITEMS_PER_PAGE, ITEMS_PER_PAGE * 2))
22+
expect(getTagPageSlice(items, 3)).toEqual(items.slice(ITEMS_PER_PAGE * 2))
23+
})
24+
25+
it('computes total pages from item count', () => {
26+
expect(getTagTotalPages(0)).toBe(0)
27+
expect(getTagTotalPages(1)).toBe(1)
28+
expect(getTagTotalPages(ITEMS_PER_PAGE)).toBe(1)
29+
expect(getTagTotalPages(ITEMS_PER_PAGE + 1)).toBe(2)
30+
})
31+
})

0 commit comments

Comments
 (0)