@fullstackdatasolutions/articles 0.1.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 (53) hide show
  1. package/README.md +324 -0
  2. package/dist/index.cjs +1027 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +232 -0
  5. package/dist/index.d.ts +232 -0
  6. package/dist/index.js +974 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/server.cjs +732 -0
  9. package/dist/server.cjs.map +1 -0
  10. package/dist/server.d.cts +127 -0
  11. package/dist/server.d.ts +127 -0
  12. package/dist/server.js +684 -0
  13. package/dist/server.js.map +1 -0
  14. package/package.json +82 -0
  15. package/src/ArticleCard.tsx +63 -0
  16. package/src/ArticleCategoryGrid.tsx +73 -0
  17. package/src/ArticleSchemas.tsx +82 -0
  18. package/src/ArticleSearchBar.tsx +50 -0
  19. package/src/ArticlesHero.tsx +33 -0
  20. package/src/ArticlesPage.tsx +167 -0
  21. package/src/CategoryArticlesPage.tsx +84 -0
  22. package/src/CommentForm.tsx +98 -0
  23. package/src/CommentItem.tsx +123 -0
  24. package/src/CommentThread.tsx +40 -0
  25. package/src/CommentsSection.tsx +84 -0
  26. package/src/FeaturedArticle.tsx +63 -0
  27. package/src/LatestArticles.tsx +42 -0
  28. package/src/LatestArticlesSection.tsx +68 -0
  29. package/src/__tests__/ArticleCard.test.tsx +78 -0
  30. package/src/__tests__/ArticleCategoryGrid.test.tsx +98 -0
  31. package/src/__tests__/ArticleSchemas.test.tsx +130 -0
  32. package/src/__tests__/ArticleSearchBar.test.tsx +79 -0
  33. package/src/__tests__/ArticlesHero.test.tsx +38 -0
  34. package/src/__tests__/ArticlesPage.test.tsx +155 -0
  35. package/src/__tests__/CategoryArticlesPage.test.tsx +156 -0
  36. package/src/__tests__/CommentForm.test.tsx +80 -0
  37. package/src/__tests__/CommentItem.test.tsx +149 -0
  38. package/src/__tests__/CommentsSection.test.tsx +118 -0
  39. package/src/__tests__/FeaturedArticle.test.tsx +85 -0
  40. package/src/__tests__/LatestArticles.test.tsx +157 -0
  41. package/src/__tests__/LatestArticlesSection.test.tsx +105 -0
  42. package/src/__tests__/jest-dom.d.ts +1 -0
  43. package/src/__tests__/seoUtils.test.ts +217 -0
  44. package/src/__tests__/useArticles.test.ts +207 -0
  45. package/src/articleTypes.ts +21 -0
  46. package/src/articlesConfig.ts +98 -0
  47. package/src/commentTypes.ts +14 -0
  48. package/src/index.ts +35 -0
  49. package/src/markdown.ts +314 -0
  50. package/src/seoUtils.ts +155 -0
  51. package/src/server-articles.ts +265 -0
  52. package/src/server.ts +25 -0
  53. package/src/useArticles.ts +93 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react'
5
+ import { render, screen } from '@testing-library/react'
6
+ import type { CategoryInfo } from '../articleTypes'
7
+ import { ArticleCategoryGrid } from '../ArticleCategoryGrid'
8
+
9
+ jest.mock('next/link', () => ({
10
+ __esModule: true,
11
+ default: ({ href, children }: { href: string; children: React.ReactNode }) => (
12
+ <a href={href}>{children}</a>
13
+ ),
14
+ }))
15
+
16
+ jest.mock('next/image', () => ({
17
+ __esModule: true,
18
+ default: (props: React.ComponentProps<'img'>) => <img alt="test" {...props} />,
19
+ }))
20
+
21
+ const mockCategories: CategoryInfo[] = [
22
+ {
23
+ name: 'Campaigns',
24
+ slug: 'campaigns',
25
+ count: 5,
26
+ featuredImage: '/articles/campaign-article/image.jpg',
27
+ },
28
+ {
29
+ name: 'Volunteer Management',
30
+ slug: 'volunteer-management',
31
+ count: 3,
32
+ featuredImage: '/articles/volunteer-article/image.jpg',
33
+ },
34
+ {
35
+ name: 'Technology',
36
+ slug: 'technology',
37
+ count: 1,
38
+ featuredImage: '/articles/tech-article/image.jpg',
39
+ },
40
+ ]
41
+
42
+ describe('ArticleCategoryGrid', () => {
43
+ it('renders nothing when categories is empty', () => {
44
+ const { container } = render(<ArticleCategoryGrid categories={[]} />)
45
+ expect(container.firstChild).toBeNull()
46
+ })
47
+
48
+ it('renders the Browse by Category heading', () => {
49
+ render(<ArticleCategoryGrid categories={mockCategories} />)
50
+ expect(screen.getByText('Browse by Category')).toBeInTheDocument()
51
+ })
52
+
53
+ it('renders a card for each category', () => {
54
+ render(<ArticleCategoryGrid categories={mockCategories} />)
55
+ expect(screen.getByText('Campaigns')).toBeInTheDocument()
56
+ expect(screen.getByText('Volunteer Management')).toBeInTheDocument()
57
+ expect(screen.getByText('Technology')).toBeInTheDocument()
58
+ })
59
+
60
+ it('links each category to the correct category page', () => {
61
+ render(<ArticleCategoryGrid categories={mockCategories} />)
62
+
63
+ const campaignLink = screen.getByRole('link', { name: /Campaigns/i })
64
+ expect(campaignLink).toHaveAttribute('href', '/articles/category/campaigns')
65
+
66
+ const volunteerLink = screen.getByRole('link', {
67
+ name: /Volunteer Management/i,
68
+ })
69
+ expect(volunteerLink).toHaveAttribute('href', '/articles/category/volunteer-management')
70
+ })
71
+
72
+ it('renders the article image for each category', () => {
73
+ render(<ArticleCategoryGrid categories={mockCategories} />)
74
+ const images = screen.getAllByRole('img')
75
+ expect(images).toHaveLength(3)
76
+ expect(images[0]).toHaveAttribute('alt', 'Campaigns')
77
+ expect(images[1]).toHaveAttribute('alt', 'Volunteer Management')
78
+ })
79
+
80
+ const countCases: Array<{ count: number; label: string }> = [
81
+ { count: 1, label: '1 article' },
82
+ { count: 5, label: '5 articles' },
83
+ { count: 3, label: '3 articles' },
84
+ ]
85
+
86
+ countCases.forEach(({ count, label }) => {
87
+ it(`renders "${label}" for count ${count}`, () => {
88
+ const cat: CategoryInfo = {
89
+ name: 'Test',
90
+ slug: 'test',
91
+ count,
92
+ featuredImage: '/image.jpg',
93
+ }
94
+ render(<ArticleCategoryGrid categories={[cat]} />)
95
+ expect(screen.getByText(label)).toBeInTheDocument()
96
+ })
97
+ })
98
+ })
@@ -0,0 +1,130 @@
1
+ import { render } from '@testing-library/react'
2
+ import { Article } from '../articleTypes'
3
+ import { ArticleSchema, BreadcrumbSchema, CollectionPageSchema } from '../ArticleSchemas'
4
+
5
+ const mockArticle: Article = {
6
+ slug: 'test-article',
7
+ title: 'Test Article',
8
+ excerpt: 'A test excerpt.',
9
+ date: '2025-03-01',
10
+ author: 'Jane Doe',
11
+ category: 'Campaigns',
12
+ categories: ['Campaigns'],
13
+ readTime: '3 min read',
14
+ featuredImage: '/articles/test/image.jpg',
15
+ tags: ['campaigns', 'testing'],
16
+ }
17
+
18
+ function parseSchemaScript(container: HTMLElement) {
19
+ const script = container.querySelector('script[type="application/ld+json"]')
20
+ expect(script).not.toBeNull()
21
+ return JSON.parse(script!.innerHTML)
22
+ }
23
+
24
+ describe('ArticleSchema', () => {
25
+ it('renders a ld+json script tag', () => {
26
+ const { container } = render(
27
+ <ArticleSchema
28
+ article={mockArticle}
29
+ articleUrl="https://example.com/articles/test-article"
30
+ siteName="Example Site"
31
+ />
32
+ )
33
+ expect(container.querySelector('script[type="application/ld+json"]')).not.toBeNull()
34
+ })
35
+
36
+ it('outputs schema.org/Article type with correct fields', () => {
37
+ const { container } = render(
38
+ <ArticleSchema
39
+ article={mockArticle}
40
+ articleUrl="https://example.com/articles/test-article"
41
+ siteName="Example Site"
42
+ />
43
+ )
44
+ const schema = parseSchemaScript(container)
45
+ expect(schema['@type']).toBe('Article')
46
+ expect(schema.headline).toBe('Test Article')
47
+ expect(schema.description).toBe('A test excerpt.')
48
+ expect(schema.author.name).toBe('Jane Doe')
49
+ expect(schema.publisher.name).toBe('Example Site')
50
+ expect(schema.mainEntityOfPage['@id']).toBe('https://example.com/articles/test-article')
51
+ })
52
+
53
+ it('includes ISO datePublished when article has a date', () => {
54
+ const { container } = render(
55
+ <ArticleSchema
56
+ article={mockArticle}
57
+ articleUrl="https://example.com/articles/test-article"
58
+ siteName="Example Site"
59
+ />
60
+ )
61
+ const schema = parseSchemaScript(container)
62
+ expect(schema.datePublished).toBe(new Date('2025-03-01').toISOString())
63
+ })
64
+
65
+ it('omits datePublished when article has no date', () => {
66
+ const { container } = render(
67
+ <ArticleSchema
68
+ article={{ ...mockArticle, date: undefined }}
69
+ articleUrl="https://example.com/articles/test-article"
70
+ siteName="Example Site"
71
+ />
72
+ )
73
+ const schema = parseSchemaScript(container)
74
+ expect(schema.datePublished).toBeUndefined()
75
+ })
76
+ })
77
+
78
+ describe('BreadcrumbSchema', () => {
79
+ const items = [
80
+ { name: 'Home', url: 'https://example.com' },
81
+ { name: 'Articles', url: 'https://example.com/articles' },
82
+ { name: 'Test Article', url: 'https://example.com/articles/test-article' },
83
+ ]
84
+
85
+ it('renders a ld+json script tag', () => {
86
+ const { container } = render(<BreadcrumbSchema items={items} />)
87
+ expect(container.querySelector('script[type="application/ld+json"]')).not.toBeNull()
88
+ })
89
+
90
+ it('outputs schema.org/BreadcrumbList with correct positions', () => {
91
+ const { container } = render(<BreadcrumbSchema items={items} />)
92
+ const schema = parseSchemaScript(container)
93
+ expect(schema['@type']).toBe('BreadcrumbList')
94
+ expect(schema.itemListElement).toHaveLength(3)
95
+ expect(schema.itemListElement[0].position).toBe(1)
96
+ expect(schema.itemListElement[0].name).toBe('Home')
97
+ expect(schema.itemListElement[2].position).toBe(3)
98
+ expect(schema.itemListElement[2].name).toBe('Test Article')
99
+ })
100
+ })
101
+
102
+ describe('CollectionPageSchema', () => {
103
+ it('renders a ld+json script tag', () => {
104
+ const { container } = render(
105
+ <CollectionPageSchema
106
+ title="Articles | Example"
107
+ description="All articles."
108
+ url="https://example.com/articles"
109
+ articleCount={42}
110
+ />
111
+ )
112
+ expect(container.querySelector('script[type="application/ld+json"]')).not.toBeNull()
113
+ })
114
+
115
+ it('outputs schema.org/CollectionPage with correct fields', () => {
116
+ const { container } = render(
117
+ <CollectionPageSchema
118
+ title="Articles | Example"
119
+ description="All articles."
120
+ url="https://example.com/articles"
121
+ articleCount={42}
122
+ />
123
+ )
124
+ const schema = parseSchemaScript(container)
125
+ expect(schema['@type']).toBe('CollectionPage')
126
+ expect(schema.name).toBe('Articles | Example')
127
+ expect(schema.numberOfItems).toBe(42)
128
+ expect(schema.url).toBe('https://example.com/articles')
129
+ })
130
+ })
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react'
5
+ import { fireEvent, render, screen } from '@testing-library/react'
6
+ import { ArticleSearchBar } from '../ArticleSearchBar'
7
+
8
+ describe('ArticleSearchBar', () => {
9
+ it('renders the search input', () => {
10
+ render(<ArticleSearchBar value="" onChange={jest.fn()} />)
11
+ expect(
12
+ screen.getByPlaceholderText('Search articles (3+ characters required)...')
13
+ ).toBeInTheDocument()
14
+ })
15
+
16
+ it('calls onChange with the typed value', () => {
17
+ const onChange = jest.fn()
18
+ render(<ArticleSearchBar value="" onChange={onChange} />)
19
+ fireEvent.change(screen.getByPlaceholderText('Search articles (3+ characters required)...'), {
20
+ target: { value: 'hello' },
21
+ })
22
+ expect(onChange).toHaveBeenCalledWith('hello')
23
+ })
24
+
25
+ it('shows the clear button when value is non-empty', () => {
26
+ render(<ArticleSearchBar value="test" onChange={jest.fn()} />)
27
+ expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument()
28
+ })
29
+
30
+ it('hides the clear button when value is empty', () => {
31
+ render(<ArticleSearchBar value="" onChange={jest.fn()} />)
32
+ expect(screen.queryByRole('button', { name: 'Clear search' })).not.toBeInTheDocument()
33
+ })
34
+
35
+ it('calls onChange with empty string when clear button is clicked', () => {
36
+ const onChange = jest.fn()
37
+ render(<ArticleSearchBar value="test" onChange={onChange} />)
38
+ fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
39
+ expect(onChange).toHaveBeenCalledWith('')
40
+ })
41
+
42
+ it('disables the input when disabled prop is true', () => {
43
+ render(<ArticleSearchBar value="" onChange={jest.fn()} disabled />)
44
+ expect(
45
+ screen.getByPlaceholderText('Search articles (3+ characters required)...')
46
+ ).toBeDisabled()
47
+ })
48
+
49
+ const resultCountCases = [
50
+ {
51
+ resultCount: 0,
52
+ value: 'xyz',
53
+ expected: 'No articles found matching your search.',
54
+ },
55
+ {
56
+ resultCount: 1,
57
+ value: 'test',
58
+ expected: 'Found 1 article matching "test"',
59
+ },
60
+ {
61
+ resultCount: 4,
62
+ value: 'camp',
63
+ expected: 'Found 4 articles matching "camp"',
64
+ },
65
+ ]
66
+
67
+ resultCountCases.forEach(({ resultCount, value, expected }) => {
68
+ it(`shows "${expected}" for resultCount ${resultCount}`, () => {
69
+ render(<ArticleSearchBar value={value} onChange={jest.fn()} resultCount={resultCount} />)
70
+ expect(screen.getByText(expected)).toBeInTheDocument()
71
+ })
72
+ })
73
+
74
+ it('does not show result count line when value is empty', () => {
75
+ render(<ArticleSearchBar value="" onChange={jest.fn()} resultCount={5} />)
76
+ expect(screen.queryByText(/Found/)).not.toBeInTheDocument()
77
+ expect(screen.queryByText(/No articles/)).not.toBeInTheDocument()
78
+ })
79
+ })
@@ -0,0 +1,38 @@
1
+ import React from 'react'
2
+ import { ArticlesHero } from '../ArticlesHero'
3
+ import { render, screen } from '@testing-library/react'
4
+
5
+ jest.mock('next/link', () => ({
6
+ __esModule: true,
7
+ default: ({ href, children }: { href: string; children: React.ReactNode }) => (
8
+ <a href={href}>{children}</a>
9
+ ),
10
+ }))
11
+
12
+ describe('ArticlesHero', () => {
13
+ it('renders the page heading', () => {
14
+ render(<ArticlesHero />)
15
+ expect(screen.getByRole('heading', { name: 'Vox Populus Insights' })).toBeInTheDocument()
16
+ })
17
+
18
+ it('renders the tagline text', () => {
19
+ render(<ArticlesHero />)
20
+ expect(screen.getByText(/expert analysis, campaign strategies/i)).toBeInTheDocument()
21
+ })
22
+
23
+ it('renders a link to the latest articles anchor', () => {
24
+ render(<ArticlesHero />)
25
+ expect(screen.getByRole('link', { name: 'Browse Latest Articles' })).toHaveAttribute(
26
+ 'href',
27
+ '#latest'
28
+ )
29
+ })
30
+
31
+ it('renders a link to the contact page', () => {
32
+ render(<ArticlesHero />)
33
+ expect(screen.getByRole('link', { name: 'Contact Our Team' })).toHaveAttribute(
34
+ 'href',
35
+ '/contact'
36
+ )
37
+ })
38
+ })
@@ -0,0 +1,155 @@
1
+ import React from 'react'
2
+ import { ArticlesPage } from '../ArticlesPage'
3
+ import type { ArticlesConfig } from '../articlesConfig'
4
+ import { render, screen, waitFor } from '@testing-library/react'
5
+
6
+ jest.mock('next/link', () => ({
7
+ __esModule: true,
8
+ default: ({ href, children }: { href: string; children: React.ReactNode }) => (
9
+ <a href={href}>{children}</a>
10
+ ),
11
+ }))
12
+
13
+ jest.mock('next/image', () => ({
14
+ __esModule: true,
15
+ default: (props: React.ComponentProps<'img'>) => {
16
+ const { alt = '', ...rest } = props
17
+ return <img alt={alt} {...rest} />
18
+ },
19
+ }))
20
+
21
+ const mockArticles = [
22
+ {
23
+ slug: 'article-1',
24
+ title: 'Featured Article',
25
+ excerpt: 'Featured excerpt',
26
+ date: '2025-01-02',
27
+ author: 'Author',
28
+ category: 'Campaigns',
29
+ categories: ['Campaigns'],
30
+ readTime: '3 min read',
31
+ featuredImage: '/articles/article-1/image.jpg',
32
+ },
33
+ {
34
+ slug: 'article-2',
35
+ title: 'Second Article',
36
+ excerpt: 'Second excerpt',
37
+ date: '2025-01-01',
38
+ author: 'Author',
39
+ category: 'Volunteers',
40
+ categories: ['Volunteers'],
41
+ readTime: '2 min read',
42
+ featuredImage: '/articles/article-2/image.jpg',
43
+ },
44
+ ]
45
+
46
+ const baseConfig: ArticlesConfig = {
47
+ siteUrl: 'https://example.com',
48
+ siteName: 'Test Site',
49
+ pageSize: 6,
50
+ }
51
+
52
+ function mockFetchSuccess() {
53
+ ;(globalThis.fetch as jest.Mock).mockResolvedValue({
54
+ ok: true,
55
+ json: async () => ({ articles: mockArticles }),
56
+ })
57
+ }
58
+
59
+ describe('ArticlesPage', () => {
60
+ beforeEach(() => {
61
+ jest.clearAllMocks()
62
+ globalThis.fetch = jest.fn()
63
+ })
64
+
65
+ it('shows loading state in the latest section while hero renders immediately', () => {
66
+ ;(globalThis.fetch as jest.Mock).mockReturnValue(new Promise(() => {}))
67
+ render(<ArticlesPage config={{ ...baseConfig, layout: ['hero', 'latest'] }} />)
68
+ // Hero is static — visible before any fetch completes
69
+ expect(screen.getByText('Vox Populus Insights')).toBeInTheDocument()
70
+ // Latest section shows a spinner while articles load
71
+ expect(screen.getByText('Loading articles...')).toBeInTheDocument()
72
+ })
73
+
74
+ it('renders all default layout sections after load', async () => {
75
+ mockFetchSuccess()
76
+ render(<ArticlesPage config={baseConfig} />)
77
+
78
+ // Static sections render immediately — no need to wait
79
+ expect(screen.getByText('Vox Populus Insights')).toBeInTheDocument()
80
+
81
+ // Article sections appear after fetch resolves
82
+ await waitFor(() => expect(screen.getByText('FEATURED ARTICLE')).toBeInTheDocument())
83
+ expect(screen.getByText('Latest Articles')).toBeInTheDocument()
84
+ })
85
+
86
+ it('respects a custom layout order — omits sections not in the array', async () => {
87
+ mockFetchSuccess()
88
+ const config: ArticlesConfig = {
89
+ ...baseConfig,
90
+ layout: ['search', 'latest'],
91
+ }
92
+ render(<ArticlesPage config={config} />)
93
+
94
+ await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
95
+
96
+ // hero not in layout
97
+ expect(screen.queryByText('Vox Populus Insights')).not.toBeInTheDocument()
98
+ // newsletter not in layout
99
+ expect(screen.queryByTestId('newsletter')).not.toBeInTheDocument()
100
+ // search is in layout
101
+ expect(
102
+ screen.getByPlaceholderText('Search articles (3+ characters required)...')
103
+ ).toBeInTheDocument()
104
+ })
105
+
106
+ it('applies theme values as CSS custom properties', async () => {
107
+ mockFetchSuccess()
108
+ const config: ArticlesConfig = {
109
+ ...baseConfig,
110
+ layout: ['latest'],
111
+ theme: { headerColor: '#ff0000', linkColor: '#00ff00' },
112
+ }
113
+ const { container } = render(<ArticlesPage config={config} />)
114
+
115
+ await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
116
+
117
+ const wrapper = container.firstChild as HTMLElement
118
+ expect(wrapper.style.getPropertyValue('--articles-header-color')).toBe('#ff0000')
119
+ expect(wrapper.style.getPropertyValue('--articles-link-color')).toBe('#00ff00')
120
+ })
121
+
122
+ it('shows inline error in the latest section when fetch fails', async () => {
123
+ ;(globalThis.fetch as jest.Mock).mockResolvedValue({ ok: false })
124
+ render(<ArticlesPage config={{ ...baseConfig, layout: ['latest'] }} />)
125
+
126
+ await waitFor(() => expect(screen.getByText('Error Loading Articles')).toBeInTheDocument())
127
+ expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
128
+ })
129
+
130
+ it('uses pageSize from config', async () => {
131
+ const manyArticles = Array.from({ length: 10 }, (_, i) => ({
132
+ ...mockArticles[0],
133
+ slug: `article-${i + 1}`,
134
+ title: `Article ${i + 1}`,
135
+ }))
136
+ ;(globalThis.fetch as jest.Mock).mockResolvedValue({
137
+ ok: true,
138
+ json: async () => ({ articles: manyArticles }),
139
+ })
140
+
141
+ const config: ArticlesConfig = {
142
+ ...baseConfig,
143
+ pageSize: 3,
144
+ layout: ['latest'],
145
+ }
146
+ render(<ArticlesPage config={config} />)
147
+
148
+ // 'featured' is not in the layout so no slice — articles 1-3 visible
149
+ await waitFor(() => expect(screen.getByText('Article 1')).toBeInTheDocument())
150
+ expect(screen.getByText('Article 2')).toBeInTheDocument()
151
+ expect(screen.getByText('Article 3')).toBeInTheDocument()
152
+ expect(screen.queryByText('Article 4')).not.toBeInTheDocument()
153
+ expect(screen.getByRole('button', { name: 'Load more articles' })).toBeInTheDocument()
154
+ })
155
+ })
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react'
5
+ import { render, screen } from '@testing-library/react'
6
+ import type { Article } from '../articleTypes'
7
+ import { CategoryArticlesPage } from '../CategoryArticlesPage'
8
+ import type { ArticlesConfig } from '../articlesConfig'
9
+
10
+ jest.mock('next/link', () => ({
11
+ __esModule: true,
12
+ default: ({
13
+ href,
14
+ children,
15
+ ...rest
16
+ }: {
17
+ href: string
18
+ children: React.ReactNode
19
+ [key: string]: unknown
20
+ }) => (
21
+ <a href={href} {...rest}>
22
+ {children}
23
+ </a>
24
+ ),
25
+ }))
26
+
27
+ jest.mock('next/image', () => ({
28
+ __esModule: true,
29
+ default: (props: React.ComponentProps<'img'>) => <img {...props} alt={props.alt ?? ''} />,
30
+ }))
31
+
32
+ jest.mock('../LatestArticles', () => ({
33
+ LatestArticles: ({ articles }: { articles: Article[] }) => (
34
+ <div data-testid="latest-articles">
35
+ {articles.map((a) => (
36
+ <div key={a.slug} data-testid="article-card">
37
+ {a.title}
38
+ </div>
39
+ ))}
40
+ </div>
41
+ ),
42
+ }))
43
+
44
+ jest.mock('../ArticleSchemas', () => ({
45
+ BreadcrumbSchema: () => null,
46
+ }))
47
+
48
+ const baseConfig: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Test Site' }
49
+
50
+ const makeArticle = (overrides: Partial<Article> = {}): Article => ({
51
+ slug: 'test-article',
52
+ title: 'Test Article',
53
+ excerpt: 'An excerpt.',
54
+ author: 'Author',
55
+ category: 'Campaigns',
56
+ categories: ['Campaigns'],
57
+ readTime: '3 min read',
58
+ featuredImage: '/articles/test-article/hero.jpg',
59
+ tags: [],
60
+ ...overrides,
61
+ })
62
+
63
+ describe('CategoryArticlesPage', () => {
64
+ it('renders nothing when articles array is empty', () => {
65
+ const { container } = render(
66
+ <CategoryArticlesPage category="campaigns" articles={[]} config={baseConfig} />
67
+ )
68
+ expect(container).toBeEmptyDOMElement()
69
+ })
70
+
71
+ it('renders the category name in the hero', () => {
72
+ render(
73
+ <CategoryArticlesPage
74
+ category="campaigns"
75
+ articles={[makeArticle({ category: 'Campaigns' })]}
76
+ config={baseConfig}
77
+ />
78
+ )
79
+ expect(screen.getByText('Campaigns')).toBeInTheDocument()
80
+ })
81
+
82
+ it('renders plural article count in the hero', () => {
83
+ render(
84
+ <CategoryArticlesPage
85
+ category="campaigns"
86
+ articles={[makeArticle({ slug: 'a1' }), makeArticle({ slug: 'a2' })]}
87
+ config={baseConfig}
88
+ />
89
+ )
90
+ expect(screen.getByText('2 articles')).toBeInTheDocument()
91
+ })
92
+
93
+ it("uses singular 'article' when count is 1", () => {
94
+ render(
95
+ <CategoryArticlesPage category="campaigns" articles={[makeArticle()]} config={baseConfig} />
96
+ )
97
+ expect(screen.getByText('1 article')).toBeInTheDocument()
98
+ })
99
+
100
+ it('renders an article card for each article via LatestArticles', () => {
101
+ render(
102
+ <CategoryArticlesPage
103
+ category="campaigns"
104
+ articles={[
105
+ makeArticle({ slug: 'a1', title: 'Article One' }),
106
+ makeArticle({ slug: 'a2', title: 'Article Two' }),
107
+ makeArticle({ slug: 'a3', title: 'Article Three' }),
108
+ ]}
109
+ config={baseConfig}
110
+ />
111
+ )
112
+ expect(screen.getAllByTestId('article-card')).toHaveLength(3)
113
+ expect(screen.getByText('Article One')).toBeInTheDocument()
114
+ expect(screen.getByText('Article Three')).toBeInTheDocument()
115
+ })
116
+
117
+ it('renders the back link to /articles', () => {
118
+ render(
119
+ <CategoryArticlesPage category="campaigns" articles={[makeArticle()]} config={baseConfig} />
120
+ )
121
+ expect(screen.getByRole('link', { name: /All Articles/i })).toHaveAttribute('href', '/articles')
122
+ })
123
+
124
+ it('renders the hero image from the first article', () => {
125
+ render(
126
+ <CategoryArticlesPage
127
+ category="campaigns"
128
+ articles={[makeArticle({ featuredImage: '/articles/hero.jpg', category: 'Campaigns' })]}
129
+ config={baseConfig}
130
+ />
131
+ )
132
+ expect(screen.getByAltText('Campaigns')).toHaveAttribute('src', '/articles/hero.jpg')
133
+ })
134
+
135
+ it('shows the long description from config when provided', () => {
136
+ const config: ArticlesConfig = {
137
+ ...baseConfig,
138
+ categoryDescriptions: {
139
+ campaigns: { short: 'Short desc', long: 'A longer category description' },
140
+ },
141
+ }
142
+ render(<CategoryArticlesPage category="campaigns" articles={[makeArticle()]} config={config} />)
143
+ expect(screen.getByText('A longer category description')).toBeInTheDocument()
144
+ })
145
+
146
+ it('falls back to a generated short description when no config description is provided', () => {
147
+ render(
148
+ <CategoryArticlesPage
149
+ category="campaigns"
150
+ articles={[makeArticle({ category: 'Campaigns' })]}
151
+ config={baseConfig}
152
+ />
153
+ )
154
+ expect(screen.getByText('Browse all articles in Campaigns.')).toBeInTheDocument()
155
+ })
156
+ })