@fullstackdatasolutions/articles 0.3.0 → 0.4.3

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.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react'
5
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
6
+ import { ArticleSocialShare } from '../ArticleSocialShare'
7
+
8
+ const mockOpen = jest.fn()
9
+ const mockWriteText = jest.fn()
10
+
11
+ beforeEach(() => {
12
+ jest.clearAllMocks()
13
+ window.open = mockOpen
14
+ Object.defineProperty(navigator, 'clipboard', {
15
+ value: { writeText: mockWriteText },
16
+ writable: true,
17
+ configurable: true,
18
+ })
19
+ mockWriteText.mockResolvedValue(undefined)
20
+ })
21
+
22
+ const defaultProps = {
23
+ title: 'My Test Article',
24
+ url: 'https://example.com/articles/my-test-article',
25
+ excerpt: 'A short excerpt.',
26
+ }
27
+
28
+ describe('ArticleSocialShare', () => {
29
+ describe('rendering', () => {
30
+ it('renders the "Share this article" heading', () => {
31
+ render(<ArticleSocialShare {...defaultProps} />)
32
+ expect(screen.getByText('Share this article')).toBeInTheDocument()
33
+ })
34
+
35
+ it('renders all 8 share buttons', () => {
36
+ render(<ArticleSocialShare {...defaultProps} />)
37
+ expect(screen.getByRole('button', { name: /linkedin/i })).toBeInTheDocument()
38
+ expect(screen.getByRole('button', { name: /facebook/i })).toBeInTheDocument()
39
+ expect(screen.getByRole('button', { name: /twitter/i })).toBeInTheDocument()
40
+ expect(screen.getByRole('button', { name: /reddit/i })).toBeInTheDocument()
41
+ expect(screen.getByRole('button', { name: /whatsapp/i })).toBeInTheDocument()
42
+ expect(screen.getByRole('button', { name: /telegram/i })).toBeInTheDocument()
43
+ expect(screen.getByRole('button', { name: /email/i })).toBeInTheDocument()
44
+ expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument()
45
+ })
46
+ })
47
+
48
+ describe('share window buttons', () => {
49
+ it('clicking LinkedIn calls window.open with the correct LinkedIn URL', () => {
50
+ render(<ArticleSocialShare {...defaultProps} />)
51
+ fireEvent.click(screen.getByRole('button', { name: /linkedin/i }))
52
+ const encodedUrl = encodeURIComponent(defaultProps.url)
53
+ expect(mockOpen).toHaveBeenCalledWith(
54
+ `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
55
+ '_blank',
56
+ 'width=600,height=400,scrollbars=yes,resizable=yes'
57
+ )
58
+ })
59
+
60
+ it('clicking Facebook calls window.open with the correct Facebook URL', () => {
61
+ render(<ArticleSocialShare {...defaultProps} />)
62
+ fireEvent.click(screen.getByRole('button', { name: /facebook/i }))
63
+ const encodedUrl = encodeURIComponent(defaultProps.url)
64
+ expect(mockOpen).toHaveBeenCalledWith(
65
+ `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
66
+ '_blank',
67
+ 'width=600,height=400,scrollbars=yes,resizable=yes'
68
+ )
69
+ })
70
+
71
+ it('clicking Twitter calls window.open with correct URL and encoded title', () => {
72
+ render(<ArticleSocialShare {...defaultProps} />)
73
+ fireEvent.click(screen.getByRole('button', { name: /twitter/i }))
74
+ const encodedTitle = encodeURIComponent(defaultProps.title)
75
+ const encodedUrl = encodeURIComponent(defaultProps.url)
76
+ expect(mockOpen).toHaveBeenCalledWith(
77
+ `https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`,
78
+ '_blank',
79
+ 'width=600,height=400,scrollbars=yes,resizable=yes'
80
+ )
81
+ })
82
+
83
+ it('clicking Reddit calls window.open with correct URL and encoded title', () => {
84
+ render(<ArticleSocialShare {...defaultProps} />)
85
+ fireEvent.click(screen.getByRole('button', { name: /reddit/i }))
86
+ const encodedTitle = encodeURIComponent(defaultProps.title)
87
+ const encodedUrl = encodeURIComponent(defaultProps.url)
88
+ expect(mockOpen).toHaveBeenCalledWith(
89
+ `https://reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`,
90
+ '_blank',
91
+ 'width=600,height=400,scrollbars=yes,resizable=yes'
92
+ )
93
+ })
94
+
95
+ it('clicking WhatsApp calls window.open with the correct WhatsApp URL', () => {
96
+ render(<ArticleSocialShare {...defaultProps} />)
97
+ fireEvent.click(screen.getByRole('button', { name: /whatsapp/i }))
98
+ const encodedTitle = encodeURIComponent(defaultProps.title)
99
+ const encodedUrl = encodeURIComponent(defaultProps.url)
100
+ expect(mockOpen).toHaveBeenCalledWith(
101
+ `https://wa.me/?text=${encodedTitle}%20${encodedUrl}`,
102
+ '_blank',
103
+ 'width=600,height=400,scrollbars=yes,resizable=yes'
104
+ )
105
+ })
106
+
107
+ it('clicking Telegram calls window.open with the correct Telegram URL', () => {
108
+ render(<ArticleSocialShare {...defaultProps} />)
109
+ fireEvent.click(screen.getByRole('button', { name: /telegram/i }))
110
+ const encodedTitle = encodeURIComponent(defaultProps.title)
111
+ const encodedUrl = encodeURIComponent(defaultProps.url)
112
+ expect(mockOpen).toHaveBeenCalledWith(
113
+ `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`,
114
+ '_blank',
115
+ 'width=600,height=400,scrollbars=yes,resizable=yes'
116
+ )
117
+ })
118
+ })
119
+
120
+ describe('Copy Link button', () => {
121
+ it('clicking Copy Link calls navigator.clipboard.writeText with the URL', async () => {
122
+ render(<ArticleSocialShare {...defaultProps} />)
123
+ fireEvent.click(screen.getByRole('button', { name: /copy link/i }))
124
+ await waitFor(() => {
125
+ expect(mockWriteText).toHaveBeenCalledWith(defaultProps.url)
126
+ })
127
+ })
128
+
129
+ it('shows "Copied!" text after clicking Copy Link', async () => {
130
+ render(<ArticleSocialShare {...defaultProps} />)
131
+ fireEvent.click(screen.getByRole('button', { name: /copy link/i }))
132
+ await waitFor(() => {
133
+ expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument()
134
+ })
135
+ })
136
+
137
+ it('does not throw when clipboard.writeText rejects', async () => {
138
+ mockWriteText.mockRejectedValueOnce(new Error('not allowed'))
139
+ render(<ArticleSocialShare {...defaultProps} />)
140
+ // should not throw - catch block silently swallows the error
141
+ expect(() =>
142
+ fireEvent.click(screen.getByRole('button', { name: /copy link/i }))
143
+ ).not.toThrow()
144
+ // Copy Link label is still present (did not toggle to Copied!)
145
+ await waitFor(() =>
146
+ expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument()
147
+ )
148
+ })
149
+ })
150
+
151
+ describe('Email button', () => {
152
+ it('clicking Email sets location.href to a mailto link', () => {
153
+ const originalLocation = globalThis.location
154
+ delete (globalThis as Record<string, unknown>).location
155
+ ;(globalThis as Record<string, unknown>).location = { href: '' }
156
+
157
+ render(<ArticleSocialShare {...defaultProps} />)
158
+ fireEvent.click(screen.getByRole('button', { name: /email/i }))
159
+
160
+ expect((globalThis.location as { href: string }).href).toContain('mailto:')
161
+ expect((globalThis.location as { href: string }).href).toContain(
162
+ encodeURIComponent(defaultProps.title)
163
+ )
164
+ ;(globalThis as Record<string, unknown>).location = originalLocation
165
+ })
166
+ })
167
+
168
+ describe('shareMessage prop', () => {
169
+ it('renders shareMessage as a paragraph when provided', () => {
170
+ render(<ArticleSocialShare {...defaultProps} shareMessage="Help spread the word!" />)
171
+ expect(screen.getByText('Help spread the word!')).toBeInTheDocument()
172
+ })
173
+
174
+ it('does not render a shareMessage paragraph when the prop is omitted', () => {
175
+ render(<ArticleSocialShare {...defaultProps} />)
176
+ expect(screen.queryByText('Help spread the word!')).not.toBeInTheDocument()
177
+ })
178
+ })
179
+
180
+ describe('excerpt prop', () => {
181
+ it('renders without error when excerpt is not provided', () => {
182
+ render(<ArticleSocialShare title="No Excerpt Title" url="https://example.com/no-excerpt" />)
183
+ expect(screen.getByText('Share this article')).toBeInTheDocument()
184
+ expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument()
185
+ })
186
+ })
187
+ })
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
2
  import { ArticlesPage } from '../ArticlesPage'
3
3
  import type { ArticlesConfig } from '../articlesConfig'
4
- import { render, screen, waitFor } from '@testing-library/react'
4
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react'
5
5
 
6
6
  jest.mock('next/link', () => ({
7
7
  __esModule: true,
@@ -127,6 +127,152 @@ describe('ArticlesPage', () => {
127
127
  expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
128
128
  })
129
129
 
130
+ it('clicking Try again in the error state resets the search', async () => {
131
+ // First call fails, second call succeeds (after Try again click)
132
+ ;(globalThis.fetch as jest.Mock).mockResolvedValueOnce({ ok: false }).mockResolvedValue({
133
+ ok: true,
134
+ json: async () => ({ articles: mockArticles }),
135
+ })
136
+ render(<ArticlesPage config={{ ...baseConfig, layout: ['latest'] }} />)
137
+
138
+ await waitFor(() => expect(screen.getByText('Error Loading Articles')).toBeInTheDocument())
139
+
140
+ // Click "Try again" - invokes the onClick={() => handleSearch('')} on line 116
141
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
142
+
143
+ await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
144
+ })
145
+
146
+ it('clicking Clear search in LatestArticlesSection invokes onClearSearch', async () => {
147
+ jest.useFakeTimers({ legacyFakeTimers: false })
148
+
149
+ // Initial load returns articles; search returns empty
150
+ ;(globalThis.fetch as jest.Mock)
151
+ .mockResolvedValueOnce({
152
+ ok: true,
153
+ json: async () => ({ articles: mockArticles }),
154
+ })
155
+ .mockResolvedValue({
156
+ ok: true,
157
+ json: async () => ({ articles: [] }),
158
+ })
159
+ render(<ArticlesPage config={{ ...baseConfig, layout: ['search', 'latest'] }} />)
160
+
161
+ // Let the initial fetch resolve - use runAllTimersAsync to flush timers + microtasks
162
+ await (jest as unknown as { runAllTimersAsync(): Promise<void> }).runAllTimersAsync()
163
+ await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
164
+
165
+ // Type a search query (3+ chars triggers debounce)
166
+ const searchInput = screen.getByPlaceholderText('Search articles (3+ characters required)...')
167
+ fireEvent.change(searchInput, { target: { value: 'xyz' } })
168
+
169
+ // Advance timers past the 1000ms debounce and flush microtasks
170
+ await (jest as unknown as { runAllTimersAsync(): Promise<void> }).runAllTimersAsync()
171
+
172
+ // Now articles=[] and searchQuery='xyz' so "Clear search" button appears
173
+ await waitFor(() =>
174
+ expect(screen.getByRole('button', { name: /clear search/i })).toBeInTheDocument()
175
+ )
176
+
177
+ // Click "Clear search" - triggers onClearSearch={() => handleSearch('')} on line 131
178
+ fireEvent.click(screen.getByRole('button', { name: /clear search/i }))
179
+
180
+ await (jest as unknown as { runAllTimersAsync(): Promise<void> }).runAllTimersAsync()
181
+
182
+ jest.useRealTimers()
183
+
184
+ await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
185
+ })
186
+
187
+ it('renders null for an unknown/unhandled layout section', async () => {
188
+ mockFetchSuccess()
189
+ // 'newsletter' is a valid ArticlesSection type but has no case in the switch -> default: return null
190
+ const { container } = render(
191
+ <ArticlesPage config={{ ...baseConfig, layout: ['newsletter'] }} />
192
+ )
193
+ await waitFor(() => {})
194
+ // The wrapper div exists but has no section content
195
+ expect(container.firstChild).toBeInTheDocument()
196
+ expect(container.querySelector('section')).not.toBeInTheDocument()
197
+ })
198
+
199
+ it('applies all theme properties as CSS custom properties', async () => {
200
+ mockFetchSuccess()
201
+ const config: ArticlesConfig = {
202
+ ...baseConfig,
203
+ layout: ['latest'],
204
+ theme: {
205
+ fontFamily: "'Inter', sans-serif",
206
+ headerColor: '#111827',
207
+ textColor: '#6b7280',
208
+ backgroundColor: '#ffffff',
209
+ linkColor: '#4f46e5',
210
+ linkHoverColor: '#4338ca',
211
+ linkTextDecoration: 'underline',
212
+ headerFontWeight: 700,
213
+ headerFontSize: '1.25rem',
214
+ bodyFontSize: '1rem',
215
+ lineHeight: '1.75',
216
+ borderRadius: '0.5rem',
217
+ },
218
+ }
219
+ const { container } = render(<ArticlesPage config={config} />)
220
+
221
+ await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
222
+
223
+ const wrapper = container.firstChild as HTMLElement
224
+ expect(wrapper.style.getPropertyValue('--articles-font-family')).toBe("'Inter', sans-serif")
225
+ expect(wrapper.style.getPropertyValue('--articles-text-color')).toBe('#6b7280')
226
+ expect(wrapper.style.getPropertyValue('--articles-bg-color')).toBe('#ffffff')
227
+ expect(wrapper.style.getPropertyValue('--articles-link-hover-color')).toBe('#4338ca')
228
+ expect(wrapper.style.getPropertyValue('--articles-link-decoration')).toBe('underline')
229
+ expect(wrapper.style.getPropertyValue('--articles-header-font-weight')).toBe('700')
230
+ expect(wrapper.style.getPropertyValue('--articles-header-font-size')).toBe('1.25rem')
231
+ expect(wrapper.style.getPropertyValue('--articles-body-font-size')).toBe('1rem')
232
+ expect(wrapper.style.getPropertyValue('--articles-line-height')).toBe('1.75')
233
+ expect(wrapper.style.getPropertyValue('--articles-border-radius')).toBe('0.5rem')
234
+ })
235
+
236
+ it('hides categories section when search query is active', async () => {
237
+ ;(globalThis.fetch as jest.Mock).mockResolvedValue({
238
+ ok: true,
239
+ json: async () => ({ articles: mockArticles }),
240
+ })
241
+ render(<ArticlesPage config={{ ...baseConfig, layout: ['search', 'categories'] }} />)
242
+
243
+ await waitFor(() => expect(screen.getByText('Browse by Category')).toBeInTheDocument())
244
+
245
+ const searchInput = screen.getByPlaceholderText('Search articles (3+ characters required)...')
246
+ fireEvent.change(searchInput, { target: { value: 'vol' } })
247
+
248
+ // searchQuery set synchronously -> categories returns null
249
+ await waitFor(() => expect(screen.queryByText('Browse by Category')).not.toBeInTheDocument())
250
+ })
251
+
252
+ it('uses DEFAULT_PAGE_SIZE when pageSize is not in config', async () => {
253
+ mockFetchSuccess()
254
+ // Omit pageSize to hit the ?? DEFAULT_PAGE_SIZE branch
255
+ const config: ArticlesConfig = {
256
+ siteUrl: 'https://example.com',
257
+ siteName: 'Test Site',
258
+ layout: ['latest'],
259
+ }
260
+ render(<ArticlesPage config={config} />)
261
+ await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
262
+ })
263
+
264
+ it('uses provided categoriesPageSize when set', async () => {
265
+ mockFetchSuccess()
266
+ // Set categoriesPageSize to hit the left branch of ?? DEFAULT_CATEGORIES_PAGE_SIZE
267
+ const config: ArticlesConfig = {
268
+ ...baseConfig,
269
+ layout: ['categories'],
270
+ categoriesPageSize: 4,
271
+ }
272
+ render(<ArticlesPage config={config} />)
273
+ await waitFor(() => expect(screen.getByText('Browse by Category')).toBeInTheDocument())
274
+ })
275
+
130
276
  it('uses pageSize from config', async () => {
131
277
  const manyArticles = Array.from({ length: 10 }, (_, i) => ({
132
278
  ...mockArticles[0],
@@ -21,6 +21,13 @@ const baseComment: ArticleCommentWithReplies = {
21
21
  replies: [],
22
22
  }
23
23
 
24
+ function makeComment(msAgo: number): ArticleCommentWithReplies {
25
+ return {
26
+ ...baseComment,
27
+ createdAt: new Date(Date.now() - msAgo).toISOString(),
28
+ }
29
+ }
30
+
24
31
  describe('CommentItem', () => {
25
32
  beforeEach(() => jest.clearAllMocks())
26
33
 
@@ -146,4 +153,107 @@ describe('CommentItem', () => {
146
153
  fireEvent.click(screen.getAllByRole('button', { name: /cancel/i })[0])
147
154
  expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
148
155
  })
156
+
157
+ it('clicking the form Cancel button hides the reply form via onCancel', () => {
158
+ render(
159
+ <CommentItem
160
+ comment={baseComment}
161
+ articleSlug="my-article"
162
+ currentUserId="other@example.com"
163
+ onRefresh={jest.fn()}
164
+ />
165
+ )
166
+ fireEvent.click(screen.getByRole('button', { name: /reply/i }))
167
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
168
+
169
+ // The CommentForm renders its own Cancel button (the second Cancel button)
170
+ const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
171
+ // Click the last one - that is the CommentForm's Cancel, which calls onCancel
172
+ fireEvent.click(cancelButtons[cancelButtons.length - 1])
173
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
174
+ })
175
+
176
+ it('calls onRefresh and hides the form after reply is submitted', async () => {
177
+ const onRefresh = jest.fn()
178
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) })
179
+
180
+ render(
181
+ <CommentItem
182
+ comment={baseComment}
183
+ articleSlug="my-article"
184
+ currentUserId="other@example.com"
185
+ onRefresh={onRefresh}
186
+ />
187
+ )
188
+ // Open reply form
189
+ fireEvent.click(screen.getByRole('button', { name: /reply/i }))
190
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
191
+
192
+ // Fill the textarea
193
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'My reply text' } })
194
+
195
+ // The CommentForm submit button is labeled "Reply" when parentId is set
196
+ // There are now two "Reply" buttons: the toggle and the form submit
197
+ // The form submit is a type="submit" button; click the second one
198
+ const replyButtons = screen.getAllByRole('button', { name: /reply/i })
199
+ fireEvent.click(replyButtons[replyButtons.length - 1])
200
+
201
+ // After successful submit: form hides and onRefresh called
202
+ await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument())
203
+ expect(onRefresh).toHaveBeenCalledTimes(1)
204
+ })
205
+
206
+ describe('relativeTime display', () => {
207
+ it('shows "just now" for timestamps less than 1 minute ago', () => {
208
+ render(<CommentItem comment={makeComment(30_000)} articleSlug="a" onRefresh={jest.fn()} />)
209
+ expect(screen.getByText('just now')).toBeInTheDocument()
210
+ })
211
+
212
+ it('shows "Xm ago" for timestamps 1-59 minutes ago', () => {
213
+ render(
214
+ <CommentItem comment={makeComment(5 * 60_000)} articleSlug="a" onRefresh={jest.fn()} />
215
+ )
216
+ expect(screen.getByText('5m ago')).toBeInTheDocument()
217
+ })
218
+
219
+ it('shows "Xh ago" for timestamps 1-23 hours ago', () => {
220
+ render(
221
+ <CommentItem comment={makeComment(3 * 60 * 60_000)} articleSlug="a" onRefresh={jest.fn()} />
222
+ )
223
+ expect(screen.getByText('3h ago')).toBeInTheDocument()
224
+ })
225
+
226
+ it('shows "Xd ago" for timestamps 1-29 days ago', () => {
227
+ render(
228
+ <CommentItem
229
+ comment={makeComment(10 * 24 * 60 * 60_000)}
230
+ articleSlug="a"
231
+ onRefresh={jest.fn()}
232
+ />
233
+ )
234
+ expect(screen.getByText('10d ago')).toBeInTheDocument()
235
+ })
236
+
237
+ it('shows "Xmo ago" for timestamps 1-11 months ago', () => {
238
+ render(
239
+ <CommentItem
240
+ comment={makeComment(60 * 24 * 60 * 60_000)}
241
+ articleSlug="a"
242
+ onRefresh={jest.fn()}
243
+ />
244
+ )
245
+ expect(screen.getByText('2mo ago')).toBeInTheDocument()
246
+ })
247
+
248
+ it('shows "Xy ago" for timestamps 12+ months ago', () => {
249
+ render(
250
+ <CommentItem
251
+ comment={makeComment(400 * 24 * 60 * 60_000)}
252
+ articleSlug="a"
253
+ onRefresh={jest.fn()}
254
+ />
255
+ )
256
+ expect(screen.getByText('1y ago')).toBeInTheDocument()
257
+ })
258
+ })
149
259
  })
@@ -1,6 +1,7 @@
1
1
  import { ArticlesConfig } from '../articlesConfig'
2
2
  import {
3
3
  generateArticleStaticParams,
4
+ generateArticlesIndexMetadata,
4
5
  generateCategoryStaticParams,
5
6
  generateArticleMetadata,
6
7
  generateCategoryMetadata,
@@ -98,6 +99,33 @@ describe('generateArticleStaticParams', () => {
98
99
  })
99
100
  })
100
101
 
102
+ describe('generateArticlesIndexMetadata', () => {
103
+ it('returns canonical URL pointing to /articles', () => {
104
+ const meta = generateArticlesIndexMetadata(config)
105
+ expect(meta.alternates?.canonical).toBe('https://example.com/articles')
106
+ })
107
+
108
+ it('strips trailing slash from siteUrl before building canonical', () => {
109
+ const meta = generateArticlesIndexMetadata({ ...config, siteUrl: 'https://example.com/' })
110
+ expect(meta.alternates?.canonical).toBe('https://example.com/articles')
111
+ })
112
+
113
+ it('works with a subdirectory siteUrl', () => {
114
+ const meta = generateArticlesIndexMetadata({ ...config, siteUrl: 'https://example.com/app' })
115
+ expect(meta.alternates?.canonical).toBe('https://example.com/app/articles')
116
+ })
117
+
118
+ it('includes title, description, openGraph, twitter, and robots', () => {
119
+ const meta = generateArticlesIndexMetadata(config)
120
+ expect(meta.title).toBeDefined()
121
+ expect(meta.description).toBeDefined()
122
+ expect(meta.openGraph).toBeDefined()
123
+ expect((meta.openGraph as { type?: string })?.type).toBe('website')
124
+ expect(meta.twitter).toBeDefined()
125
+ expect(meta.robots).toBeDefined()
126
+ })
127
+ })
128
+
101
129
  describe('generateCategoryStaticParams', () => {
102
130
  it('returns category slug params', async () => {
103
131
  const result = await generateCategoryStaticParams()
@@ -214,4 +242,39 @@ describe('getArticleSitemapEntries', () => {
214
242
  const entries = await getArticleSitemapEntries('https://example.com')
215
243
  expect(entries).toEqual([])
216
244
  })
245
+
246
+ it('accepts ArticlesConfig and uses siteUrl from config', async () => {
247
+ const entries = await getArticleSitemapEntries(config)
248
+ expect(entries).toHaveLength(4)
249
+
250
+ const articleUrls = entries.filter((e) => !e.url.includes('/category/')).map((e) => e.url)
251
+ expect(articleUrls).toEqual([
252
+ 'https://example.com/articles/article-one',
253
+ 'https://example.com/articles/article-two',
254
+ ])
255
+
256
+ const categoryUrls = entries.filter((e) => e.url.includes('/category/')).map((e) => e.url)
257
+ expect(categoryUrls).toEqual([
258
+ 'https://example.com/articles/category/campaigns',
259
+ 'https://example.com/articles/category/volunteers',
260
+ ])
261
+ })
262
+
263
+ it('accepts ArticlesConfig with trailing slash and strips it', async () => {
264
+ const entries = await getArticleSitemapEntries({
265
+ siteUrl: 'https://example.com/',
266
+ siteName: 'Example Site',
267
+ })
268
+ expect(entries).toHaveLength(4)
269
+ entries.forEach((e) => {
270
+ // protocol (https://) is fine; double slash in the path is not
271
+ expect(e.url.replace(/^https?:\/\//, '')).not.toContain('//')
272
+ })
273
+ })
274
+
275
+ it('returns same results whether called with string or ArticlesConfig', async () => {
276
+ const stringResult = await getArticleSitemapEntries('https://example.com')
277
+ const configResult = await getArticleSitemapEntries(config)
278
+ expect(JSON.stringify(stringResult)).toBe(JSON.stringify(configResult))
279
+ })
217
280
  })
package/src/index.ts CHANGED
@@ -13,6 +13,9 @@ export { LatestArticlesSection } from './LatestArticlesSection'
13
13
  export { CategoryArticlesPage } from './CategoryArticlesPage'
14
14
  export { ArticleSchema, BreadcrumbSchema, CollectionPageSchema } from './ArticleSchemas'
15
15
 
16
+ export { ArticleSocialShare } from './ArticleSocialShare'
17
+ export { ArticleNavigation } from './ArticleNavigation'
18
+
16
19
  // Comments
17
20
  export { CommentsSection } from './CommentsSection'
18
21
  export { CommentThread } from './CommentThread'
package/src/seoUtils.ts CHANGED
@@ -99,6 +99,45 @@ export async function generateArticleMetadata(
99
99
  }
100
100
  }
101
101
 
102
+ export function generateArticlesIndexMetadata(config: ArticlesConfig): Metadata {
103
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
104
+ const indexUrl = `${siteUrl}/articles`
105
+ const title = `Articles | ${config.siteName}`
106
+ const description =
107
+ config.hero?.description ?? `Expert analysis and insights from ${config.siteName}.`
108
+ return {
109
+ title,
110
+ description,
111
+ openGraph: {
112
+ title,
113
+ description,
114
+ url: indexUrl,
115
+ siteName: config.siteName,
116
+ type: 'website',
117
+ locale: 'en_US',
118
+ },
119
+ twitter: {
120
+ card: 'summary_large_image',
121
+ title,
122
+ description,
123
+ },
124
+ alternates: {
125
+ canonical: indexUrl,
126
+ },
127
+ robots: {
128
+ index: true,
129
+ follow: true,
130
+ googleBot: {
131
+ index: true,
132
+ follow: true,
133
+ 'max-video-preview': -1,
134
+ 'max-image-preview': 'large',
135
+ 'max-snippet': -1,
136
+ },
137
+ },
138
+ }
139
+ }
140
+
102
141
  export async function generateCategoryMetadata(
103
142
  categorySlug: string,
104
143
  config: ArticlesConfig
@@ -114,8 +153,9 @@ export async function generateCategoryMetadata(
114
153
  const fallback = `Browse ${articles.length} article${articles.length === 1 ? '' : 's'} in the ${categoryName} category.`
115
154
  const description = typeof raw === 'string' ? raw : (raw?.short ?? fallback)
116
155
 
156
+ const title = `${categoryName} Articles | ${config.siteName}`
117
157
  return {
118
- title: `${categoryName} Articles | ${config.siteName}`,
158
+ title,
119
159
  description,
120
160
  openGraph: {
121
161
  title: `${categoryName} Articles`,
@@ -123,14 +163,38 @@ export async function generateCategoryMetadata(
123
163
  url: categoryUrl,
124
164
  siteName: config.siteName,
125
165
  images: [{ url: articles[0].featuredImage }],
166
+ type: 'website',
167
+ locale: 'en_US',
168
+ },
169
+ twitter: {
170
+ card: 'summary_large_image',
171
+ title: `${categoryName} Articles`,
172
+ description,
126
173
  },
127
174
  alternates: {
128
175
  canonical: categoryUrl,
129
176
  },
177
+ robots: {
178
+ index: true,
179
+ follow: true,
180
+ googleBot: {
181
+ index: true,
182
+ follow: true,
183
+ 'max-video-preview': -1,
184
+ 'max-image-preview': 'large',
185
+ 'max-snippet': -1,
186
+ },
187
+ },
130
188
  }
131
189
  }
132
190
 
133
- export async function getArticleSitemapEntries(baseUrl: string): Promise<MetadataRoute.Sitemap> {
191
+ export async function getArticleSitemapEntries(
192
+ baseUrlOrConfig: string | ArticlesConfig
193
+ ): Promise<MetadataRoute.Sitemap> {
194
+ const baseUrl = (
195
+ typeof baseUrlOrConfig === 'string' ? baseUrlOrConfig : baseUrlOrConfig.siteUrl
196
+ ).replace(/\/$/, '')
197
+
134
198
  try {
135
199
  const [articles, categories] = await Promise.all([getAllArticles(), getAllCategories()])
136
200
 
package/src/server.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  export {
15
15
  generateArticleStaticParams,
16
16
  generateCategoryStaticParams,
17
+ generateArticlesIndexMetadata,
17
18
  generateArticleMetadata,
18
19
  generateCategoryMetadata,
19
20
  getArticleSitemapEntries,