@fullstackdatasolutions/articles 0.11.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +568 -6
- package/dist/index.cjs +970 -389
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +375 -21
- package/dist/index.d.ts +375 -21
- package/dist/index.js +954 -377
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +74 -6
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +141 -0
- package/dist/nextjs.d.ts +141 -0
- package/dist/nextjs.js +74 -6
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +665 -27
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +349 -3
- package/dist/server.d.ts +349 -3
- package/dist/server.js +643 -29
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleCard.tsx +37 -1
- package/src/ArticleContent.tsx +144 -5
- package/src/ArticleDetailHero.tsx +23 -0
- package/src/ArticleNavigation.tsx +32 -1
- package/src/ArticleSchemas.tsx +43 -39
- package/src/ArticleSocialShare.tsx +54 -10
- package/src/ArticlesPage.tsx +65 -7
- package/src/AuthorArticlesPage.tsx +308 -14
- package/src/AuthorCard.tsx +1 -1
- package/src/CategoryArticlesPage.tsx +34 -2
- package/src/LatestArticles.tsx +28 -1
- package/src/LatestArticlesSection.tsx +15 -1
- package/src/PaginationNav.tsx +78 -0
- package/src/RelatedArticlesSection.tsx +55 -0
- package/src/SeriesArticlesPage.tsx +66 -0
- package/src/__tests__/ArticleCard.test.tsx +63 -3
- package/src/__tests__/ArticleContent.test.tsx +143 -0
- package/src/__tests__/ArticleDetailHero.test.tsx +30 -0
- package/src/__tests__/ArticleNavigation.test.tsx +81 -3
- package/src/__tests__/ArticleSchemas.test.tsx +155 -81
- package/src/__tests__/ArticleSocialShare.test.tsx +54 -0
- package/src/__tests__/ArticlesPage.test.tsx +148 -0
- package/src/__tests__/AuthorArticlesPage.test.tsx +304 -3
- package/src/__tests__/CategoryArticlesPage.test.tsx +116 -1
- package/src/__tests__/LatestArticles.test.tsx +52 -0
- package/src/__tests__/LatestArticlesSection.test.tsx +28 -0
- package/src/__tests__/PaginationNav.test.tsx +73 -0
- package/src/__tests__/RelatedArticlesSection.test.tsx +132 -0
- package/src/__tests__/SeriesArticlesPage.test.tsx +121 -0
- package/src/__tests__/eventTracking.test.tsx +145 -0
- package/src/__tests__/events.test.ts +82 -0
- package/src/__tests__/markdown.test.ts +78 -1
- package/src/__tests__/pagination.test.ts +178 -0
- package/src/__tests__/seoUtils-authors.test.ts +37 -0
- package/src/__tests__/seoUtils.test.ts +246 -0
- package/src/__tests__/server-articles.test.ts +356 -1
- package/src/__tests__/useArticles.test.ts +28 -0
- package/src/__tests__/validateArticles.test.ts +312 -0
- package/src/articleTypes.ts +109 -0
- package/src/articlesConfig.ts +37 -1
- package/src/eventTracking.tsx +97 -0
- package/src/events.ts +105 -0
- package/src/index.ts +26 -1
- package/src/markdown.ts +41 -0
- package/src/pagination.ts +93 -0
- package/src/seoUtils.ts +198 -11
- package/src/server-articles.ts +199 -6
- package/src/server.ts +46 -2
- package/src/useArticles.ts +10 -5
- package/src/validateArticles.ts +260 -0
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react'
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
2
|
import type React from 'react'
|
|
3
3
|
import { AuthorArticlesPage } from '../AuthorArticlesPage'
|
|
4
4
|
|
|
5
5
|
jest.mock('next/link', () => ({
|
|
6
6
|
__esModule: true,
|
|
7
|
-
default: ({
|
|
8
|
-
|
|
7
|
+
default: ({
|
|
8
|
+
href,
|
|
9
|
+
children,
|
|
10
|
+
...rest
|
|
11
|
+
}: { href: string; children: React.ReactNode } & Record<string, unknown>) => (
|
|
12
|
+
<a href={href} {...rest}>
|
|
13
|
+
{children}
|
|
14
|
+
</a>
|
|
9
15
|
),
|
|
10
16
|
}))
|
|
11
17
|
|
|
@@ -70,5 +76,300 @@ describe('AuthorArticlesPage', () => {
|
|
|
70
76
|
sameAs: ['https://fullstackdatasolutions.com'],
|
|
71
77
|
})
|
|
72
78
|
)
|
|
79
|
+
|
|
80
|
+
const collectionPage = schemas.find((schema) => schema['@type'] === 'CollectionPage')
|
|
81
|
+
expect(collectionPage).toEqual(
|
|
82
|
+
expect.objectContaining({
|
|
83
|
+
name: 'Andrew Blase | Example',
|
|
84
|
+
url: 'https://example.com/articles/authors/andrew-blase',
|
|
85
|
+
numberOfItems: 1,
|
|
86
|
+
mainEntity: {
|
|
87
|
+
'@type': 'ItemList',
|
|
88
|
+
itemListElement: [
|
|
89
|
+
{
|
|
90
|
+
'@type': 'ListItem',
|
|
91
|
+
position: 1,
|
|
92
|
+
url: 'https://example.com/articles/test-article',
|
|
93
|
+
name: 'Test Article',
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('listingPagination: "pages" mode', () => {
|
|
102
|
+
const pagesConfig = { ...config, listingPagination: 'pages' as const }
|
|
103
|
+
|
|
104
|
+
it('does not render PaginationNav when config.listingPagination is unset, even with page/totalPages', () => {
|
|
105
|
+
render(
|
|
106
|
+
<AuthorArticlesPage
|
|
107
|
+
author={author}
|
|
108
|
+
articles={articles}
|
|
109
|
+
config={config}
|
|
110
|
+
page={2}
|
|
111
|
+
totalPages={4}
|
|
112
|
+
/>
|
|
113
|
+
)
|
|
114
|
+
expect(screen.queryByText(/Page \d+ of \d+/)).not.toBeInTheDocument()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('renders PaginationNav built from the author slug when config.listingPagination is "pages"', () => {
|
|
118
|
+
render(
|
|
119
|
+
<AuthorArticlesPage
|
|
120
|
+
author={author}
|
|
121
|
+
articles={articles}
|
|
122
|
+
config={pagesConfig}
|
|
123
|
+
page={2}
|
|
124
|
+
totalPages={4}
|
|
125
|
+
/>
|
|
126
|
+
)
|
|
127
|
+
expect(screen.getByText('Page 2 of 4')).toBeInTheDocument()
|
|
128
|
+
expect(screen.getByRole('link', { name: /next/i })).toHaveAttribute(
|
|
129
|
+
'href',
|
|
130
|
+
'/articles/authors/andrew-blase/page/3'
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('uses totalCount for the Person interactionStatistic and CollectionPage articleCount', () => {
|
|
135
|
+
const { container } = render(
|
|
136
|
+
<AuthorArticlesPage
|
|
137
|
+
author={author}
|
|
138
|
+
articles={articles}
|
|
139
|
+
config={pagesConfig}
|
|
140
|
+
page={1}
|
|
141
|
+
totalPages={5}
|
|
142
|
+
totalCount={9}
|
|
143
|
+
/>
|
|
144
|
+
)
|
|
145
|
+
const schemas = parseSchemaScript(container)
|
|
146
|
+
const person = schemas.find((schema) => schema['@type'] === 'Person')
|
|
147
|
+
const collectionPage = schemas.find((schema) => schema['@type'] === 'CollectionPage')
|
|
148
|
+
expect(person?.interactionStatistic).toEqual(
|
|
149
|
+
expect.objectContaining({ userInteractionCount: 9 })
|
|
150
|
+
)
|
|
151
|
+
expect(collectionPage?.numberOfItems).toBe(9)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('falls back to articles.length when totalCount is omitted', () => {
|
|
155
|
+
const { container } = render(
|
|
156
|
+
<AuthorArticlesPage
|
|
157
|
+
author={author}
|
|
158
|
+
articles={articles}
|
|
159
|
+
config={pagesConfig}
|
|
160
|
+
page={1}
|
|
161
|
+
totalPages={5}
|
|
162
|
+
/>
|
|
163
|
+
)
|
|
164
|
+
const schemas = parseSchemaScript(container)
|
|
165
|
+
const collectionPage = schemas.find((schema) => schema['@type'] === 'CollectionPage')
|
|
166
|
+
expect(collectionPage?.numberOfItems).toBe(1)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('rich AuthorProfile fields (Phase 27E)', () => {
|
|
171
|
+
const richAuthor = {
|
|
172
|
+
...author,
|
|
173
|
+
promise: 'Helping new GMs run confident first sessions.',
|
|
174
|
+
servesWho: ['New game masters', 'Streaming DMs'],
|
|
175
|
+
originStory: [
|
|
176
|
+
{ heading: 'How it started', paragraphs: ['Ran my first session in 2010.'] },
|
|
177
|
+
{ paragraphs: ['Never looked back.'] },
|
|
178
|
+
],
|
|
179
|
+
principles: ['Fun first', 'Prep less, improvise more'],
|
|
180
|
+
credentials: ['10+ years running published campaigns'],
|
|
181
|
+
proof: [
|
|
182
|
+
{ claim: '500+ sessions run', source: 'Personal log' },
|
|
183
|
+
{ claim: 'Featured on a podcast', url: 'https://example.com/podcast' },
|
|
184
|
+
],
|
|
185
|
+
primaryCta: { label: 'Book a session', href: '/book' },
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
it('does not render any new section when sections is omitted, even with a fully populated profile', () => {
|
|
189
|
+
const { container } = render(
|
|
190
|
+
<AuthorArticlesPage author={richAuthor} articles={articles} config={config} />
|
|
191
|
+
)
|
|
192
|
+
expect(screen.queryByText(richAuthor.promise)).not.toBeInTheDocument()
|
|
193
|
+
expect(screen.queryByText('Who I help')).not.toBeInTheDocument()
|
|
194
|
+
expect(screen.queryByText('My story')).not.toBeInTheDocument()
|
|
195
|
+
expect(screen.queryByText('What I believe')).not.toBeInTheDocument()
|
|
196
|
+
expect(screen.queryByText('Proof')).not.toBeInTheDocument()
|
|
197
|
+
expect(screen.queryByRole('link', { name: 'Book a session' })).not.toBeInTheDocument()
|
|
198
|
+
expect(container.querySelector('[aria-label="Author promise"]')).not.toBeInTheDocument()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('legacy profile (no new fields) renders identically whether or not sections is passed with only "articles"', () => {
|
|
202
|
+
const legacy = render(
|
|
203
|
+
<AuthorArticlesPage author={author} articles={articles} config={config} />
|
|
204
|
+
)
|
|
205
|
+
expect(legacy.getByText('Articles by Andrew Blase')).toBeInTheDocument()
|
|
206
|
+
legacy.unmount()
|
|
207
|
+
|
|
208
|
+
const withSections = render(
|
|
209
|
+
<AuthorArticlesPage
|
|
210
|
+
author={author}
|
|
211
|
+
articles={articles}
|
|
212
|
+
config={config}
|
|
213
|
+
sections={['articles']}
|
|
214
|
+
/>
|
|
215
|
+
)
|
|
216
|
+
expect(withSections.getByText('Articles by Andrew Blase')).toBeInTheDocument()
|
|
217
|
+
expect(withSections.getByRole('link', { name: 'Test Article' })).toHaveAttribute(
|
|
218
|
+
'href',
|
|
219
|
+
'/articles/test-article'
|
|
220
|
+
)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('renders only the requested sections, in the requested order', () => {
|
|
224
|
+
render(
|
|
225
|
+
<AuthorArticlesPage
|
|
226
|
+
author={richAuthor}
|
|
227
|
+
articles={articles}
|
|
228
|
+
config={config}
|
|
229
|
+
sections={['promise', 'hero']}
|
|
230
|
+
/>
|
|
231
|
+
)
|
|
232
|
+
expect(screen.getByText(richAuthor.promise)).toBeInTheDocument()
|
|
233
|
+
expect(screen.getByRole('heading', { name: 'Andrew Blase' })).toBeInTheDocument()
|
|
234
|
+
expect(screen.queryByText('Who I help')).not.toBeInTheDocument()
|
|
235
|
+
expect(screen.queryByRole('link', { name: 'Test Article' })).not.toBeInTheDocument()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('omits a listed section with no backing data', () => {
|
|
239
|
+
render(
|
|
240
|
+
<AuthorArticlesPage
|
|
241
|
+
author={author}
|
|
242
|
+
articles={articles}
|
|
243
|
+
config={config}
|
|
244
|
+
sections={['promise', 'servesWho', 'originStory', 'principles', 'proof', 'cta']}
|
|
245
|
+
/>
|
|
246
|
+
)
|
|
247
|
+
expect(screen.queryByText('Who I help')).not.toBeInTheDocument()
|
|
248
|
+
expect(screen.queryByText('My story')).not.toBeInTheDocument()
|
|
249
|
+
expect(screen.queryByText('What I believe')).not.toBeInTheDocument()
|
|
250
|
+
expect(screen.queryByText('Proof')).not.toBeInTheDocument()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('renders servesWho, originStory, principles, and proof with accessible landmarks', () => {
|
|
254
|
+
const { container } = render(
|
|
255
|
+
<AuthorArticlesPage
|
|
256
|
+
author={richAuthor}
|
|
257
|
+
articles={articles}
|
|
258
|
+
config={config}
|
|
259
|
+
sections={['servesWho', 'originStory', 'principles', 'proof']}
|
|
260
|
+
/>
|
|
261
|
+
)
|
|
262
|
+
expect(screen.getByRole('heading', { name: 'Who I help' })).toBeInTheDocument()
|
|
263
|
+
expect(screen.getByText('New game masters')).toBeInTheDocument()
|
|
264
|
+
expect(screen.getByText('Streaming DMs')).toBeInTheDocument()
|
|
265
|
+
|
|
266
|
+
expect(screen.getByRole('heading', { name: 'My story' })).toBeInTheDocument()
|
|
267
|
+
expect(screen.getByRole('heading', { name: 'How it started' })).toBeInTheDocument()
|
|
268
|
+
expect(screen.getByText('Ran my first session in 2010.')).toBeInTheDocument()
|
|
269
|
+
expect(screen.getByText('Never looked back.')).toBeInTheDocument()
|
|
270
|
+
|
|
271
|
+
expect(screen.getByRole('heading', { name: 'What I believe' })).toBeInTheDocument()
|
|
272
|
+
expect(screen.getByText('Fun first')).toBeInTheDocument()
|
|
273
|
+
|
|
274
|
+
expect(screen.getByRole('heading', { name: 'Proof' })).toBeInTheDocument()
|
|
275
|
+
expect(screen.getByText('500+ sessions run')).toBeInTheDocument()
|
|
276
|
+
expect(screen.getByText('Personal log')).toBeInTheDocument()
|
|
277
|
+
expect(screen.getByRole('link', { name: 'https://example.com/podcast' })).toHaveAttribute(
|
|
278
|
+
'href',
|
|
279
|
+
'https://example.com/podcast'
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
expect(container.querySelector('[aria-label="Who this author serves"]')).toBeInTheDocument()
|
|
283
|
+
expect(container.querySelector('[aria-label="Author origin story"]')).toBeInTheDocument()
|
|
284
|
+
expect(container.querySelector('[aria-label="Author principles"]')).toBeInTheDocument()
|
|
285
|
+
expect(container.querySelector('[aria-label="Author proof"]')).toBeInTheDocument()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('renders primaryCta as a link with the configured label and href', () => {
|
|
289
|
+
render(
|
|
290
|
+
<AuthorArticlesPage
|
|
291
|
+
author={richAuthor}
|
|
292
|
+
articles={articles}
|
|
293
|
+
config={config}
|
|
294
|
+
sections={['cta']}
|
|
295
|
+
/>
|
|
296
|
+
)
|
|
297
|
+
expect(screen.getByRole('link', { name: 'Book a session' })).toHaveAttribute('href', '/book')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('fires cta_clicked with a ctaId when the primaryCta link is clicked (Phase 27F)', () => {
|
|
301
|
+
const onEvent = jest.fn()
|
|
302
|
+
render(
|
|
303
|
+
<AuthorArticlesPage
|
|
304
|
+
author={richAuthor}
|
|
305
|
+
articles={articles}
|
|
306
|
+
config={{ ...config, onEvent }}
|
|
307
|
+
sections={['cta']}
|
|
308
|
+
/>
|
|
309
|
+
)
|
|
310
|
+
fireEvent.click(screen.getByRole('link', { name: 'Book a session' }))
|
|
311
|
+
expect(onEvent).toHaveBeenCalledWith(
|
|
312
|
+
expect.objectContaining({ name: 'cta_clicked', ctaId: 'author-cta:andrew-blase' })
|
|
313
|
+
)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('fires cta_viewed for the primaryCta section (Phase 27F, no IntersectionObserver in jsdom falls back to immediate)', () => {
|
|
317
|
+
const onEvent = jest.fn()
|
|
318
|
+
render(
|
|
319
|
+
<AuthorArticlesPage
|
|
320
|
+
author={richAuthor}
|
|
321
|
+
articles={articles}
|
|
322
|
+
config={{ ...config, onEvent }}
|
|
323
|
+
sections={['cta']}
|
|
324
|
+
/>
|
|
325
|
+
)
|
|
326
|
+
expect(onEvent).toHaveBeenCalledWith(
|
|
327
|
+
expect.objectContaining({ name: 'cta_viewed', ctaId: 'author-cta:andrew-blase' })
|
|
328
|
+
)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('renders the custom section slot only when "custom" is listed, using customSection', () => {
|
|
332
|
+
const { rerender } = render(
|
|
333
|
+
<AuthorArticlesPage
|
|
334
|
+
author={author}
|
|
335
|
+
articles={articles}
|
|
336
|
+
config={config}
|
|
337
|
+
sections={['articles']}
|
|
338
|
+
customSection={<div>Custom block</div>}
|
|
339
|
+
/>
|
|
340
|
+
)
|
|
341
|
+
expect(screen.queryByText('Custom block')).not.toBeInTheDocument()
|
|
342
|
+
|
|
343
|
+
rerender(
|
|
344
|
+
<AuthorArticlesPage
|
|
345
|
+
author={author}
|
|
346
|
+
articles={articles}
|
|
347
|
+
config={config}
|
|
348
|
+
sections={['custom']}
|
|
349
|
+
customSection={<div>Custom block</div>}
|
|
350
|
+
/>
|
|
351
|
+
)
|
|
352
|
+
expect(screen.getByText('Custom block')).toBeInTheDocument()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('excludes credentials, servesWho, principles, proof, and promise from the Person JSON-LD', () => {
|
|
356
|
+
const { container } = render(
|
|
357
|
+
<AuthorArticlesPage author={richAuthor} articles={articles} config={config} />
|
|
358
|
+
)
|
|
359
|
+
const schemas = parseSchemaScript(container)
|
|
360
|
+
const person = schemas.find((schema) => schema['@type'] === 'Person')
|
|
361
|
+
expect(person).toBeDefined()
|
|
362
|
+
const serialized = JSON.stringify(person)
|
|
363
|
+
expect(serialized).not.toContain('Helping new GMs')
|
|
364
|
+
expect(serialized).not.toContain('10+ years running')
|
|
365
|
+
expect(serialized).not.toContain('New game masters')
|
|
366
|
+
expect(serialized).not.toContain('Fun first')
|
|
367
|
+
expect(serialized).not.toContain('500+ sessions run')
|
|
368
|
+
expect(person).toEqual(
|
|
369
|
+
expect.objectContaining({
|
|
370
|
+
description: 'Writer focused on civic technology.',
|
|
371
|
+
})
|
|
372
|
+
)
|
|
373
|
+
})
|
|
73
374
|
})
|
|
74
375
|
})
|
|
@@ -30,8 +30,19 @@ jest.mock('next/image', () => ({
|
|
|
30
30
|
}))
|
|
31
31
|
|
|
32
32
|
jest.mock('../LatestArticles', () => ({
|
|
33
|
-
LatestArticles: ({
|
|
33
|
+
LatestArticles: ({
|
|
34
|
+
articles,
|
|
35
|
+
pagination,
|
|
36
|
+
}: {
|
|
37
|
+
articles: Article[]
|
|
38
|
+
pagination?: { page: number; totalPages: number; basePath: string }
|
|
39
|
+
}) => (
|
|
34
40
|
<div data-testid="latest-articles">
|
|
41
|
+
{pagination && (
|
|
42
|
+
<div data-testid="pagination-context">
|
|
43
|
+
{`page=${pagination.page} totalPages=${pagination.totalPages} basePath=${pagination.basePath}`}
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
35
46
|
{articles.map((a) => (
|
|
36
47
|
<div key={a.slug} data-testid="article-card">
|
|
37
48
|
{a.title}
|
|
@@ -179,4 +190,108 @@ describe('CategoryArticlesPage', () => {
|
|
|
179
190
|
)
|
|
180
191
|
expect(screen.getByText('Browse all articles in Campaigns.')).toBeInTheDocument()
|
|
181
192
|
})
|
|
193
|
+
|
|
194
|
+
it('renders a CollectionPage schema with an ItemList of the articles', () => {
|
|
195
|
+
const { container } = render(
|
|
196
|
+
<CategoryArticlesPage
|
|
197
|
+
category="campaigns"
|
|
198
|
+
articles={[
|
|
199
|
+
makeArticle({ slug: 'a1', title: 'Article One', category: 'Campaigns' }),
|
|
200
|
+
makeArticle({ slug: 'a2', title: 'Article Two', category: 'Campaigns' }),
|
|
201
|
+
]}
|
|
202
|
+
config={baseConfig}
|
|
203
|
+
/>
|
|
204
|
+
)
|
|
205
|
+
const script = container.querySelector('script[type="application/ld+json"]')
|
|
206
|
+
expect(script).not.toBeNull()
|
|
207
|
+
const schema = JSON.parse(script!.innerHTML)
|
|
208
|
+
expect(schema).toEqual(
|
|
209
|
+
expect.objectContaining({
|
|
210
|
+
'@type': 'CollectionPage',
|
|
211
|
+
name: 'Campaigns | Test Site',
|
|
212
|
+
url: 'https://example.com/articles/category/campaigns',
|
|
213
|
+
numberOfItems: 2,
|
|
214
|
+
mainEntity: {
|
|
215
|
+
'@type': 'ItemList',
|
|
216
|
+
itemListElement: [
|
|
217
|
+
{
|
|
218
|
+
'@type': 'ListItem',
|
|
219
|
+
position: 1,
|
|
220
|
+
url: 'https://example.com/articles/a1',
|
|
221
|
+
name: 'Article One',
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
'@type': 'ListItem',
|
|
225
|
+
position: 2,
|
|
226
|
+
url: 'https://example.com/articles/a2',
|
|
227
|
+
name: 'Article Two',
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
describe('listingPagination: "pages" mode', () => {
|
|
236
|
+
const pagesConfig: ArticlesConfig = { ...baseConfig, listingPagination: 'pages' }
|
|
237
|
+
|
|
238
|
+
it('does not pass pagination through when config.listingPagination is unset, even with page/totalPages', () => {
|
|
239
|
+
render(
|
|
240
|
+
<CategoryArticlesPage
|
|
241
|
+
category="campaigns"
|
|
242
|
+
articles={[makeArticle()]}
|
|
243
|
+
config={baseConfig}
|
|
244
|
+
page={2}
|
|
245
|
+
totalPages={4}
|
|
246
|
+
/>
|
|
247
|
+
)
|
|
248
|
+
expect(screen.queryByTestId('pagination-context')).not.toBeInTheDocument()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('passes a pagination context built from category/page/totalPages down to LatestArticles', () => {
|
|
252
|
+
render(
|
|
253
|
+
<CategoryArticlesPage
|
|
254
|
+
category="campaigns"
|
|
255
|
+
articles={[makeArticle()]}
|
|
256
|
+
config={pagesConfig}
|
|
257
|
+
page={2}
|
|
258
|
+
totalPages={4}
|
|
259
|
+
/>
|
|
260
|
+
)
|
|
261
|
+
expect(screen.getByTestId('pagination-context')).toHaveTextContent(
|
|
262
|
+
'page=2 totalPages=4 basePath=/articles/category/campaigns'
|
|
263
|
+
)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('uses totalCount for the CollectionPage schema articleCount when provided', () => {
|
|
267
|
+
const { container } = render(
|
|
268
|
+
<CategoryArticlesPage
|
|
269
|
+
category="campaigns"
|
|
270
|
+
articles={[makeArticle({ category: 'Campaigns' })]}
|
|
271
|
+
config={pagesConfig}
|
|
272
|
+
page={1}
|
|
273
|
+
totalPages={5}
|
|
274
|
+
totalCount={17}
|
|
275
|
+
/>
|
|
276
|
+
)
|
|
277
|
+
const script = container.querySelector('script[type="application/ld+json"]')
|
|
278
|
+
const schema = JSON.parse(script!.innerHTML)
|
|
279
|
+
expect(schema.numberOfItems).toBe(17)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('falls back to articles.length for articleCount when totalCount is omitted', () => {
|
|
283
|
+
const { container } = render(
|
|
284
|
+
<CategoryArticlesPage
|
|
285
|
+
category="campaigns"
|
|
286
|
+
articles={[makeArticle({ category: 'Campaigns' })]}
|
|
287
|
+
config={pagesConfig}
|
|
288
|
+
page={1}
|
|
289
|
+
totalPages={5}
|
|
290
|
+
/>
|
|
291
|
+
)
|
|
292
|
+
const script = container.querySelector('script[type="application/ld+json"]')
|
|
293
|
+
const schema = JSON.parse(script!.innerHTML)
|
|
294
|
+
expect(schema.numberOfItems).toBe(1)
|
|
295
|
+
})
|
|
296
|
+
})
|
|
182
297
|
})
|
|
@@ -154,4 +154,56 @@ describe('LatestArticles', () => {
|
|
|
154
154
|
}
|
|
155
155
|
expect(screen.queryByRole('button', { name: 'Load more articles' })).not.toBeInTheDocument()
|
|
156
156
|
})
|
|
157
|
+
|
|
158
|
+
describe('pagination prop (listingPagination: "pages" mode)', () => {
|
|
159
|
+
it('renders every given article without slicing to pageSize', () => {
|
|
160
|
+
render(
|
|
161
|
+
<LatestArticles
|
|
162
|
+
articles={makeArticles(3)}
|
|
163
|
+
pageSize={3}
|
|
164
|
+
pagination={{ page: 2, totalPages: 4, basePath: '/articles' }}
|
|
165
|
+
/>
|
|
166
|
+
)
|
|
167
|
+
expect(screen.getByText('Article 1')).toBeInTheDocument()
|
|
168
|
+
expect(screen.getByText('Article 2')).toBeInTheDocument()
|
|
169
|
+
expect(screen.getByText('Article 3')).toBeInTheDocument()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('does not render the "Load more" button', () => {
|
|
173
|
+
render(
|
|
174
|
+
<LatestArticles
|
|
175
|
+
articles={makeArticles(3)}
|
|
176
|
+
pageSize={3}
|
|
177
|
+
pagination={{ page: 1, totalPages: 3, basePath: '/articles' }}
|
|
178
|
+
/>
|
|
179
|
+
)
|
|
180
|
+
expect(screen.queryByRole('button', { name: 'Load more articles' })).not.toBeInTheDocument()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('renders PaginationNav with the given page/totalPages/basePath', () => {
|
|
184
|
+
render(
|
|
185
|
+
<LatestArticles
|
|
186
|
+
articles={makeArticles(3)}
|
|
187
|
+
pageSize={3}
|
|
188
|
+
pagination={{ page: 2, totalPages: 4, basePath: '/articles/category/campaigns' }}
|
|
189
|
+
/>
|
|
190
|
+
)
|
|
191
|
+
expect(screen.getByText('Page 2 of 4')).toBeInTheDocument()
|
|
192
|
+
expect(screen.getByRole('link', { name: /next/i })).toHaveAttribute(
|
|
193
|
+
'href',
|
|
194
|
+
'/articles/category/campaigns/page/3'
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('does not render PaginationNav when totalPages is 1', () => {
|
|
199
|
+
render(
|
|
200
|
+
<LatestArticles
|
|
201
|
+
articles={makeArticles(3)}
|
|
202
|
+
pageSize={3}
|
|
203
|
+
pagination={{ page: 1, totalPages: 1, basePath: '/articles' }}
|
|
204
|
+
/>
|
|
205
|
+
)
|
|
206
|
+
expect(screen.queryByText(/Page \d+ of \d+/)).not.toBeInTheDocument()
|
|
207
|
+
})
|
|
208
|
+
})
|
|
157
209
|
})
|
|
@@ -102,4 +102,32 @@ describe('LatestArticlesSection', () => {
|
|
|
102
102
|
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
|
103
103
|
expect(onClearSearch).toHaveBeenCalledTimes(1)
|
|
104
104
|
})
|
|
105
|
+
|
|
106
|
+
describe('pagination prop (listingPagination: "pages" mode)', () => {
|
|
107
|
+
const pagination = { page: 2, totalPages: 3, basePath: '/articles' }
|
|
108
|
+
|
|
109
|
+
it('passes pagination through to LatestArticles when not searching', () => {
|
|
110
|
+
render(
|
|
111
|
+
<LatestArticlesSection
|
|
112
|
+
articles={[makeArticle(1)]}
|
|
113
|
+
searchQuery=""
|
|
114
|
+
onClearSearch={noOp}
|
|
115
|
+
pagination={pagination}
|
|
116
|
+
/>
|
|
117
|
+
)
|
|
118
|
+
expect(screen.getByText('Page 2 of 3')).toBeInTheDocument()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('does not pass pagination through while a search query is active', () => {
|
|
122
|
+
render(
|
|
123
|
+
<LatestArticlesSection
|
|
124
|
+
articles={[makeArticle(1)]}
|
|
125
|
+
searchQuery="voter"
|
|
126
|
+
onClearSearch={noOp}
|
|
127
|
+
pagination={pagination}
|
|
128
|
+
/>
|
|
129
|
+
)
|
|
130
|
+
expect(screen.queryByText(/Page \d+ of \d+/)).not.toBeInTheDocument()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
105
133
|
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render, screen } from '@testing-library/react'
|
|
6
|
+
import { PaginationNav } from '../PaginationNav'
|
|
7
|
+
|
|
8
|
+
jest.mock('next/link', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
|
11
|
+
<a href={href}>{children}</a>
|
|
12
|
+
),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
describe('PaginationNav', () => {
|
|
16
|
+
it('renders nothing when there is only one page', () => {
|
|
17
|
+
const { container } = render(<PaginationNav basePath="/articles" page={1} totalPages={1} />)
|
|
18
|
+
expect(container.firstChild).toBeNull()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('shows the current page and total page count', () => {
|
|
22
|
+
render(<PaginationNav basePath="/articles" page={2} totalPages={4} />)
|
|
23
|
+
expect(screen.getByText('Page 2 of 4')).toBeInTheDocument()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('renders a real Previous link with the correct href on a middle page', () => {
|
|
27
|
+
render(<PaginationNav basePath="/articles" page={2} totalPages={4} />)
|
|
28
|
+
expect(screen.getByRole('link', { name: /previous/i })).toHaveAttribute('href', '/articles')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders a real Next link with the correct href on a middle page', () => {
|
|
32
|
+
render(<PaginationNav basePath="/articles" page={2} totalPages={4} />)
|
|
33
|
+
expect(screen.getByRole('link', { name: /next/i })).toHaveAttribute('href', '/articles/page/3')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('has no Previous link on page 1', () => {
|
|
37
|
+
render(<PaginationNav basePath="/articles" page={1} totalPages={4} />)
|
|
38
|
+
expect(screen.queryByRole('link', { name: /previous/i })).not.toBeInTheDocument()
|
|
39
|
+
expect(screen.getByText('Previous')).toBeInTheDocument()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('has no Next link on the last page', () => {
|
|
43
|
+
render(<PaginationNav basePath="/articles" page={4} totalPages={4} />)
|
|
44
|
+
expect(screen.queryByRole('link', { name: /next/i })).not.toBeInTheDocument()
|
|
45
|
+
expect(screen.getByText('Next')).toBeInTheDocument()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// React 19 hoists <link>/<meta> elements rendered anywhere in the tree into
|
|
49
|
+
// document.head (not left in place in the local subtree) - so these
|
|
50
|
+
// assertions check document.head rather than the render()'d container.
|
|
51
|
+
it('renders rel="prev" and rel="next" link tags for crawlers/tools that read them', () => {
|
|
52
|
+
render(<PaginationNav basePath="/articles" page={2} totalPages={4} />)
|
|
53
|
+
expect(document.head.querySelector('link[rel="prev"]')).toHaveAttribute('href', '/articles')
|
|
54
|
+
expect(document.head.querySelector('link[rel="next"]')).toHaveAttribute(
|
|
55
|
+
'href',
|
|
56
|
+
'/articles/page/3'
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('omits rel="prev" on page 1 and rel="next" on the last page', () => {
|
|
61
|
+
render(<PaginationNav basePath="/articles" page={1} totalPages={4} />)
|
|
62
|
+
expect(document.head.querySelector('link[rel="prev"]')).toBeNull()
|
|
63
|
+
expect(document.head.querySelector('link[rel="next"]')).not.toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('builds hrefs from a nested basePath (category/author routes)', () => {
|
|
67
|
+
render(<PaginationNav basePath="/articles/category/campaigns" page={1} totalPages={2} />)
|
|
68
|
+
expect(screen.getByRole('link', { name: /next/i })).toHaveAttribute(
|
|
69
|
+
'href',
|
|
70
|
+
'/articles/category/campaigns/page/2'
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
})
|