@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.
- package/README.md +95 -6
- package/dist/index.cjs +287 -105
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -2
- package/dist/index.d.ts +24 -2
- package/dist/index.js +280 -100
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +63 -3
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +3 -2
- package/dist/server.d.ts +3 -2
- package/dist/server.js +62 -3
- package/dist/server.js.map +1 -1
- package/package.json +3 -2
- package/src/ArticleDetailHero.tsx +31 -5
- package/src/ArticleNavigation.tsx +56 -0
- package/src/ArticleSocialShare.tsx +96 -0
- package/src/CategoryArticlesPage.tsx +21 -4
- package/src/__tests__/ArticleCategoryGrid.test.tsx +34 -1
- package/src/__tests__/ArticleDetailHero.test.tsx +42 -10
- package/src/__tests__/ArticleNavigation.test.tsx +127 -0
- package/src/__tests__/ArticleSocialShare.test.tsx +187 -0
- package/src/__tests__/ArticlesPage.test.tsx +147 -1
- package/src/__tests__/CommentItem.test.tsx +110 -0
- package/src/__tests__/seoUtils.test.ts +63 -0
- package/src/index.ts +3 -0
- package/src/seoUtils.ts +66 -2
- package/src/server.ts +1 -0
|
@@ -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
|
|
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(
|
|
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
|
|