@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.
- package/README.md +324 -0
- package/dist/index.cjs +1027 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +232 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.js +974 -0
- package/dist/index.js.map +1 -0
- package/dist/server.cjs +732 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +127 -0
- package/dist/server.d.ts +127 -0
- package/dist/server.js +684 -0
- package/dist/server.js.map +1 -0
- package/package.json +82 -0
- package/src/ArticleCard.tsx +63 -0
- package/src/ArticleCategoryGrid.tsx +73 -0
- package/src/ArticleSchemas.tsx +82 -0
- package/src/ArticleSearchBar.tsx +50 -0
- package/src/ArticlesHero.tsx +33 -0
- package/src/ArticlesPage.tsx +167 -0
- package/src/CategoryArticlesPage.tsx +84 -0
- package/src/CommentForm.tsx +98 -0
- package/src/CommentItem.tsx +123 -0
- package/src/CommentThread.tsx +40 -0
- package/src/CommentsSection.tsx +84 -0
- package/src/FeaturedArticle.tsx +63 -0
- package/src/LatestArticles.tsx +42 -0
- package/src/LatestArticlesSection.tsx +68 -0
- package/src/__tests__/ArticleCard.test.tsx +78 -0
- package/src/__tests__/ArticleCategoryGrid.test.tsx +98 -0
- package/src/__tests__/ArticleSchemas.test.tsx +130 -0
- package/src/__tests__/ArticleSearchBar.test.tsx +79 -0
- package/src/__tests__/ArticlesHero.test.tsx +38 -0
- package/src/__tests__/ArticlesPage.test.tsx +155 -0
- package/src/__tests__/CategoryArticlesPage.test.tsx +156 -0
- package/src/__tests__/CommentForm.test.tsx +80 -0
- package/src/__tests__/CommentItem.test.tsx +149 -0
- package/src/__tests__/CommentsSection.test.tsx +118 -0
- package/src/__tests__/FeaturedArticle.test.tsx +85 -0
- package/src/__tests__/LatestArticles.test.tsx +157 -0
- package/src/__tests__/LatestArticlesSection.test.tsx +105 -0
- package/src/__tests__/jest-dom.d.ts +1 -0
- package/src/__tests__/seoUtils.test.ts +217 -0
- package/src/__tests__/useArticles.test.ts +207 -0
- package/src/articleTypes.ts +21 -0
- package/src/articlesConfig.ts +98 -0
- package/src/commentTypes.ts +14 -0
- package/src/index.ts +35 -0
- package/src/markdown.ts +314 -0
- package/src/seoUtils.ts +155 -0
- package/src/server-articles.ts +265 -0
- package/src/server.ts +25 -0
- package/src/useArticles.ts +93 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { LatestArticlesSection } from '../LatestArticlesSection'
|
|
3
|
+
import type { Article } from '../articleTypes'
|
|
4
|
+
import { render, screen, fireEvent } 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
|
+
function makeArticle(n: number): Article {
|
|
22
|
+
return {
|
|
23
|
+
slug: `article-${n}`,
|
|
24
|
+
title: `Article ${n}`,
|
|
25
|
+
excerpt: `Excerpt ${n}`,
|
|
26
|
+
date: '2025-01-01',
|
|
27
|
+
author: 'Author',
|
|
28
|
+
category: 'Test',
|
|
29
|
+
categories: ['Test'],
|
|
30
|
+
readTime: '3 min read',
|
|
31
|
+
featuredImage: `/articles/article-${n}/image.jpg`,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const noOp = jest.fn()
|
|
36
|
+
|
|
37
|
+
describe('LatestArticlesSection', () => {
|
|
38
|
+
beforeEach(() => jest.clearAllMocks())
|
|
39
|
+
|
|
40
|
+
it('shows "Latest Articles" heading when there is no search query', () => {
|
|
41
|
+
render(
|
|
42
|
+
<LatestArticlesSection articles={[makeArticle(1)]} searchQuery="" onClearSearch={noOp} />
|
|
43
|
+
)
|
|
44
|
+
expect(screen.getByRole('heading', { name: 'Latest Articles' })).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('shows "Search Results" heading when a search query is active', () => {
|
|
48
|
+
render(
|
|
49
|
+
<LatestArticlesSection articles={[makeArticle(1)]} searchQuery="voter" onClearSearch={noOp} />
|
|
50
|
+
)
|
|
51
|
+
expect(screen.getByRole('heading', { name: 'Search Results' })).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('shows the general description text when not searching', () => {
|
|
55
|
+
render(
|
|
56
|
+
<LatestArticlesSection articles={[makeArticle(1)]} searchQuery="" onClearSearch={noOp} />
|
|
57
|
+
)
|
|
58
|
+
expect(screen.getByText(/stay informed/i)).toBeInTheDocument()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('shows the article count in the description when searching', () => {
|
|
62
|
+
const articles = [makeArticle(1), makeArticle(2)]
|
|
63
|
+
render(<LatestArticlesSection articles={articles} searchQuery="voter" onClearSearch={noOp} />)
|
|
64
|
+
expect(screen.getByText('Found 2 articles matching your search.')).toBeInTheDocument()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('uses singular "article" when count is 1', () => {
|
|
68
|
+
render(
|
|
69
|
+
<LatestArticlesSection articles={[makeArticle(1)]} searchQuery="voter" onClearSearch={noOp} />
|
|
70
|
+
)
|
|
71
|
+
expect(screen.getByText('Found 1 article matching your search.')).toBeInTheDocument()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('renders article cards when articles are present', () => {
|
|
75
|
+
render(
|
|
76
|
+
<LatestArticlesSection
|
|
77
|
+
articles={[makeArticle(1), makeArticle(2)]}
|
|
78
|
+
searchQuery=""
|
|
79
|
+
onClearSearch={noOp}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
expect(screen.getByText('Article 1')).toBeInTheDocument()
|
|
83
|
+
expect(screen.getByText('Article 2')).toBeInTheDocument()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('shows the empty state when searching returns no results', () => {
|
|
87
|
+
render(<LatestArticlesSection articles={[]} searchQuery="xyzzy" onClearSearch={noOp} />)
|
|
88
|
+
expect(screen.getByText('No articles found')).toBeInTheDocument()
|
|
89
|
+
expect(screen.getByText(/xyzzy/)).toBeInTheDocument()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('does not show the empty state when not searching and articles is empty', () => {
|
|
93
|
+
render(<LatestArticlesSection articles={[]} searchQuery="" onClearSearch={noOp} />)
|
|
94
|
+
expect(screen.queryByText('No articles found')).not.toBeInTheDocument()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('calls onClearSearch when the Clear search button is clicked', () => {
|
|
98
|
+
const onClearSearch = jest.fn()
|
|
99
|
+
render(
|
|
100
|
+
<LatestArticlesSection articles={[]} searchQuery="xyzzy" onClearSearch={onClearSearch} />
|
|
101
|
+
)
|
|
102
|
+
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
|
103
|
+
expect(onClearSearch).toHaveBeenCalledTimes(1)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom'
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { ArticlesConfig } from '../articlesConfig'
|
|
2
|
+
import {
|
|
3
|
+
generateArticleStaticParams,
|
|
4
|
+
generateCategoryStaticParams,
|
|
5
|
+
generateArticleMetadata,
|
|
6
|
+
generateCategoryMetadata,
|
|
7
|
+
getArticleSitemapEntries,
|
|
8
|
+
} from '../seoUtils'
|
|
9
|
+
|
|
10
|
+
jest.mock('../server-articles', () => ({
|
|
11
|
+
getAvailableArticleSlugs: jest.fn(() => ['article-one', 'article-two']),
|
|
12
|
+
getAllCategories: jest.fn(async () => [
|
|
13
|
+
{ name: 'Campaigns', slug: 'campaigns', count: 2, featuredImage: '/campaigns.jpg' },
|
|
14
|
+
{ name: 'Volunteers', slug: 'volunteers', count: 1, featuredImage: '/volunteers.jpg' },
|
|
15
|
+
]),
|
|
16
|
+
getArticleMetadata: jest.fn(async (slug: string) => {
|
|
17
|
+
if (slug === 'article-one') {
|
|
18
|
+
return {
|
|
19
|
+
slug: 'article-one',
|
|
20
|
+
title: 'Article One',
|
|
21
|
+
excerpt: 'Excerpt for article one.',
|
|
22
|
+
date: '2025-01-15',
|
|
23
|
+
author: 'Jane Doe',
|
|
24
|
+
category: 'Campaigns',
|
|
25
|
+
categories: ['Campaigns'],
|
|
26
|
+
readTime: '3 min read',
|
|
27
|
+
featuredImage: '/articles/article-one/image.jpg',
|
|
28
|
+
tags: ['campaigns', 'strategy'],
|
|
29
|
+
htmlContent: '<p>content</p>',
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null
|
|
33
|
+
}),
|
|
34
|
+
getArticlesByCategory: jest.fn(async (slug: string) => {
|
|
35
|
+
if (slug === 'campaigns') {
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
slug: 'article-one',
|
|
39
|
+
title: 'Article One',
|
|
40
|
+
excerpt: 'Excerpt.',
|
|
41
|
+
date: '2025-01-15',
|
|
42
|
+
author: 'Jane Doe',
|
|
43
|
+
category: 'Campaigns',
|
|
44
|
+
categories: ['Campaigns'],
|
|
45
|
+
readTime: '3 min read',
|
|
46
|
+
featuredImage: '/articles/article-one/image.jpg',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
slug: 'article-two',
|
|
50
|
+
title: 'Article Two',
|
|
51
|
+
excerpt: 'Excerpt two.',
|
|
52
|
+
date: '2025-01-16',
|
|
53
|
+
author: 'John Smith',
|
|
54
|
+
category: 'Campaigns',
|
|
55
|
+
categories: ['Campaigns'],
|
|
56
|
+
readTime: '4 min read',
|
|
57
|
+
featuredImage: '/articles/article-two/image.jpg',
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
return []
|
|
62
|
+
}),
|
|
63
|
+
getAllArticles: jest.fn(async () => [
|
|
64
|
+
{
|
|
65
|
+
slug: 'article-one',
|
|
66
|
+
date: '2025-01-15',
|
|
67
|
+
title: 'Article One',
|
|
68
|
+
excerpt: '',
|
|
69
|
+
author: '',
|
|
70
|
+
category: '',
|
|
71
|
+
categories: [],
|
|
72
|
+
readTime: '',
|
|
73
|
+
featuredImage: '',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
slug: 'article-two',
|
|
77
|
+
date: '2025-01-16',
|
|
78
|
+
title: 'Article Two',
|
|
79
|
+
excerpt: '',
|
|
80
|
+
author: '',
|
|
81
|
+
category: '',
|
|
82
|
+
categories: [],
|
|
83
|
+
readTime: '',
|
|
84
|
+
featuredImage: '',
|
|
85
|
+
},
|
|
86
|
+
]),
|
|
87
|
+
}))
|
|
88
|
+
|
|
89
|
+
const config: ArticlesConfig = {
|
|
90
|
+
siteUrl: 'https://example.com',
|
|
91
|
+
siteName: 'Example Site',
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('generateArticleStaticParams', () => {
|
|
95
|
+
it('returns slug params from available articles', () => {
|
|
96
|
+
const result = generateArticleStaticParams()
|
|
97
|
+
expect(result).toEqual([{ slug: 'article-one' }, { slug: 'article-two' }])
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('generateCategoryStaticParams', () => {
|
|
102
|
+
it('returns category slug params', async () => {
|
|
103
|
+
const result = await generateCategoryStaticParams()
|
|
104
|
+
expect(result).toEqual([{ category: 'campaigns' }, { category: 'volunteers' }])
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('generateArticleMetadata', () => {
|
|
109
|
+
it('returns correct title and description using config values', async () => {
|
|
110
|
+
const meta = await generateArticleMetadata('article-one', config)
|
|
111
|
+
expect(meta.title).toBe('Article One | Example Site')
|
|
112
|
+
expect(meta.description).toBe('Excerpt for article one.')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('includes correct canonical URL', async () => {
|
|
116
|
+
const meta = await generateArticleMetadata('article-one', config)
|
|
117
|
+
expect(meta.alternates?.canonical).toBe('https://example.com/articles/article-one')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('builds OpenGraph with siteName from config', async () => {
|
|
121
|
+
const meta = await generateArticleMetadata('article-one', config)
|
|
122
|
+
expect((meta.openGraph as { siteName?: string })?.siteName).toBe('Example Site')
|
|
123
|
+
expect((meta.openGraph as { url?: string })?.url).toBe(
|
|
124
|
+
'https://example.com/articles/article-one'
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('resolves relative image paths using siteUrl', async () => {
|
|
129
|
+
const meta = await generateArticleMetadata('article-one', config)
|
|
130
|
+
const images = (meta.openGraph as { images?: { url: string }[] })?.images
|
|
131
|
+
expect(images?.[0].url).toBe('https://example.com/articles/article-one/image.jpg')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('returns not-found metadata for unknown slug', async () => {
|
|
135
|
+
const meta = await generateArticleMetadata('nonexistent', config)
|
|
136
|
+
expect(meta.title).toBe('Article Not Found')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('strips trailing slash from siteUrl before building URLs', async () => {
|
|
140
|
+
const meta = await generateArticleMetadata('article-one', {
|
|
141
|
+
...config,
|
|
142
|
+
siteUrl: 'https://example.com/',
|
|
143
|
+
})
|
|
144
|
+
expect(meta.alternates?.canonical).toBe('https://example.com/articles/article-one')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('generateCategoryMetadata', () => {
|
|
149
|
+
it('returns correct title using category name and siteName', async () => {
|
|
150
|
+
const meta = await generateCategoryMetadata('campaigns', config)
|
|
151
|
+
expect(meta.title).toBe('Campaigns Articles | Example Site')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('generates fallback description when no categoryDescriptions set', async () => {
|
|
155
|
+
const meta = await generateCategoryMetadata('campaigns', config)
|
|
156
|
+
expect(meta.description).toContain('2 articles')
|
|
157
|
+
expect(meta.description).toContain('Campaigns')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('uses short description from categoryDescriptions when provided', async () => {
|
|
161
|
+
const cfg: ArticlesConfig = {
|
|
162
|
+
...config,
|
|
163
|
+
categoryDescriptions: {
|
|
164
|
+
campaigns: { short: 'Campaign strategy articles.', long: 'A longer description.' },
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
const meta = await generateCategoryMetadata('campaigns', cfg)
|
|
168
|
+
expect(meta.description).toBe('Campaign strategy articles.')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('includes canonical URL', async () => {
|
|
172
|
+
const meta = await generateCategoryMetadata('campaigns', config)
|
|
173
|
+
expect(meta.alternates?.canonical).toBe('https://example.com/articles/category/campaigns')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('returns Category Not Found for empty category', async () => {
|
|
177
|
+
const meta = await generateCategoryMetadata('nonexistent', config)
|
|
178
|
+
expect(meta.title).toBe('Category Not Found')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('getArticleSitemapEntries', () => {
|
|
183
|
+
it('returns article and category entries prefixed with baseUrl', async () => {
|
|
184
|
+
const entries = await getArticleSitemapEntries('https://example.com')
|
|
185
|
+
// 2 articles + 2 categories
|
|
186
|
+
expect(entries).toHaveLength(4)
|
|
187
|
+
|
|
188
|
+
const articleUrls = entries.filter((e) => !e.url.includes('/category/')).map((e) => e.url)
|
|
189
|
+
expect(articleUrls).toEqual([
|
|
190
|
+
'https://example.com/articles/article-one',
|
|
191
|
+
'https://example.com/articles/article-two',
|
|
192
|
+
])
|
|
193
|
+
|
|
194
|
+
const categoryUrls = entries.filter((e) => e.url.includes('/category/')).map((e) => e.url)
|
|
195
|
+
expect(categoryUrls).toEqual([
|
|
196
|
+
'https://example.com/articles/category/campaigns',
|
|
197
|
+
'https://example.com/articles/category/volunteers',
|
|
198
|
+
])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('sets priority 0.8 for articles and 0.7 for categories', async () => {
|
|
202
|
+
const entries = await getArticleSitemapEntries('https://example.com')
|
|
203
|
+
const articleEntry = entries.find((e) => !e.url.includes('/category/'))!
|
|
204
|
+
const categoryEntry = entries.find((e) => e.url.includes('/category/'))!
|
|
205
|
+
expect(articleEntry.priority).toBe(0.8)
|
|
206
|
+
expect(articleEntry.changeFrequency).toBe('weekly')
|
|
207
|
+
expect(categoryEntry.priority).toBe(0.7)
|
|
208
|
+
expect(categoryEntry.changeFrequency).toBe('weekly')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('returns empty array when fetching throws', async () => {
|
|
212
|
+
const { getAllArticles } = jest.requireMock('../server-articles')
|
|
213
|
+
getAllArticles.mockRejectedValueOnce(new Error('filesystem error'))
|
|
214
|
+
const entries = await getArticleSitemapEntries('https://example.com')
|
|
215
|
+
expect(entries).toEqual([])
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useArticles } from '../useArticles'
|
|
2
|
+
import { renderHook, act, waitFor } from '@testing-library/react'
|
|
3
|
+
|
|
4
|
+
const mockArticles = [
|
|
5
|
+
{
|
|
6
|
+
slug: 'article-1',
|
|
7
|
+
title: 'Article 1',
|
|
8
|
+
excerpt: 'Excerpt 1',
|
|
9
|
+
date: '2025-01-01',
|
|
10
|
+
author: 'Author',
|
|
11
|
+
category: 'Campaigns',
|
|
12
|
+
categories: ['Campaigns'],
|
|
13
|
+
readTime: '3 min read',
|
|
14
|
+
featuredImage: '/articles/article-1/image.jpg',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
slug: 'article-2',
|
|
18
|
+
title: 'Article 2',
|
|
19
|
+
excerpt: 'Excerpt 2',
|
|
20
|
+
date: '2025-01-02',
|
|
21
|
+
author: 'Author',
|
|
22
|
+
category: 'Campaigns',
|
|
23
|
+
categories: ['Campaigns'],
|
|
24
|
+
readTime: '4 min read',
|
|
25
|
+
featuredImage: '/articles/article-2/image.jpg',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
slug: 'article-3',
|
|
29
|
+
title: 'Article 3',
|
|
30
|
+
excerpt: 'Excerpt 3',
|
|
31
|
+
date: '2025-01-03',
|
|
32
|
+
author: 'Author',
|
|
33
|
+
category: 'Volunteers',
|
|
34
|
+
categories: ['Volunteers'],
|
|
35
|
+
readTime: '2 min read',
|
|
36
|
+
featuredImage: '/articles/article-3/image.jpg',
|
|
37
|
+
},
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
describe('useArticles', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
jest.clearAllMocks()
|
|
43
|
+
globalThis.fetch = jest.fn()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('starts in loading state with empty articles', () => {
|
|
47
|
+
;(globalThis.fetch as jest.Mock).mockReturnValue(new Promise(() => {}))
|
|
48
|
+
const { result } = renderHook(() => useArticles())
|
|
49
|
+
expect(result.current.loading).toBe(true)
|
|
50
|
+
expect(result.current.articles).toEqual([])
|
|
51
|
+
expect(result.current.error).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('fetches articles on mount and populates state', async () => {
|
|
55
|
+
;(globalThis.fetch as jest.Mock).mockResolvedValue({
|
|
56
|
+
ok: true,
|
|
57
|
+
json: async () => ({ articles: mockArticles }),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const { result } = renderHook(() => useArticles())
|
|
61
|
+
|
|
62
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
63
|
+
|
|
64
|
+
expect(result.current.articles).toHaveLength(3)
|
|
65
|
+
expect(globalThis.fetch).toHaveBeenCalledWith('/api/articles')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('derives categories from the fetched article list', async () => {
|
|
69
|
+
;(globalThis.fetch as jest.Mock).mockResolvedValue({
|
|
70
|
+
ok: true,
|
|
71
|
+
json: async () => ({ articles: mockArticles }),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const { result } = renderHook(() => useArticles())
|
|
75
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
76
|
+
|
|
77
|
+
expect(result.current.categories).toHaveLength(2)
|
|
78
|
+
// Campaigns has 2 articles so it sorts first
|
|
79
|
+
expect(result.current.categories[0].name).toBe('Campaigns')
|
|
80
|
+
expect(result.current.categories[0].count).toBe(2)
|
|
81
|
+
expect(result.current.categories[1].name).toBe('Volunteers')
|
|
82
|
+
expect(result.current.categories[1].count).toBe(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('sets error state when fetch fails', async () => {
|
|
86
|
+
;(globalThis.fetch as jest.Mock).mockResolvedValue({ ok: false })
|
|
87
|
+
|
|
88
|
+
const { result } = renderHook(() => useArticles())
|
|
89
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
90
|
+
|
|
91
|
+
expect(result.current.error).toBe('Failed to fetch articles')
|
|
92
|
+
expect(result.current.articles).toEqual([])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('debounced search', () => {
|
|
96
|
+
beforeEach(() => jest.useFakeTimers())
|
|
97
|
+
afterEach(() => jest.useRealTimers())
|
|
98
|
+
|
|
99
|
+
it('does not fetch immediately when 3+ characters are typed', async () => {
|
|
100
|
+
;(globalThis.fetch as jest.Mock).mockResolvedValue({
|
|
101
|
+
ok: true,
|
|
102
|
+
json: async () => ({ articles: mockArticles }),
|
|
103
|
+
})
|
|
104
|
+
const { result } = renderHook(() => useArticles())
|
|
105
|
+
// Drain the initial mount fetch with real async resolution
|
|
106
|
+
await act(async () => {
|
|
107
|
+
await Promise.resolve()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
act(() => result.current.handleSearch('cam'))
|
|
111
|
+
|
|
112
|
+
// Still only the initial fetch — debounce hasn't fired yet
|
|
113
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
|
|
114
|
+
expect(result.current.searchQuery).toBe('cam')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('fetches after 1 second of inactivity', async () => {
|
|
118
|
+
;(globalThis.fetch as jest.Mock)
|
|
119
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ articles: mockArticles }) })
|
|
120
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ articles: [mockArticles[0]] }) })
|
|
121
|
+
const { result } = renderHook(() => useArticles())
|
|
122
|
+
await act(async () => {
|
|
123
|
+
await Promise.resolve()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
act(() => result.current.handleSearch('cam'))
|
|
127
|
+
act(() => jest.advanceTimersByTime(1000))
|
|
128
|
+
|
|
129
|
+
await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2))
|
|
130
|
+
expect(globalThis.fetch).toHaveBeenLastCalledWith('/api/articles?q=cam')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('resets the timer when the user keeps typing — only one fetch fires', async () => {
|
|
134
|
+
;(globalThis.fetch as jest.Mock).mockResolvedValue({
|
|
135
|
+
ok: true,
|
|
136
|
+
json: async () => ({ articles: mockArticles }),
|
|
137
|
+
})
|
|
138
|
+
const { result } = renderHook(() => useArticles())
|
|
139
|
+
await act(async () => {
|
|
140
|
+
await Promise.resolve()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
act(() => {
|
|
144
|
+
result.current.handleSearch('cam')
|
|
145
|
+
jest.advanceTimersByTime(500)
|
|
146
|
+
result.current.handleSearch('camp')
|
|
147
|
+
jest.advanceTimersByTime(500)
|
|
148
|
+
result.current.handleSearch('campa')
|
|
149
|
+
jest.advanceTimersByTime(1000) // only this final 1s fires a fetch
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2))
|
|
153
|
+
expect(globalThis.fetch).toHaveBeenLastCalledWith('/api/articles?q=campa')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('does not fetch for 1-2 character queries even after 1 second', async () => {
|
|
157
|
+
;(globalThis.fetch as jest.Mock).mockResolvedValue({
|
|
158
|
+
ok: true,
|
|
159
|
+
json: async () => ({ articles: mockArticles }),
|
|
160
|
+
})
|
|
161
|
+
const { result } = renderHook(() => useArticles())
|
|
162
|
+
await act(async () => {
|
|
163
|
+
await Promise.resolve()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
act(() => {
|
|
167
|
+
result.current.handleSearch('ca')
|
|
168
|
+
jest.advanceTimersByTime(1000)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1) // only the initial mount fetch
|
|
172
|
+
expect(result.current.searchQuery).toBe('ca')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('fetches immediately (no debounce) when query is cleared', async () => {
|
|
176
|
+
;(globalThis.fetch as jest.Mock).mockResolvedValue({
|
|
177
|
+
ok: true,
|
|
178
|
+
json: async () => ({ articles: mockArticles }),
|
|
179
|
+
})
|
|
180
|
+
const { result } = renderHook(() => useArticles())
|
|
181
|
+
await act(async () => {
|
|
182
|
+
await Promise.resolve()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
act(() => result.current.handleSearch(''))
|
|
186
|
+
|
|
187
|
+
// Second fetch fires without advancing timers
|
|
188
|
+
await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2))
|
|
189
|
+
expect(globalThis.fetch).toHaveBeenLastCalledWith('/api/articles')
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('refetches all articles when handleSearch is called with empty string', async () => {
|
|
194
|
+
;(globalThis.fetch as jest.Mock).mockResolvedValue({
|
|
195
|
+
ok: true,
|
|
196
|
+
json: async () => ({ articles: mockArticles }),
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const { result } = renderHook(() => useArticles())
|
|
200
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
201
|
+
|
|
202
|
+
act(() => result.current.handleSearch(''))
|
|
203
|
+
await waitFor(() => expect(result.current.loading).toBe(false))
|
|
204
|
+
|
|
205
|
+
expect(globalThis.fetch).toHaveBeenLastCalledWith('/api/articles')
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface Article {
|
|
2
|
+
slug: string
|
|
3
|
+
title: string
|
|
4
|
+
excerpt: string
|
|
5
|
+
date?: string
|
|
6
|
+
author: string
|
|
7
|
+
category: string
|
|
8
|
+
categories: string[]
|
|
9
|
+
readTime: string
|
|
10
|
+
featuredImage: string
|
|
11
|
+
tags?: string[]
|
|
12
|
+
content?: string
|
|
13
|
+
htmlContent?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CategoryInfo {
|
|
17
|
+
name: string
|
|
18
|
+
slug: string
|
|
19
|
+
count: number
|
|
20
|
+
featuredImage: string
|
|
21
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Keys for each renderable section of the articles listing page. */
|
|
2
|
+
export type ArticlesSection =
|
|
3
|
+
| 'hero'
|
|
4
|
+
| 'search'
|
|
5
|
+
| 'featured'
|
|
6
|
+
| 'latest'
|
|
7
|
+
| 'categories'
|
|
8
|
+
| 'newsletter'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* CSS custom-property overrides applied to the library's wrapper div.
|
|
12
|
+
* All fields are optional; omitted fields fall back to the consuming app's Tailwind theme.
|
|
13
|
+
*/
|
|
14
|
+
export interface ArticlesTheme {
|
|
15
|
+
/** Font family for the articles section. Example: `"'Inter', sans-serif"` */
|
|
16
|
+
fontFamily?: string
|
|
17
|
+
/** Color for article card titles and section headings. Example: `'#111827'` */
|
|
18
|
+
headerColor?: string
|
|
19
|
+
/** Color for body and excerpt text. Example: `'#6b7280'` */
|
|
20
|
+
textColor?: string
|
|
21
|
+
/** Background color for sections and cards. Example: `'#ffffff'` */
|
|
22
|
+
backgroundColor?: string
|
|
23
|
+
/** Color for "Read More" links and inline links. Example: `'#4f46e5'` */
|
|
24
|
+
linkColor?: string
|
|
25
|
+
/** Hover color for links. Example: `'#4338ca'` */
|
|
26
|
+
linkHoverColor?: string
|
|
27
|
+
/** Text decoration for links. Values: `'underline'` | `'none'` */
|
|
28
|
+
linkTextDecoration?: string
|
|
29
|
+
/** Font weight for headings. Example: `700` */
|
|
30
|
+
headerFontWeight?: string | number
|
|
31
|
+
/** Font size for article card titles. Example: `'1.25rem'` */
|
|
32
|
+
headerFontSize?: string
|
|
33
|
+
/** Font size for body text. Example: `'1rem'` */
|
|
34
|
+
bodyFontSize?: string
|
|
35
|
+
/** Line height for body text. Example: `'1.75'` */
|
|
36
|
+
lineHeight?: string
|
|
37
|
+
/** Border radius for cards. Example: `'0.5rem'` */
|
|
38
|
+
borderRadius?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Per-category text used in meta descriptions and on the category hero page. */
|
|
42
|
+
export interface CategoryDescription {
|
|
43
|
+
/** Short description — used in meta description and category grid card. */
|
|
44
|
+
short: string
|
|
45
|
+
/** Long description — rendered as a hero paragraph on the category page. */
|
|
46
|
+
long?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Controls the comments feature. Attach to `ArticlesConfig.comments`. */
|
|
50
|
+
export interface CommentsConfig {
|
|
51
|
+
/** Set to `true` to enable comments globally across all articles. */
|
|
52
|
+
enabled: boolean
|
|
53
|
+
/** Maximum reply nesting depth. Default: `1` (replies to top-level only). */
|
|
54
|
+
maxDepth?: number
|
|
55
|
+
/**
|
|
56
|
+
* Per-article overrides keyed by slug.
|
|
57
|
+
* Example: `{ 'sensitive-article': false }` disables comments on that article only.
|
|
58
|
+
*/
|
|
59
|
+
perArticleOverride?: Record<string, boolean>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Top-level configuration object. Pass one instance to every library component. */
|
|
63
|
+
export interface ArticlesConfig {
|
|
64
|
+
/** Canonical base URL of the site, used in metadata and JSON-LD. Example: `'https://yoursite.com'` */
|
|
65
|
+
siteUrl: string
|
|
66
|
+
/** Site name shown in metadata titles and JSON-LD publisher fields. Example: `'Vox Populus'` */
|
|
67
|
+
siteName: string
|
|
68
|
+
/** Number of articles shown per page in the grid and loaded on each "Load more" click. Default: `6` */
|
|
69
|
+
pageSize?: number
|
|
70
|
+
/** Number of category cards shown before a "Load more categories" button appears. Default: `8` */
|
|
71
|
+
categoriesPageSize?: number
|
|
72
|
+
/**
|
|
73
|
+
* Ordered list of sections to render on the articles listing page.
|
|
74
|
+
* Omit a key to hide that section entirely. Default: `['hero','search','featured','latest','categories']`
|
|
75
|
+
*/
|
|
76
|
+
layout?: ArticlesSection[]
|
|
77
|
+
/** CSS custom-property overrides for colors, fonts, and spacing. All fields optional. */
|
|
78
|
+
theme?: ArticlesTheme
|
|
79
|
+
/**
|
|
80
|
+
* Descriptive text for each category, keyed by category slug.
|
|
81
|
+
* A plain string is treated as the short description only.
|
|
82
|
+
* Example: `{ 'campaigns': { short: 'Campaign tips', long: 'Full paragraph...' } }`
|
|
83
|
+
*/
|
|
84
|
+
categoryDescriptions?: Record<string, string | CategoryDescription>
|
|
85
|
+
/** Comments configuration. Omit or set `enabled: false` to hide comments entirely. */
|
|
86
|
+
comments?: CommentsConfig
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const DEFAULT_PAGE_SIZE = 6
|
|
90
|
+
export const DEFAULT_CATEGORIES_PAGE_SIZE = 8
|
|
91
|
+
|
|
92
|
+
export const DEFAULT_LAYOUT: ArticlesSection[] = [
|
|
93
|
+
'hero',
|
|
94
|
+
'search',
|
|
95
|
+
'featured',
|
|
96
|
+
'latest',
|
|
97
|
+
'categories',
|
|
98
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ArticleComment {
|
|
2
|
+
id: string
|
|
3
|
+
articleSlug: string
|
|
4
|
+
body: string
|
|
5
|
+
isDeleted: boolean
|
|
6
|
+
createdAt: string
|
|
7
|
+
authorId: string
|
|
8
|
+
authorName: string
|
|
9
|
+
parentId: string | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ArticleCommentWithReplies extends ArticleComment {
|
|
13
|
+
replies: ArticleComment[]
|
|
14
|
+
}
|