Skip to content

Commit e0fca0c

Browse files
authored
Merge pull request #668 from webstackdev/maintenance/refactor-tags-page-to-static-pagination
Maintenance/refactor tags page to static pagination
2 parents 26ef8ae + c8970b5 commit e0fca0c

13 files changed

Lines changed: 532 additions & 424 deletions

File tree

.cache/pages.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,28 +103,36 @@
103103
"apis-and-gateways",
104104
"argo-cd",
105105
"aws",
106+
"aws/2",
107+
"aws/3",
106108
"azure",
107109
"backstage-idp",
108110
"build-and-deploy",
109111
"cloud-platforms",
112+
"cloud-platforms/2",
110113
"crossplane",
111114
"docker",
112115
"dotnet",
113116
"go",
114117
"grafana",
115118
"helm",
116119
"kubernetes",
120+
"kubernetes/2",
121+
"kubernetes/3",
117122
"observability-and-telemetry",
118123
"openstack",
119124
"platform-engineering",
120125
"prometheus",
126+
"prometheus/2",
121127
"python",
128+
"python/2",
122129
"reliability-and-testing",
123130
"ruby",
124131
"system-modernization",
125132
"systems-and-development",
126133
"terraform",
127-
"typescript"
134+
"typescript",
135+
"typescript/2"
128136
]
129137
},
130138
"terms"

_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.

src/actions/gdpr/@types/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export interface ConsentResponse {
5858
record: ConsentRecord
5959
}
6060

61-
export const CONSENT_PURPOSES = ['contact', 'marketing', 'analytics', 'downloads'] as const
61+
export const CONSENT_PURPOSES = ['contact', 'marketing', 'analytics', 'functional', 'downloads'] as const
6262

6363
export type ConsentPurpose = (typeof CONSENT_PURPOSES)[number]
6464

src/actions/gdpr/__tests__/constants.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CONSENT_PURPOSES, CONSENT_SOURCES } from '../constants'
55
describe('gdpr constants', () => {
66
it('exposes expected consent purposes', () => {
77
expect(CONSENT_PURPOSES).toContain('contact')
8+
expect(CONSENT_PURPOSES).toContain('functional')
89
expect(CONSENT_PURPOSES).toContain('downloads')
910
})
1011

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>

src/components/scripts/sentry/__tests__/helpers.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const createContactSubmitHttpErrorEvent = (): Parameters<typeof beforeSendHandle
5757
const createConsentRateLimitHttpErrorEvent = (): Parameters<typeof beforeSendHandler>[0] =>
5858
({
5959
type: 'error',
60-
request: { url: 'https://www.webstackbuilders.com/_actions/gdpr.consentCreate' },
60+
request: { url: 'https://www.webstackbuilders.com/_actions/gdpr/consentCreate' },
6161
exception: {
6262
values: [
6363
{

src/components/scripts/sentry/helpers.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ type BeforeSendHandler = NonNullable<BrowserOptions['beforeSend']>
66

77
const SAFE_BREADCRUMB_CATEGORIES = new Set(['script', 'sentry.event'])
88

9+
const isConsentActionRequest = (requestUrl: string): boolean => {
10+
return (
11+
requestUrl.includes('/_actions/gdpr.consentCreate') ||
12+
requestUrl.includes('/_actions/gdpr/consentCreate')
13+
)
14+
}
15+
916
const isHandledContactSubmitHttpError = (event: Parameters<BeforeSendHandler>[0]): boolean => {
1017
const requestUrl = event.request?.url
1118
const exception = event.exception?.values?.[0]
@@ -29,7 +36,7 @@ const isHandledConsentRateLimitHttpError = (event: Parameters<BeforeSendHandler>
2936

3037
return (
3138
typeof requestUrl === 'string' &&
32-
requestUrl.includes('/_actions/gdpr.consentCreate') &&
39+
isConsentActionRequest(requestUrl) &&
3340
mechanismType === 'auto.http.client.fetch' &&
3441
typeof errorMessage === 'string' &&
3542
errorMessage.includes('HTTP Client Error with status code: 429')

0 commit comments

Comments
 (0)