@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,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
+ }