Skip to content

Commit a0b9886

Browse files
authored
Merge pull request #1049 from mbifulco/chore/prettier
Feat: #554 pagination on articles and newsletterss
2 parents a2d86de + 8ba4149 commit a0b9886

23 files changed

Lines changed: 1782 additions & 31 deletions

e2e/pagination.spec.ts

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
test.describe('Pagination', () => {
4+
test.describe('Home page pagination', () => {
5+
test('should display pagination on home page', async ({ page }) => {
6+
await page.goto('/');
7+
8+
// Check if pagination is present (only if there are multiple pages)
9+
const pagination = page.locator(
10+
'[role="navigation"][aria-label="pagination"]'
11+
);
12+
13+
// If pagination exists, test it
14+
if ((await pagination.count()) > 0) {
15+
await expect(pagination).toBeVisible();
16+
17+
// Check for next page link
18+
const nextLink = page.locator('a[aria-label="Go to next page"]');
19+
if ((await nextLink.count()) > 0) {
20+
await expect(nextLink).toBeVisible();
21+
}
22+
}
23+
});
24+
25+
test('should navigate to page 2 from home page', async ({ page }) => {
26+
await page.goto('/');
27+
28+
// Check if page 2 link exists (numbered page link, not next button)
29+
const page2Link = page
30+
.locator(
31+
'[role="navigation"][aria-label="pagination"] a[href="/page/2"]'
32+
)
33+
.filter({ hasText: '2' });
34+
if ((await page2Link.count()) > 0) {
35+
await page2Link.click();
36+
await expect(page).toHaveURL('/page/2');
37+
38+
// Should show either a previous button or page 1 link on page 2
39+
const prevLink = page.locator('a[aria-label="Go to previous page"]');
40+
const page1Link = page.locator(
41+
'[role="navigation"][aria-label="pagination"] a[href="/"]'
42+
);
43+
44+
// At least one way to go back should be available
45+
if ((await prevLink.count()) > 0) {
46+
await expect(prevLink).toBeVisible();
47+
await expect(prevLink).toHaveAttribute('href', '/');
48+
} else {
49+
await expect(page1Link).toBeVisible();
50+
}
51+
}
52+
});
53+
54+
test('should navigate back to home from page 2', async ({ page }) => {
55+
await page.goto('/page/2');
56+
57+
// Click either previous button or page 1 link to go back to home
58+
const prevLink = page.locator('a[aria-label="Go to previous page"]');
59+
const page1Link = page.locator(
60+
'[role="navigation"][aria-label="pagination"] a[href="/"]'
61+
);
62+
63+
if ((await prevLink.count()) > 0) {
64+
await prevLink.click();
65+
} else {
66+
await page1Link.click();
67+
}
68+
await expect(page).toHaveURL('/');
69+
});
70+
71+
test('should handle invalid page numbers with redirects', async ({
72+
page,
73+
}) => {
74+
// Test invalid page number
75+
const response = await page.goto('/page/999');
76+
// Should redirect to last page or home
77+
expect(response?.status()).toBe(200);
78+
79+
// Test non-numeric page
80+
const response2 = await page.goto('/page/abc');
81+
expect(response2?.status()).toBe(200);
82+
await expect(page).toHaveURL('/');
83+
});
84+
});
85+
86+
test.describe('Newsletter page pagination', () => {
87+
test('should display pagination on newsletter page', async ({ page }) => {
88+
await page.goto('/newsletter');
89+
90+
// Check if pagination is present
91+
const pagination = page.locator(
92+
'[role="navigation"][aria-label="pagination"]'
93+
);
94+
95+
if ((await pagination.count()) > 0) {
96+
await expect(pagination).toBeVisible();
97+
98+
// Check for next page link
99+
const nextLink = page.locator('a[aria-label="Go to next page"]');
100+
if ((await nextLink.count()) > 0) {
101+
await expect(nextLink).toBeVisible();
102+
}
103+
}
104+
});
105+
106+
test('should navigate to newsletter page 2', async ({ page }) => {
107+
await page.goto('/newsletter');
108+
109+
// Check if page 2 link exists (numbered page link, not next button)
110+
const page2Link = page
111+
.locator(
112+
'[role="navigation"][aria-label="pagination"] a[href="/newsletter/page/2"]'
113+
)
114+
.filter({ hasText: '2' });
115+
if ((await page2Link.count()) > 0) {
116+
await page2Link.click();
117+
await expect(page).toHaveURL('/newsletter/page/2');
118+
119+
// Should show either a previous button or page 1 link on page 2
120+
const prevLink = page.locator('a[aria-label="Go to previous page"]');
121+
const page1Link = page.locator(
122+
'[role="navigation"][aria-label="pagination"] a[href="/newsletter"]'
123+
);
124+
125+
// At least one way to go back should be available
126+
if ((await prevLink.count()) > 0) {
127+
await expect(prevLink).toBeVisible();
128+
await expect(prevLink).toHaveAttribute('href', '/newsletter');
129+
} else {
130+
await expect(page1Link).toBeVisible();
131+
}
132+
}
133+
});
134+
135+
test('should navigate back to newsletter home from page 2', async ({
136+
page,
137+
}) => {
138+
await page.goto('/newsletter/page/2');
139+
140+
// Click either previous button or page 1 link to go back to newsletter home
141+
const prevLink = page.locator('a[aria-label="Go to previous page"]');
142+
const page1Link = page.locator(
143+
'[role="navigation"][aria-label="pagination"] a[href="/newsletter"]'
144+
);
145+
146+
if ((await prevLink.count()) > 0) {
147+
await prevLink.click();
148+
} else {
149+
await page1Link.click();
150+
}
151+
await expect(page).toHaveURL('/newsletter');
152+
});
153+
154+
test('should handle invalid newsletter page numbers with redirects', async ({
155+
page,
156+
}) => {
157+
// Test invalid page number
158+
const response = await page.goto('/newsletter/page/999');
159+
expect(response?.status()).toBe(200);
160+
161+
// Test non-numeric page
162+
const response2 = await page.goto('/newsletter/page/abc');
163+
expect(response2?.status()).toBe(200);
164+
await expect(page).toHaveURL('/newsletter');
165+
});
166+
});
167+
168+
test.describe('Pagination accessibility', () => {
169+
test('should have proper ARIA labels', async ({ page }) => {
170+
await page.goto('/');
171+
172+
const pagination = page.locator(
173+
'[role="navigation"][aria-label="pagination"]'
174+
);
175+
if ((await pagination.count()) > 0) {
176+
await expect(pagination).toBeVisible();
177+
178+
// Check ARIA labels
179+
const prevLink = page.locator('a[aria-label="Go to previous page"]');
180+
const nextLink = page.locator('a[aria-label="Go to next page"]');
181+
182+
if ((await prevLink.count()) > 0) {
183+
await expect(prevLink).toBeVisible();
184+
}
185+
186+
if ((await nextLink.count()) > 0) {
187+
await expect(nextLink).toBeVisible();
188+
}
189+
}
190+
});
191+
192+
test('should have proper aria-current on active page', async ({ page }) => {
193+
await page.goto('/page/2');
194+
195+
// Find the active page link
196+
const activePageLink = page.locator('a[aria-current="page"]');
197+
if ((await activePageLink.count()) > 0) {
198+
await expect(activePageLink).toBeVisible();
199+
await expect(activePageLink).toContainText('2');
200+
}
201+
});
202+
});
203+
204+
test.describe('Pagination SEO', () => {
205+
test('should have clean URLs for pagination', async ({ page }) => {
206+
await page.goto('/');
207+
208+
// Check that pagination links use clean URLs (numbered page link, not next button)
209+
const page2Link = page
210+
.locator(
211+
'[role="navigation"][aria-label="pagination"] a[href="/page/2"]'
212+
)
213+
.filter({ hasText: '2' });
214+
if ((await page2Link.count()) > 0) {
215+
await expect(page2Link).toBeVisible();
216+
217+
// Navigate and check URL
218+
await page2Link.click();
219+
await expect(page).toHaveURL('/page/2');
220+
}
221+
});
222+
223+
test('should redirect /page to home', async ({ page }) => {
224+
const response = await page.goto('/page');
225+
expect(response?.status()).toBe(200);
226+
await expect(page).toHaveURL('/');
227+
});
228+
229+
test('should redirect /newsletter/page to newsletter', async ({ page }) => {
230+
const response = await page.goto('/newsletter/page');
231+
expect(response?.status()).toBe(200);
232+
await expect(page).toHaveURL('/newsletter');
233+
});
234+
235+
test('should redirect /page/1 to home', async ({ page }) => {
236+
const response = await page.goto('/page/1');
237+
expect(response?.status()).toBe(200);
238+
await expect(page).toHaveURL('/');
239+
});
240+
241+
test('should redirect /newsletter/page/1 to newsletter', async ({
242+
page,
243+
}) => {
244+
const response = await page.goto('/newsletter/page/1');
245+
expect(response?.status()).toBe(200);
246+
await expect(page).toHaveURL('/newsletter');
247+
});
248+
});
249+
250+
test.describe('Pagination performance', () => {
251+
test('should load paginated pages quickly', async ({ page }) => {
252+
const start = Date.now();
253+
await page.goto('/page/2');
254+
const loadTime = Date.now() - start;
255+
256+
// Should load within 10 seconds (generous for CI)
257+
expect(loadTime).toBeLessThan(10000);
258+
});
259+
260+
test('should prefetch pagination links', async ({ page }) => {
261+
await page.goto('/');
262+
263+
// Check if next page link exists and is prefetchable (numbered page link, not next button)
264+
const nextLink = page
265+
.locator(
266+
'[role="navigation"][aria-label="pagination"] a[href="/page/2"]'
267+
)
268+
.filter({ hasText: '2' });
269+
if ((await nextLink.count()) > 0) {
270+
await expect(nextLink).toBeVisible();
271+
// In a real app, you might check for prefetch attributes
272+
}
273+
});
274+
});
275+
});

next.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ const jiti = createJiti(new URL(import.meta.url).pathname);
55

66
await jiti.import('./src/utils/env');
77

8+
// Import centralized pagination redirect logic
9+
const { generatePaginationConfigRedirects } = await jiti.import('./src/utils/pagination-redirects');
10+
811
// Redirect legacy post paths to the new pattern
912
const oldPostPaths = [
1013
'/why-fathom-analytics',
@@ -90,6 +93,7 @@ const config = {
9093
destination: '/newsletter',
9194
permanent: false,
9295
},
96+
...generatePaginationConfigRedirects(),
9397
...postRedirects,
9498
],
9599
rewrites: async () => [

package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@mdx-js/react": "^3.1.0",
3030
"@next/bundle-analyzer": "^15.4.1",
3131
"@number-flow/react": "^0.5.10",
32+
"@radix-ui/react-slot": "^1.2.3",
3233
"@react-email/components": "^0.3.1",
3334
"@react-email/render": "^1.1.3",
3435
"@t3-oss/env-nextjs": "^0.13.8",
@@ -39,6 +40,7 @@
3940
"@trpc/react-query": "11.4.3",
4041
"@trpc/server": "11.4.3",
4142
"@types/mdx": "^2.0.13",
43+
"class-variance-authority": "^0.7.1",
4244
"clsx": "^2.1.1",
4345
"date-fns": "^4.1.0",
4446
"fathom-client": "^3.7.2",
@@ -52,12 +54,12 @@
5254
"pluralize": "^8.0.0",
5355
"postcss": "^8.5.6",
5456
"posthog-js": "^1.257.0",
55-
"posthog-node": "^5.5.0",
57+
"posthog-node": "^5.5.1",
5658
"prism-react-renderer": "^2.4.1",
5759
"react": "19.1.0",
5860
"react-aria": "^3.41.1",
5961
"react-dom": "19.1.0",
60-
"react-email": "4.1.3",
62+
"react-email": "4.2.1",
6163
"react-icons": "^5.5.0",
6264
"react-stately": "^3.39.0",
6365
"rehype-img-size": "^1.0.1",
@@ -79,6 +81,8 @@
7981
"@next/eslint-plugin-next": "^15.4.1",
8082
"@playwright/test": "^1.54.1",
8183
"@tailwindcss/postcss": "^4.1.11",
84+
"@testing-library/jest-dom": "^6.6.3",
85+
"@testing-library/react": "^16.3.0",
8286
"@types/eslint": "^9.6.1",
8387
"@types/node": "^24.0.14",
8488
"@types/pluralize": "^0.0.33",
@@ -90,7 +94,7 @@
9094
"eslint": "9.31.0",
9195
"eslint-config-next": "^15.4.1",
9296
"eslint-config-prettier": "^10.1.5",
93-
"eslint-config-turbo": "^2.5.4",
97+
"eslint-config-turbo": "^2.5.5",
9498
"eslint-plugin-import": "^2.32.0",
9599
"eslint-plugin-jsx-a11y": "^6.10.2",
96100
"eslint-plugin-react": "^7.37.5",

0 commit comments

Comments
 (0)