@fullstackdatasolutions/articles 0.12.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +550 -3
  3. package/dist/index.cjs +960 -383
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +372 -20
  6. package/dist/index.d.ts +372 -20
  7. package/dist/index.js +944 -371
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +74 -6
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +141 -0
  12. package/dist/nextjs.d.ts +141 -0
  13. package/dist/nextjs.js +74 -6
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +665 -27
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +349 -3
  18. package/dist/server.d.ts +349 -3
  19. package/dist/server.js +643 -29
  20. package/dist/server.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/ArticleCard.tsx +37 -1
  23. package/src/ArticleContent.tsx +144 -5
  24. package/src/ArticleDetailHero.tsx +23 -0
  25. package/src/ArticleNavigation.tsx +32 -1
  26. package/src/ArticleSchemas.tsx +43 -39
  27. package/src/ArticleSocialShare.tsx +54 -10
  28. package/src/ArticlesPage.tsx +55 -5
  29. package/src/AuthorArticlesPage.tsx +308 -14
  30. package/src/AuthorCard.tsx +1 -1
  31. package/src/CategoryArticlesPage.tsx +34 -2
  32. package/src/LatestArticles.tsx +28 -1
  33. package/src/LatestArticlesSection.tsx +15 -1
  34. package/src/PaginationNav.tsx +78 -0
  35. package/src/RelatedArticlesSection.tsx +55 -0
  36. package/src/SeriesArticlesPage.tsx +66 -0
  37. package/src/__tests__/ArticleCard.test.tsx +63 -3
  38. package/src/__tests__/ArticleContent.test.tsx +143 -0
  39. package/src/__tests__/ArticleDetailHero.test.tsx +30 -0
  40. package/src/__tests__/ArticleNavigation.test.tsx +81 -3
  41. package/src/__tests__/ArticleSchemas.test.tsx +155 -81
  42. package/src/__tests__/ArticleSocialShare.test.tsx +54 -0
  43. package/src/__tests__/ArticlesPage.test.tsx +131 -0
  44. package/src/__tests__/AuthorArticlesPage.test.tsx +304 -3
  45. package/src/__tests__/CategoryArticlesPage.test.tsx +116 -1
  46. package/src/__tests__/LatestArticles.test.tsx +52 -0
  47. package/src/__tests__/LatestArticlesSection.test.tsx +28 -0
  48. package/src/__tests__/PaginationNav.test.tsx +73 -0
  49. package/src/__tests__/RelatedArticlesSection.test.tsx +132 -0
  50. package/src/__tests__/SeriesArticlesPage.test.tsx +121 -0
  51. package/src/__tests__/eventTracking.test.tsx +145 -0
  52. package/src/__tests__/events.test.ts +82 -0
  53. package/src/__tests__/markdown.test.ts +78 -1
  54. package/src/__tests__/pagination.test.ts +178 -0
  55. package/src/__tests__/seoUtils-authors.test.ts +37 -0
  56. package/src/__tests__/seoUtils.test.ts +246 -0
  57. package/src/__tests__/server-articles.test.ts +356 -1
  58. package/src/__tests__/validateArticles.test.ts +312 -0
  59. package/src/articleTypes.ts +109 -0
  60. package/src/articlesConfig.ts +37 -1
  61. package/src/eventTracking.tsx +97 -0
  62. package/src/events.ts +105 -0
  63. package/src/index.ts +26 -1
  64. package/src/markdown.ts +41 -0
  65. package/src/pagination.ts +93 -0
  66. package/src/seoUtils.ts +198 -11
  67. package/src/server-articles.ts +199 -6
  68. package/src/server.ts +46 -2
  69. 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
  })
@@ -290,6 +290,40 @@ describe('ArticlesPage', () => {
290
290
  expect(globalThis.fetch).not.toHaveBeenCalled()
291
291
  })
292
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
+
293
327
  it('uses pageSize from config', async () => {
294
328
  const manyArticles = Array.from({ length: 10 }, (_, i) => ({
295
329
  ...mockArticles[0],
@@ -315,4 +349,101 @@ describe('ArticlesPage', () => {
315
349
  expect(screen.queryByText('Article 4')).not.toBeInTheDocument()
316
350
  expect(screen.getByRole('button', { name: 'Load more articles' })).toBeInTheDocument()
317
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
+ })
318
449
  })