@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +568 -6
  3. package/dist/index.cjs +970 -389
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +375 -21
  6. package/dist/index.d.ts +375 -21
  7. package/dist/index.js +954 -377
  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 +65 -7
  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 +148 -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__/useArticles.test.ts +28 -0
  59. package/src/__tests__/validateArticles.test.ts +312 -0
  60. package/src/articleTypes.ts +109 -0
  61. package/src/articlesConfig.ts +37 -1
  62. package/src/eventTracking.tsx +97 -0
  63. package/src/events.ts +105 -0
  64. package/src/index.ts +26 -1
  65. package/src/markdown.ts +41 -0
  66. package/src/pagination.ts +93 -0
  67. package/src/seoUtils.ts +198 -11
  68. package/src/server-articles.ts +199 -6
  69. package/src/server.ts +46 -2
  70. package/src/useArticles.ts +10 -5
  71. package/src/validateArticles.ts +260 -0
@@ -0,0 +1,178 @@
1
+ import type { Article } from '../articleTypes'
2
+ import {
3
+ getTotalPages,
4
+ paginateArticles,
5
+ buildPageUrl,
6
+ buildPaginationLinks,
7
+ generateListingPageStaticParams,
8
+ parsePageParam,
9
+ isPageOutOfRange,
10
+ } from '../pagination'
11
+
12
+ function makeArticles(count: number): Article[] {
13
+ return Array.from({ length: count }, (_, i) => ({
14
+ slug: `article-${i + 1}`,
15
+ title: `Article ${i + 1}`,
16
+ excerpt: `Excerpt ${i + 1}`,
17
+ author: 'Author',
18
+ category: 'Test',
19
+ categories: ['Test'],
20
+ readTime: '3 min read',
21
+ featuredImage: `/articles/article-${i + 1}/image.jpg`,
22
+ }))
23
+ }
24
+
25
+ describe('getTotalPages', () => {
26
+ it('divides totalCount by pageSize, rounding up', () => {
27
+ expect(getTotalPages(10, 3)).toBe(4)
28
+ expect(getTotalPages(9, 3)).toBe(3)
29
+ })
30
+
31
+ it('returns 1 for a zero or negative totalCount', () => {
32
+ expect(getTotalPages(0, 6)).toBe(1)
33
+ expect(getTotalPages(-5, 6)).toBe(1)
34
+ })
35
+
36
+ it('returns 1 for a zero or negative pageSize', () => {
37
+ expect(getTotalPages(10, 0)).toBe(1)
38
+ expect(getTotalPages(10, -1)).toBe(1)
39
+ })
40
+
41
+ it('returns 1 when totalCount is less than pageSize', () => {
42
+ expect(getTotalPages(2, 6)).toBe(1)
43
+ })
44
+ })
45
+
46
+ describe('paginateArticles', () => {
47
+ it('slices the requested page', () => {
48
+ const result = paginateArticles(makeArticles(10), 2, 3)
49
+ expect(result.articles.map((a) => a.slug)).toEqual(['article-4', 'article-5', 'article-6'])
50
+ expect(result.page).toBe(2)
51
+ expect(result.totalPages).toBe(4)
52
+ expect(result.hasPrevious).toBe(true)
53
+ expect(result.hasNext).toBe(true)
54
+ })
55
+
56
+ it('returns the first page by default characteristics (no previous, has next)', () => {
57
+ const result = paginateArticles(makeArticles(10), 1, 3)
58
+ expect(result.hasPrevious).toBe(false)
59
+ expect(result.hasNext).toBe(true)
60
+ expect(result.articles.map((a) => a.slug)).toEqual(['article-1', 'article-2', 'article-3'])
61
+ })
62
+
63
+ it('returns a shorter final slice and no next page on the last page', () => {
64
+ const result = paginateArticles(makeArticles(10), 4, 3)
65
+ expect(result.articles.map((a) => a.slug)).toEqual(['article-10'])
66
+ expect(result.hasNext).toBe(false)
67
+ expect(result.hasPrevious).toBe(true)
68
+ })
69
+
70
+ it('clamps a page number above totalPages down to the last page', () => {
71
+ const result = paginateArticles(makeArticles(10), 99, 3)
72
+ expect(result.page).toBe(4)
73
+ expect(result.articles.map((a) => a.slug)).toEqual(['article-10'])
74
+ })
75
+
76
+ it('clamps a page number below 1 up to page 1', () => {
77
+ const result = paginateArticles(makeArticles(10), -3, 3)
78
+ expect(result.page).toBe(1)
79
+ expect(result.articles.map((a) => a.slug)).toEqual(['article-1', 'article-2', 'article-3'])
80
+ })
81
+
82
+ it('clamps a non-integer page number', () => {
83
+ const result = paginateArticles(makeArticles(10), 2.9, 3)
84
+ expect(result.page).toBe(2)
85
+ })
86
+
87
+ it('returns totalPages 1 and all articles for an empty list', () => {
88
+ const result = paginateArticles([], 1, 6)
89
+ expect(result.totalPages).toBe(1)
90
+ expect(result.articles).toEqual([])
91
+ expect(result.hasNext).toBe(false)
92
+ expect(result.hasPrevious).toBe(false)
93
+ })
94
+ })
95
+
96
+ describe('buildPageUrl', () => {
97
+ it('returns basePath unchanged for page 1', () => {
98
+ expect(buildPageUrl('/articles', 1)).toBe('/articles')
99
+ })
100
+
101
+ it('appends /page/N for page > 1', () => {
102
+ expect(buildPageUrl('/articles', 2)).toBe('/articles/page/2')
103
+ expect(buildPageUrl('/articles/category/campaigns', 3)).toBe(
104
+ '/articles/category/campaigns/page/3'
105
+ )
106
+ })
107
+
108
+ it('strips a trailing slash from basePath before appending', () => {
109
+ expect(buildPageUrl('/articles/', 2)).toBe('/articles/page/2')
110
+ })
111
+ })
112
+
113
+ describe('buildPaginationLinks', () => {
114
+ it('has no prevUrl on page 1', () => {
115
+ const links = buildPaginationLinks('/articles', 1, 4)
116
+ expect(links.prevUrl).toBeNull()
117
+ expect(links.nextUrl).toBe('/articles/page/2')
118
+ expect(links.canonicalUrl).toBe('/articles')
119
+ })
120
+
121
+ it('has no nextUrl on the last page', () => {
122
+ const links = buildPaginationLinks('/articles', 4, 4)
123
+ expect(links.nextUrl).toBeNull()
124
+ expect(links.prevUrl).toBe('/articles/page/3')
125
+ expect(links.canonicalUrl).toBe('/articles/page/4')
126
+ })
127
+
128
+ it('has both prevUrl and nextUrl on a middle page', () => {
129
+ const links = buildPaginationLinks('/articles', 2, 4)
130
+ expect(links.prevUrl).toBe('/articles')
131
+ expect(links.nextUrl).toBe('/articles/page/3')
132
+ expect(links.canonicalUrl).toBe('/articles/page/2')
133
+ })
134
+ })
135
+
136
+ describe('generateListingPageStaticParams', () => {
137
+ it('returns params for pages 2..totalPages, excluding page 1', () => {
138
+ expect(generateListingPageStaticParams(4)).toEqual([
139
+ { page: '2' },
140
+ { page: '3' },
141
+ { page: '4' },
142
+ ])
143
+ })
144
+
145
+ it('returns an empty array when there is only one page', () => {
146
+ expect(generateListingPageStaticParams(1)).toEqual([])
147
+ })
148
+ })
149
+
150
+ describe('parsePageParam', () => {
151
+ it('parses a valid numeric string', () => {
152
+ expect(parsePageParam('3')).toBe(3)
153
+ })
154
+
155
+ it('defaults to 1 for undefined, null, or empty input', () => {
156
+ expect(parsePageParam(undefined)).toBe(1)
157
+ expect(parsePageParam(null)).toBe(1)
158
+ expect(parsePageParam('')).toBe(1)
159
+ })
160
+
161
+ it('defaults to 1 for non-numeric or non-positive input', () => {
162
+ expect(parsePageParam('abc')).toBe(1)
163
+ expect(parsePageParam('0')).toBe(1)
164
+ expect(parsePageParam('-2')).toBe(1)
165
+ })
166
+ })
167
+
168
+ describe('isPageOutOfRange', () => {
169
+ it('returns false for pages within [1, totalPages]', () => {
170
+ expect(isPageOutOfRange(1, 4)).toBe(false)
171
+ expect(isPageOutOfRange(4, 4)).toBe(false)
172
+ })
173
+
174
+ it('returns true for pages below 1 or above totalPages', () => {
175
+ expect(isPageOutOfRange(0, 4)).toBe(true)
176
+ expect(isPageOutOfRange(5, 4)).toBe(true)
177
+ })
178
+ })
@@ -3,6 +3,7 @@ import {
3
3
  buildAuthorBreadcrumbs,
4
4
  buildCategoryBreadcrumbs,
5
5
  generateAuthorMetadata,
6
+ generateAuthorPageMetadata,
6
7
  generateAuthorStaticParams,
7
8
  } from '../seoUtils'
8
9
 
@@ -158,3 +159,39 @@ describe('author metadata utilities', () => {
158
159
  })
159
160
  })
160
161
  })
162
+
163
+ describe('generateAuthorPageMetadata', () => {
164
+ it('matches generateAuthorMetadata unchanged on page 1', async () => {
165
+ const base = await generateAuthorMetadata('andrew-blase', config)
166
+ const paged = await generateAuthorPageMetadata('andrew-blase', 1, 3, config)
167
+ expect(paged.title).toBe(base.title)
168
+ expect(paged.alternates?.canonical).toBe('https://example.com/articles/authors/andrew-blase')
169
+ })
170
+
171
+ it('appends a page suffix to the title and points canonical at the paginated URL', async () => {
172
+ const meta = await generateAuthorPageMetadata('andrew-blase', 2, 3, config)
173
+ expect(meta.title).toBe('Andrew Blase Articles | Example - Page 2')
174
+ expect(meta.alternates?.canonical).toBe(
175
+ 'https://example.com/articles/authors/andrew-blase/page/2'
176
+ )
177
+ })
178
+
179
+ it('uses a configured absolute author.url as the pagination basePath when set', async () => {
180
+ const cfg = {
181
+ ...config,
182
+ authors: {
183
+ 'andrew-blase': {
184
+ ...config.authors['andrew-blase'],
185
+ url: 'https://elsewhere.com/team/andrew',
186
+ },
187
+ },
188
+ }
189
+ const meta = await generateAuthorPageMetadata('andrew-blase', 2, 3, cfg)
190
+ expect(meta.alternates?.canonical).toBe('https://elsewhere.com/team/andrew/page/2')
191
+ })
192
+
193
+ it('returns Author Not Found metadata unchanged for an unknown author slug', async () => {
194
+ const meta = await generateAuthorPageMetadata('nobody', 2, 3, config)
195
+ expect(meta.title).toBe('Author Not Found')
196
+ })
197
+ })
@@ -2,11 +2,17 @@ import { ArticlesConfig } from '../articlesConfig'
2
2
  import {
3
3
  generateArticleStaticParams,
4
4
  generateArticlesIndexMetadata,
5
+ generateArticlesIndexPageMetadata,
5
6
  generateCategoryStaticParams,
7
+ generateSeriesStaticParams,
6
8
  generateArticleMetadata,
7
9
  generateCategoryMetadata,
10
+ generateCategoryPageMetadata,
11
+ generateSeriesMetadata,
8
12
  getArticleSitemapEntries,
9
13
  generateRssFeed,
14
+ resolveSearchMetadata,
15
+ resolveSocialMetadata,
10
16
  } from '../seoUtils'
11
17
 
12
18
  jest.mock('../server-articles', () => ({
@@ -96,6 +102,27 @@ jest.mock('../server-articles', () => ({
96
102
  return `${cfg.siteUrl.replace(/\/$/, '')}/articles/${article.slug}.md`
97
103
  }
98
104
  ),
105
+ getArticlesBySeries: jest.fn(async (seriesSlug: string) => {
106
+ if (seriesSlug === 'new-gm-path') {
107
+ return [
108
+ {
109
+ slug: 'article-one',
110
+ title: 'Article One',
111
+ excerpt: 'Excerpt.',
112
+ date: '2025-01-15',
113
+ author: 'Jane Doe',
114
+ category: 'Campaigns',
115
+ categories: ['Campaigns'],
116
+ readTime: '3 min read',
117
+ featuredImage: '/articles/article-one/image.jpg',
118
+ series: 'New GM Path',
119
+ seriesSlug: 'new-gm-path',
120
+ seriesOrder: 1,
121
+ },
122
+ ]
123
+ }
124
+ return []
125
+ }),
99
126
  }))
100
127
 
101
128
  const config: ArticlesConfig = {
@@ -404,6 +431,41 @@ describe('generateArticleMetadata', () => {
404
431
  })
405
432
  })
406
433
 
434
+ describe('generateArticlesIndexPageMetadata', () => {
435
+ it('matches generateArticlesIndexMetadata unchanged on page 1', () => {
436
+ const base = generateArticlesIndexMetadata(config)
437
+ const paged = generateArticlesIndexPageMetadata(1, 3, config)
438
+ expect(paged.title).toBe(base.title)
439
+ expect(paged.alternates?.canonical).toBe('https://example.com/articles')
440
+ })
441
+
442
+ it('appends a page suffix to the title on page > 1', () => {
443
+ const meta = generateArticlesIndexPageMetadata(2, 3, config)
444
+ expect(meta.title).toBe('Articles | Example Site - Page 2')
445
+ })
446
+
447
+ it('points canonical at the paginated URL on page > 1', () => {
448
+ const meta = generateArticlesIndexPageMetadata(2, 3, config)
449
+ expect(meta.alternates?.canonical).toBe('https://example.com/articles/page/2')
450
+ })
451
+
452
+ it('appends the page suffix to openGraph and twitter titles', () => {
453
+ const meta = generateArticlesIndexPageMetadata(3, 3, config)
454
+ expect(meta.openGraph?.title).toBe('Articles | Example Site - Page 3')
455
+ expect(meta.twitter?.title).toBe('Articles | Example Site - Page 3')
456
+ })
457
+
458
+ it('sets the openGraph url to the paginated canonical URL', () => {
459
+ const meta = generateArticlesIndexPageMetadata(2, 3, config)
460
+ expect(meta.openGraph?.url).toBe('https://example.com/articles/page/2')
461
+ })
462
+
463
+ it('remains index/follow on paginated pages', () => {
464
+ const meta = generateArticlesIndexPageMetadata(2, 3, config)
465
+ expect(meta.robots).toEqual(expect.objectContaining({ index: true, follow: true }))
466
+ })
467
+ })
468
+
407
469
  describe('generateCategoryMetadata', () => {
408
470
  it('returns correct title using category name and siteName', async () => {
409
471
  const meta = await generateCategoryMetadata('campaigns', config)
@@ -449,6 +511,29 @@ describe('generateCategoryMetadata', () => {
449
511
  })
450
512
  })
451
513
 
514
+ describe('generateCategoryPageMetadata', () => {
515
+ it('matches generateCategoryMetadata unchanged on page 1', async () => {
516
+ const base = await generateCategoryMetadata('campaigns', config)
517
+ const paged = await generateCategoryPageMetadata('campaigns', 1, 2, config)
518
+ expect(paged.title).toBe(base.title)
519
+ expect(paged.alternates?.canonical).toBe('https://example.com/articles/category/campaigns')
520
+ })
521
+
522
+ it('appends a page suffix to the title and points canonical at the paginated URL', async () => {
523
+ const meta = await generateCategoryPageMetadata('campaigns', 2, 2, config)
524
+ expect(meta.title).toBe('Campaigns Articles | Example Site - Page 2')
525
+ expect(meta.alternates?.canonical).toBe(
526
+ 'https://example.com/articles/category/campaigns/page/2'
527
+ )
528
+ })
529
+
530
+ it('returns Category Not Found metadata unchanged for an unknown category (no page suffix/canonical added)', async () => {
531
+ const meta = await generateCategoryPageMetadata('nonexistent', 2, 2, config)
532
+ expect(meta.title).toBe('Category Not Found')
533
+ expect(meta.alternates).toBeUndefined()
534
+ })
535
+ })
536
+
452
537
  describe('getArticleSitemapEntries', () => {
453
538
  it('returns article and category entries prefixed with baseUrl', async () => {
454
539
  const entries = await getArticleSitemapEntries('https://example.com')
@@ -685,3 +770,164 @@ describe('generateRssFeed', () => {
685
770
  expect(xml).toContain('<![CDATA[Second Article]]>')
686
771
  })
687
772
  })
773
+
774
+ // ---------------------------------------------------------------------------
775
+ // Phase 27F: discovery metadata channel separation
776
+ // ---------------------------------------------------------------------------
777
+
778
+ describe('resolveSearchMetadata', () => {
779
+ it('falls back to title/excerpt when searchTitle/searchDescription are unset', () => {
780
+ const result = resolveSearchMetadata(
781
+ { title: 'The Title', excerpt: 'The excerpt.' },
782
+ { siteName: 'Example' }
783
+ )
784
+ expect(result).toEqual({ title: 'The Title', description: 'The excerpt.' })
785
+ })
786
+
787
+ it('prefers searchTitle/searchDescription when set', () => {
788
+ const result = resolveSearchMetadata(
789
+ {
790
+ title: 'The Title',
791
+ excerpt: 'The excerpt.',
792
+ searchTitle: 'SEO Title',
793
+ searchDescription: 'SEO description.',
794
+ },
795
+ { siteName: 'Example' }
796
+ )
797
+ expect(result).toEqual({ title: 'SEO Title', description: 'SEO description.' })
798
+ })
799
+ })
800
+
801
+ describe('resolveSocialMetadata', () => {
802
+ it('falls back to title/excerpt/featuredImage when social overrides are unset', () => {
803
+ const result = resolveSocialMetadata(
804
+ { title: 'The Title', excerpt: 'The excerpt.', featuredImage: '/img.jpg' },
805
+ 'https://example.com'
806
+ )
807
+ expect(result).toEqual({
808
+ title: 'The Title',
809
+ description: 'The excerpt.',
810
+ imageUrl: 'https://example.com/img.jpg',
811
+ })
812
+ })
813
+
814
+ it('prefers socialTitle/socialDescription/socialImage when set', () => {
815
+ const result = resolveSocialMetadata(
816
+ {
817
+ title: 'The Title',
818
+ excerpt: 'The excerpt.',
819
+ featuredImage: '/img.jpg',
820
+ socialTitle: 'Social Title',
821
+ socialDescription: 'Social description.',
822
+ socialImage: '/social.jpg',
823
+ },
824
+ 'https://example.com'
825
+ )
826
+ expect(result).toEqual({
827
+ title: 'Social Title',
828
+ description: 'Social description.',
829
+ imageUrl: 'https://example.com/social.jpg',
830
+ })
831
+ })
832
+
833
+ it('passes through an absolute socialImage URL unchanged', () => {
834
+ const result = resolveSocialMetadata(
835
+ {
836
+ title: 'T',
837
+ excerpt: 'E',
838
+ featuredImage: '/img.jpg',
839
+ socialImage: 'https://cdn.example.com/social.jpg',
840
+ },
841
+ 'https://example.com'
842
+ )
843
+ expect(result.imageUrl).toBe('https://cdn.example.com/social.jpg')
844
+ })
845
+ })
846
+
847
+ describe('generateArticleMetadata - discovery channel separation', () => {
848
+ it('uses title/excerpt (not overrides) for <title>/description when only canonical fields are set', async () => {
849
+ const meta = await generateArticleMetadata('article-one', config)
850
+ expect(meta.title).toBe('Article One | Example Site')
851
+ expect(meta.description).toBe('Excerpt for article one.')
852
+ })
853
+
854
+ it('prefers searchTitle/searchDescription for <title>/description, leaving openGraph on title/excerpt', async () => {
855
+ const { getArticleMetadata: mockGetArticleMetadata } = jest.requireMock('../server-articles')
856
+ mockGetArticleMetadata.mockImplementationOnce(async () => ({
857
+ slug: 'article-one',
858
+ title: 'Article One',
859
+ excerpt: 'Excerpt.',
860
+ date: '2025-01-15',
861
+ author: 'Jane Doe',
862
+ category: 'Campaigns',
863
+ categories: ['Campaigns'],
864
+ readTime: '3 min read',
865
+ featuredImage: '/articles/article-one/image.jpg',
866
+ searchTitle: 'SEO-Optimized Title',
867
+ searchDescription: 'SEO-optimized description.',
868
+ }))
869
+ const meta = await generateArticleMetadata('article-one', config)
870
+ expect(meta.title).toBe('SEO-Optimized Title | Example Site')
871
+ expect(meta.description).toBe('SEO-optimized description.')
872
+ expect((meta.openGraph as { title?: string }).title).toBe('Article One')
873
+ })
874
+
875
+ it('prefers socialTitle/socialDescription/socialImage for openGraph/twitter only', async () => {
876
+ const { getArticleMetadata: mockGetArticleMetadata } = jest.requireMock('../server-articles')
877
+ mockGetArticleMetadata.mockImplementationOnce(async () => ({
878
+ slug: 'article-one',
879
+ title: 'Article One',
880
+ excerpt: 'Excerpt.',
881
+ date: '2025-01-15',
882
+ author: 'Jane Doe',
883
+ category: 'Campaigns',
884
+ categories: ['Campaigns'],
885
+ readTime: '3 min read',
886
+ featuredImage: '/articles/article-one/image.jpg',
887
+ socialTitle: 'Social-Optimized Title',
888
+ socialDescription: 'Social-optimized description.',
889
+ socialImage: '/articles/article-one/social.jpg',
890
+ }))
891
+ const meta = await generateArticleMetadata('article-one', config)
892
+ expect(meta.title).toBe('Article One | Example Site')
893
+ expect((meta.openGraph as { title?: string }).title).toBe('Social-Optimized Title')
894
+ expect((meta.twitter as { title?: string }).title).toBe('Social-Optimized Title')
895
+ expect((meta.openGraph as { images?: Array<{ url: string }> }).images?.[0].url).toBe(
896
+ 'https://example.com/articles/article-one/social.jpg'
897
+ )
898
+ })
899
+ })
900
+
901
+ // ---------------------------------------------------------------------------
902
+ // Phase 27F: series static params + metadata
903
+ // ---------------------------------------------------------------------------
904
+
905
+ describe('generateSeriesStaticParams', () => {
906
+ it('returns one entry per distinct seriesSlug across all articles', async () => {
907
+ const { getAllArticles: mockGetAllArticles } = jest.requireMock('../server-articles')
908
+ mockGetAllArticles.mockResolvedValueOnce([
909
+ { slug: 'a', seriesSlug: 'new-gm-path' },
910
+ { slug: 'b', seriesSlug: 'new-gm-path' },
911
+ { slug: 'c', seriesSlug: 'combat-basics' },
912
+ { slug: 'd' },
913
+ ])
914
+ const result = await generateSeriesStaticParams()
915
+ expect(result).toEqual(
916
+ expect.arrayContaining([{ series: 'new-gm-path' }, { series: 'combat-basics' }])
917
+ )
918
+ expect(result).toHaveLength(2)
919
+ })
920
+ })
921
+
922
+ describe('generateSeriesMetadata', () => {
923
+ it('returns "Series Not Found" for an unknown seriesSlug', async () => {
924
+ const meta = await generateSeriesMetadata('nonexistent', config)
925
+ expect(meta.title).toBe('Series Not Found')
926
+ })
927
+
928
+ it('builds title/description/canonical from the series articles', async () => {
929
+ const meta = await generateSeriesMetadata('new-gm-path', config)
930
+ expect(meta.title).toBe('New GM Path Series | Example Site')
931
+ expect(meta.alternates?.canonical).toBe('https://example.com/articles/series/new-gm-path')
932
+ })
933
+ })