@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,7 +1,6 @@
|
|
|
1
1
|
import { render } from '@testing-library/react'
|
|
2
2
|
import { Article } from '../articleTypes'
|
|
3
3
|
import {
|
|
4
|
-
ArticleSchema,
|
|
5
4
|
ArticleSEO,
|
|
6
5
|
BreadcrumbSchema,
|
|
7
6
|
CollectionPageSchema,
|
|
@@ -9,19 +8,6 @@ import {
|
|
|
9
8
|
} from '../ArticleSchemas'
|
|
10
9
|
import { Breadcrumb } from '../Breadcrumb'
|
|
11
10
|
|
|
12
|
-
const mockArticle: Article = {
|
|
13
|
-
slug: 'test-article',
|
|
14
|
-
title: 'Test Article',
|
|
15
|
-
excerpt: 'A test excerpt.',
|
|
16
|
-
date: '2025-03-01',
|
|
17
|
-
author: 'Jane Doe',
|
|
18
|
-
category: 'Campaigns',
|
|
19
|
-
categories: ['Campaigns'],
|
|
20
|
-
readTime: '3 min read',
|
|
21
|
-
featuredImage: '/articles/test/image.jpg',
|
|
22
|
-
tags: ['campaigns', 'testing'],
|
|
23
|
-
}
|
|
24
|
-
|
|
25
11
|
function parseSchemaScript(container: HTMLElement) {
|
|
26
12
|
const script = container.querySelector('script[type="application/ld+json"]')
|
|
27
13
|
expect(script).not.toBeNull()
|
|
@@ -33,73 +19,6 @@ function parseAllSchemaScripts(container: HTMLElement) {
|
|
|
33
19
|
return Array.from(scripts).map((s) => JSON.parse(s.innerHTML))
|
|
34
20
|
}
|
|
35
21
|
|
|
36
|
-
describe('ArticleSchema', () => {
|
|
37
|
-
it('renders a ld+json script tag', () => {
|
|
38
|
-
const { container } = render(
|
|
39
|
-
<ArticleSchema
|
|
40
|
-
article={mockArticle}
|
|
41
|
-
articleUrl="https://example.com/articles/test-article"
|
|
42
|
-
siteName="Example Site"
|
|
43
|
-
/>
|
|
44
|
-
)
|
|
45
|
-
expect(container.querySelector('script[type="application/ld+json"]')).not.toBeNull()
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('outputs schema.org/Article type with correct fields', () => {
|
|
49
|
-
const { container } = render(
|
|
50
|
-
<ArticleSchema
|
|
51
|
-
article={mockArticle}
|
|
52
|
-
articleUrl="https://example.com/articles/test-article"
|
|
53
|
-
siteName="Example Site"
|
|
54
|
-
/>
|
|
55
|
-
)
|
|
56
|
-
const schema = parseSchemaScript(container)
|
|
57
|
-
expect(schema['@type']).toBe('Article')
|
|
58
|
-
expect(schema.headline).toBe('Test Article')
|
|
59
|
-
expect(schema.description).toBe('A test excerpt.')
|
|
60
|
-
expect(schema.author[0].name).toBe('Jane Doe')
|
|
61
|
-
expect(schema.publisher.name).toBe('Example Site')
|
|
62
|
-
expect(schema.mainEntityOfPage['@id']).toBe('https://example.com/articles/test-article')
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('omits author when showAuthor is false', () => {
|
|
66
|
-
const { container } = render(
|
|
67
|
-
<ArticleSchema
|
|
68
|
-
article={mockArticle}
|
|
69
|
-
articleUrl="https://example.com/articles/test-article"
|
|
70
|
-
siteName="Example Site"
|
|
71
|
-
showAuthor={false}
|
|
72
|
-
/>
|
|
73
|
-
)
|
|
74
|
-
const schema = parseSchemaScript(container)
|
|
75
|
-
expect(schema.author).toBeUndefined()
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('includes ISO datePublished when article has a date', () => {
|
|
79
|
-
const { container } = render(
|
|
80
|
-
<ArticleSchema
|
|
81
|
-
article={mockArticle}
|
|
82
|
-
articleUrl="https://example.com/articles/test-article"
|
|
83
|
-
siteName="Example Site"
|
|
84
|
-
/>
|
|
85
|
-
)
|
|
86
|
-
const schema = parseSchemaScript(container)
|
|
87
|
-
expect(schema.datePublished).toBe(new Date('2025-03-01').toISOString())
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('omits datePublished when article has no date', () => {
|
|
91
|
-
const { container } = render(
|
|
92
|
-
<ArticleSchema
|
|
93
|
-
article={{ ...mockArticle, date: undefined }}
|
|
94
|
-
articleUrl="https://example.com/articles/test-article"
|
|
95
|
-
siteName="Example Site"
|
|
96
|
-
/>
|
|
97
|
-
)
|
|
98
|
-
const schema = parseSchemaScript(container)
|
|
99
|
-
expect(schema.datePublished).toBeUndefined()
|
|
100
|
-
})
|
|
101
|
-
})
|
|
102
|
-
|
|
103
22
|
describe('BreadcrumbSchema', () => {
|
|
104
23
|
const items = [
|
|
105
24
|
{ name: 'Home', url: 'https://example.com' },
|
|
@@ -169,6 +88,54 @@ describe('CollectionPageSchema', () => {
|
|
|
169
88
|
expect(schema.numberOfItems).toBe(42)
|
|
170
89
|
expect(schema.url).toBe('https://example.com/articles')
|
|
171
90
|
})
|
|
91
|
+
|
|
92
|
+
it('omits mainEntity when items is not provided', () => {
|
|
93
|
+
const { container } = render(
|
|
94
|
+
<CollectionPageSchema
|
|
95
|
+
title="Articles | Example"
|
|
96
|
+
description="All articles."
|
|
97
|
+
url="https://example.com/articles"
|
|
98
|
+
articleCount={42}
|
|
99
|
+
/>
|
|
100
|
+
)
|
|
101
|
+
const schema = parseSchemaScript(container)
|
|
102
|
+
expect(schema.mainEntity).toBeUndefined()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('omits mainEntity when items is an empty array', () => {
|
|
106
|
+
const { container } = render(
|
|
107
|
+
<CollectionPageSchema
|
|
108
|
+
title="Articles | Example"
|
|
109
|
+
description="All articles."
|
|
110
|
+
url="https://example.com/articles"
|
|
111
|
+
articleCount={0}
|
|
112
|
+
items={[]}
|
|
113
|
+
/>
|
|
114
|
+
)
|
|
115
|
+
const schema = parseSchemaScript(container)
|
|
116
|
+
expect(schema.mainEntity).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('includes an ItemList mainEntity when items is provided', () => {
|
|
120
|
+
const { container } = render(
|
|
121
|
+
<CollectionPageSchema
|
|
122
|
+
title="Articles | Example"
|
|
123
|
+
description="All articles."
|
|
124
|
+
url="https://example.com/articles"
|
|
125
|
+
articleCount={2}
|
|
126
|
+
items={[
|
|
127
|
+
{ position: 1, url: 'https://example.com/articles/one', name: 'One' },
|
|
128
|
+
{ position: 2, url: 'https://example.com/articles/two', name: 'Two' },
|
|
129
|
+
]}
|
|
130
|
+
/>
|
|
131
|
+
)
|
|
132
|
+
const schema = parseSchemaScript(container)
|
|
133
|
+
expect(schema.mainEntity['@type']).toBe('ItemList')
|
|
134
|
+
expect(schema.mainEntity.itemListElement).toEqual([
|
|
135
|
+
{ '@type': 'ListItem', position: 1, url: 'https://example.com/articles/one', name: 'One' },
|
|
136
|
+
{ '@type': 'ListItem', position: 2, url: 'https://example.com/articles/two', name: 'Two' },
|
|
137
|
+
])
|
|
138
|
+
})
|
|
172
139
|
})
|
|
173
140
|
|
|
174
141
|
describe('FAQPageSchema', () => {
|
|
@@ -269,6 +236,37 @@ describe('ArticleSEO', () => {
|
|
|
269
236
|
})
|
|
270
237
|
})
|
|
271
238
|
|
|
239
|
+
it('excludes rich AuthorProfile fields (promise, servesWho, principles, credentials, proof) from Person JSON-LD (Phase 27E)', () => {
|
|
240
|
+
const { container } = render(
|
|
241
|
+
<ArticleSEO
|
|
242
|
+
article={{ ...baseArticle, author: 'andrew-blase' }}
|
|
243
|
+
articleUrl="https://example.com/articles/test-article"
|
|
244
|
+
siteName="Example Site"
|
|
245
|
+
authors={[
|
|
246
|
+
{
|
|
247
|
+
name: 'Andrew Blase',
|
|
248
|
+
slug: 'andrew-blase',
|
|
249
|
+
bio: 'Writer.',
|
|
250
|
+
url: 'https://example.com/articles/authors/andrew-blase',
|
|
251
|
+
promise: 'Helping new GMs run confident first sessions.',
|
|
252
|
+
servesWho: ['New game masters'],
|
|
253
|
+
principles: ['Fun first'],
|
|
254
|
+
credentials: ['10+ years running published campaigns'],
|
|
255
|
+
proof: [{ claim: '500+ sessions run' }],
|
|
256
|
+
primaryCta: { label: 'Book a session', href: '/book' },
|
|
257
|
+
},
|
|
258
|
+
]}
|
|
259
|
+
/>
|
|
260
|
+
)
|
|
261
|
+
const schemas = parseAllSchemaScripts(container)
|
|
262
|
+
const article = schemas.find((s) => s['@type'] === 'Article')
|
|
263
|
+
expect(article.author[0]).toEqual({
|
|
264
|
+
'@type': 'Person',
|
|
265
|
+
name: 'Andrew Blase',
|
|
266
|
+
url: 'https://example.com/articles/authors/andrew-blase',
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
272
270
|
it('uses articleType when provided', () => {
|
|
273
271
|
const { container } = render(
|
|
274
272
|
<ArticleSEO
|
|
@@ -411,4 +409,80 @@ describe('ArticleSEO', () => {
|
|
|
411
409
|
const schema = schemas[0]
|
|
412
410
|
expect(schema.image).toBeUndefined()
|
|
413
411
|
})
|
|
412
|
+
|
|
413
|
+
it('includes articleSection from article.category', () => {
|
|
414
|
+
const { container } = render(
|
|
415
|
+
<ArticleSEO
|
|
416
|
+
article={baseArticle}
|
|
417
|
+
articleUrl="https://example.com/articles/test-article"
|
|
418
|
+
siteName="Example Site"
|
|
419
|
+
/>
|
|
420
|
+
)
|
|
421
|
+
const schemas = parseAllSchemaScripts(container)
|
|
422
|
+
expect(schemas[0].articleSection).toBe('Campaigns')
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('includes keywords joined from article.tags', () => {
|
|
426
|
+
const { container } = render(
|
|
427
|
+
<ArticleSEO
|
|
428
|
+
article={{ ...baseArticle, tags: ['campaigns', 'worldbuilding'] }}
|
|
429
|
+
articleUrl="https://example.com/articles/test-article"
|
|
430
|
+
siteName="Example Site"
|
|
431
|
+
/>
|
|
432
|
+
)
|
|
433
|
+
const schemas = parseAllSchemaScripts(container)
|
|
434
|
+
expect(schemas[0].keywords).toBe('campaigns, worldbuilding')
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('omits keywords when article has no tags', () => {
|
|
438
|
+
const { container } = render(
|
|
439
|
+
<ArticleSEO
|
|
440
|
+
article={{ ...baseArticle, tags: undefined }}
|
|
441
|
+
articleUrl="https://example.com/articles/test-article"
|
|
442
|
+
siteName="Example Site"
|
|
443
|
+
/>
|
|
444
|
+
)
|
|
445
|
+
const schemas = parseAllSchemaScripts(container)
|
|
446
|
+
expect(schemas[0].keywords).toBeUndefined()
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('includes wordCount when set on the article', () => {
|
|
450
|
+
const { container } = render(
|
|
451
|
+
<ArticleSEO
|
|
452
|
+
article={{ ...baseArticle, wordCount: 1234 }}
|
|
453
|
+
articleUrl="https://example.com/articles/test-article"
|
|
454
|
+
siteName="Example Site"
|
|
455
|
+
/>
|
|
456
|
+
)
|
|
457
|
+
const schemas = parseAllSchemaScripts(container)
|
|
458
|
+
expect(schemas[0].wordCount).toBe(1234)
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('omits wordCount when unset on the article', () => {
|
|
462
|
+
const { container } = render(
|
|
463
|
+
<ArticleSEO
|
|
464
|
+
article={baseArticle}
|
|
465
|
+
articleUrl="https://example.com/articles/test-article"
|
|
466
|
+
siteName="Example Site"
|
|
467
|
+
/>
|
|
468
|
+
)
|
|
469
|
+
const schemas = parseAllSchemaScripts(container)
|
|
470
|
+
expect(schemas[0].wordCount).toBeUndefined()
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('includes the author image in Person schema when config resolves an avatar', () => {
|
|
474
|
+
const { container } = render(
|
|
475
|
+
<ArticleSEO
|
|
476
|
+
article={baseArticle}
|
|
477
|
+
articleUrl="https://example.com/articles/test-article"
|
|
478
|
+
siteName="Example Site"
|
|
479
|
+
authors={[{ name: 'Jane Doe', slug: 'jane-doe', bio: '', avatar: 'avatar.jpg' }]}
|
|
480
|
+
config={{ siteUrl: 'https://example.com', siteName: 'Example Site' }}
|
|
481
|
+
/>
|
|
482
|
+
)
|
|
483
|
+
const schemas = parseAllSchemaScripts(container)
|
|
484
|
+
expect(schemas[0].author[0].image).toBe(
|
|
485
|
+
'https://example.com/articles/authors/jane-doe/avatar.jpg'
|
|
486
|
+
)
|
|
487
|
+
})
|
|
414
488
|
})
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import React from 'react'
|
|
5
5
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
6
6
|
import { ArticleSocialShare } from '../ArticleSocialShare'
|
|
7
|
+
import type { ArticlesConfig } from '../articlesConfig'
|
|
7
8
|
|
|
8
9
|
const mockOpen = jest.fn()
|
|
9
10
|
const mockWriteText = jest.fn()
|
|
@@ -179,4 +180,57 @@ describe('ArticleSocialShare', () => {
|
|
|
179
180
|
expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument()
|
|
180
181
|
})
|
|
181
182
|
})
|
|
183
|
+
|
|
184
|
+
describe('shared event (Phase 27F)', () => {
|
|
185
|
+
const config = (onEvent: jest.Mock): ArticlesConfig => ({
|
|
186
|
+
siteUrl: 'https://example.com',
|
|
187
|
+
siteName: 'Test',
|
|
188
|
+
onEvent,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('fires shared with channel "linkedin" when the LinkedIn button is clicked', () => {
|
|
192
|
+
const onEvent = jest.fn()
|
|
193
|
+
render(
|
|
194
|
+
<ArticleSocialShare {...defaultProps} config={config(onEvent)} articleSlug="my-article" />
|
|
195
|
+
)
|
|
196
|
+
fireEvent.click(screen.getByRole('button', { name: /linkedin/i }))
|
|
197
|
+
expect(onEvent).toHaveBeenCalledWith(
|
|
198
|
+
expect.objectContaining({ name: 'shared', articleSlug: 'my-article', channel: 'linkedin' })
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('fires shared with channel "copy-link" when Copy Link succeeds', async () => {
|
|
203
|
+
const onEvent = jest.fn()
|
|
204
|
+
render(
|
|
205
|
+
<ArticleSocialShare {...defaultProps} config={config(onEvent)} articleSlug="my-article" />
|
|
206
|
+
)
|
|
207
|
+
fireEvent.click(screen.getByRole('button', { name: /copy link/i }))
|
|
208
|
+
await waitFor(() =>
|
|
209
|
+
expect(onEvent).toHaveBeenCalledWith(
|
|
210
|
+
expect.objectContaining({ name: 'shared', channel: 'copy-link' })
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('fires shared with channel "email" when the Email link is clicked', () => {
|
|
216
|
+
const onEvent = jest.fn()
|
|
217
|
+
render(
|
|
218
|
+
<ArticleSocialShare {...defaultProps} config={config(onEvent)} articleSlug="my-article" />
|
|
219
|
+
)
|
|
220
|
+
fireEvent.click(screen.getByRole('link', { name: /email/i }))
|
|
221
|
+
expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ channel: 'email' }))
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('does not fire an event when articleSlug is omitted', () => {
|
|
225
|
+
const onEvent = jest.fn()
|
|
226
|
+
render(<ArticleSocialShare {...defaultProps} config={config(onEvent)} />)
|
|
227
|
+
fireEvent.click(screen.getByRole('button', { name: /linkedin/i }))
|
|
228
|
+
expect(onEvent).not.toHaveBeenCalled()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('does not throw when config is omitted', () => {
|
|
232
|
+
render(<ArticleSocialShare {...defaultProps} articleSlug="my-article" />)
|
|
233
|
+
expect(() => fireEvent.click(screen.getByRole('button', { name: /twitter/i }))).not.toThrow()
|
|
234
|
+
})
|
|
235
|
+
})
|
|
182
236
|
})
|
|
@@ -273,6 +273,57 @@ describe('ArticlesPage', () => {
|
|
|
273
273
|
await waitFor(() => expect(screen.getByText('Browse by Category')).toBeInTheDocument())
|
|
274
274
|
})
|
|
275
275
|
|
|
276
|
+
it('renders real article links synchronously when given server-fetched initialArticles, with no client fetch', () => {
|
|
277
|
+
// Simulates the SSR path: a server component fetches articles via getAllArticles()
|
|
278
|
+
// and passes them down, so Googlebot's first request already contains real hrefs.
|
|
279
|
+
;(globalThis.fetch as jest.Mock).mockReturnValue(new Promise(() => {}))
|
|
280
|
+
render(
|
|
281
|
+
<ArticlesPage config={{ ...baseConfig, layout: ['latest'] }} initialArticles={mockArticles} />
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
expect(screen.getByText('Latest Articles')).toBeInTheDocument()
|
|
285
|
+
expect(screen.queryByText('Loading articles...')).not.toBeInTheDocument()
|
|
286
|
+
expect(screen.getByRole('link', { name: 'Second Article' })).toHaveAttribute(
|
|
287
|
+
'href',
|
|
288
|
+
'/articles/article-2'
|
|
289
|
+
)
|
|
290
|
+
expect(globalThis.fetch).not.toHaveBeenCalled()
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('renders a CollectionPage schema with an ItemList of the initially-visible articles', () => {
|
|
294
|
+
;(globalThis.fetch as jest.Mock).mockReturnValue(new Promise(() => {}))
|
|
295
|
+
const { container } = render(
|
|
296
|
+
<ArticlesPage config={{ ...baseConfig, layout: ['latest'] }} initialArticles={mockArticles} />
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
const script = container.querySelector('script[type="application/ld+json"]')
|
|
300
|
+
expect(script).not.toBeNull()
|
|
301
|
+
const schema = JSON.parse(script!.innerHTML)
|
|
302
|
+
expect(schema).toEqual(
|
|
303
|
+
expect.objectContaining({
|
|
304
|
+
'@type': 'CollectionPage',
|
|
305
|
+
numberOfItems: 2,
|
|
306
|
+
mainEntity: {
|
|
307
|
+
'@type': 'ItemList',
|
|
308
|
+
itemListElement: [
|
|
309
|
+
{
|
|
310
|
+
'@type': 'ListItem',
|
|
311
|
+
position: 1,
|
|
312
|
+
url: 'https://example.com/articles/article-1',
|
|
313
|
+
name: 'Featured Article',
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
'@type': 'ListItem',
|
|
317
|
+
position: 2,
|
|
318
|
+
url: 'https://example.com/articles/article-2',
|
|
319
|
+
name: 'Second Article',
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
})
|
|
324
|
+
)
|
|
325
|
+
})
|
|
326
|
+
|
|
276
327
|
it('uses pageSize from config', async () => {
|
|
277
328
|
const manyArticles = Array.from({ length: 10 }, (_, i) => ({
|
|
278
329
|
...mockArticles[0],
|
|
@@ -298,4 +349,101 @@ describe('ArticlesPage', () => {
|
|
|
298
349
|
expect(screen.queryByText('Article 4')).not.toBeInTheDocument()
|
|
299
350
|
expect(screen.getByRole('button', { name: 'Load more articles' })).toBeInTheDocument()
|
|
300
351
|
})
|
|
352
|
+
|
|
353
|
+
describe('listingPagination: "pages" mode', () => {
|
|
354
|
+
it('does nothing when config.listingPagination is not set, even if page/totalPages are passed', () => {
|
|
355
|
+
const manyArticles = Array.from({ length: 10 }, (_, i) => ({
|
|
356
|
+
...mockArticles[0],
|
|
357
|
+
slug: `article-${i + 1}`,
|
|
358
|
+
title: `Article ${i + 1}`,
|
|
359
|
+
}))
|
|
360
|
+
render(
|
|
361
|
+
<ArticlesPage
|
|
362
|
+
config={{ ...baseConfig, pageSize: 3, layout: ['latest'] }}
|
|
363
|
+
initialArticles={manyArticles}
|
|
364
|
+
page={2}
|
|
365
|
+
totalPages={4}
|
|
366
|
+
/>
|
|
367
|
+
)
|
|
368
|
+
expect(screen.getByRole('button', { name: 'Load more articles' })).toBeInTheDocument()
|
|
369
|
+
expect(screen.queryByText(/Page \d+ of \d+/)).not.toBeInTheDocument()
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('renders PaginationNav instead of "Load more" when config and page/totalPages are set', () => {
|
|
373
|
+
render(
|
|
374
|
+
<ArticlesPage
|
|
375
|
+
config={{ ...baseConfig, layout: ['latest'], listingPagination: 'pages' }}
|
|
376
|
+
initialArticles={mockArticles}
|
|
377
|
+
page={1}
|
|
378
|
+
totalPages={3}
|
|
379
|
+
/>
|
|
380
|
+
)
|
|
381
|
+
expect(screen.queryByRole('button', { name: 'Load more articles' })).not.toBeInTheDocument()
|
|
382
|
+
expect(screen.getByText('Page 1 of 3')).toBeInTheDocument()
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('does not pull out a "featured" article on page > 1', () => {
|
|
386
|
+
render(
|
|
387
|
+
<ArticlesPage
|
|
388
|
+
config={{
|
|
389
|
+
...baseConfig,
|
|
390
|
+
layout: ['featured', 'latest'],
|
|
391
|
+
listingPagination: 'pages',
|
|
392
|
+
}}
|
|
393
|
+
initialArticles={mockArticles}
|
|
394
|
+
page={2}
|
|
395
|
+
totalPages={3}
|
|
396
|
+
/>
|
|
397
|
+
)
|
|
398
|
+
expect(screen.queryByText('FEATURED ARTICLE')).not.toBeInTheDocument()
|
|
399
|
+
// Both articles render in the grid instead of the first being pinned as featured.
|
|
400
|
+
expect(screen.getByText('Featured Article')).toBeInTheDocument()
|
|
401
|
+
expect(screen.getByText('Second Article')).toBeInTheDocument()
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('still pulls out a "featured" article on page 1', () => {
|
|
405
|
+
render(
|
|
406
|
+
<ArticlesPage
|
|
407
|
+
config={{
|
|
408
|
+
...baseConfig,
|
|
409
|
+
layout: ['featured', 'latest'],
|
|
410
|
+
listingPagination: 'pages',
|
|
411
|
+
}}
|
|
412
|
+
initialArticles={mockArticles}
|
|
413
|
+
page={1}
|
|
414
|
+
totalPages={3}
|
|
415
|
+
/>
|
|
416
|
+
)
|
|
417
|
+
expect(screen.getByText('FEATURED ARTICLE')).toBeInTheDocument()
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('uses totalCount for the CollectionPage schema articleCount when provided', () => {
|
|
421
|
+
const { container } = render(
|
|
422
|
+
<ArticlesPage
|
|
423
|
+
config={{ ...baseConfig, layout: ['latest'], listingPagination: 'pages' }}
|
|
424
|
+
initialArticles={mockArticles}
|
|
425
|
+
page={1}
|
|
426
|
+
totalPages={5}
|
|
427
|
+
totalCount={42}
|
|
428
|
+
/>
|
|
429
|
+
)
|
|
430
|
+
const script = container.querySelector('script[type="application/ld+json"]')
|
|
431
|
+
const schema = JSON.parse(script!.innerHTML)
|
|
432
|
+
expect(schema.numberOfItems).toBe(42)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('falls back to the visible article count when totalCount is omitted', () => {
|
|
436
|
+
const { container } = render(
|
|
437
|
+
<ArticlesPage
|
|
438
|
+
config={{ ...baseConfig, layout: ['latest'], listingPagination: 'pages' }}
|
|
439
|
+
initialArticles={mockArticles}
|
|
440
|
+
page={1}
|
|
441
|
+
totalPages={5}
|
|
442
|
+
/>
|
|
443
|
+
)
|
|
444
|
+
const script = container.querySelector('script[type="application/ld+json"]')
|
|
445
|
+
const schema = JSON.parse(script!.innerHTML)
|
|
446
|
+
expect(schema.numberOfItems).toBe(mockArticles.length)
|
|
447
|
+
})
|
|
448
|
+
})
|
|
301
449
|
})
|