@fullstackdatasolutions/articles 0.4.3 → 0.5.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.
@@ -1,6 +1,12 @@
1
1
  import { render } from '@testing-library/react'
2
2
  import { Article } from '../articleTypes'
3
- import { ArticleSchema, BreadcrumbSchema, CollectionPageSchema } from '../ArticleSchemas'
3
+ import {
4
+ ArticleSchema,
5
+ ArticleSEO,
6
+ BreadcrumbSchema,
7
+ CollectionPageSchema,
8
+ FAQPageSchema,
9
+ } from '../ArticleSchemas'
4
10
 
5
11
  const mockArticle: Article = {
6
12
  slug: 'test-article',
@@ -21,6 +27,11 @@ function parseSchemaScript(container: HTMLElement) {
21
27
  return JSON.parse(script!.innerHTML)
22
28
  }
23
29
 
30
+ function parseAllSchemaScripts(container: HTMLElement) {
31
+ const scripts = container.querySelectorAll('script[type="application/ld+json"]')
32
+ return Array.from(scripts).map((s) => JSON.parse(s.innerHTML))
33
+ }
34
+
24
35
  describe('ArticleSchema', () => {
25
36
  it('renders a ld+json script tag', () => {
26
37
  const { container } = render(
@@ -128,3 +139,203 @@ describe('CollectionPageSchema', () => {
128
139
  expect(schema.url).toBe('https://example.com/articles')
129
140
  })
130
141
  })
142
+
143
+ describe('FAQPageSchema', () => {
144
+ const faqItems = [
145
+ { question: 'What is this?', answer: 'A test.' },
146
+ { question: 'Why?', answer: 'Because.' },
147
+ ]
148
+
149
+ it('renders a ld+json script tag', () => {
150
+ const { container } = render(<FAQPageSchema items={faqItems} />)
151
+ expect(container.querySelector('script[type="application/ld+json"]')).not.toBeNull()
152
+ })
153
+
154
+ it('outputs schema.org/FAQPage with mainEntity items', () => {
155
+ const { container } = render(<FAQPageSchema items={faqItems} />)
156
+ const schema = parseSchemaScript(container)
157
+ expect(schema['@type']).toBe('FAQPage')
158
+ expect(schema.mainEntity).toHaveLength(2)
159
+ expect(schema.mainEntity[0]['@type']).toBe('Question')
160
+ expect(schema.mainEntity[0].name).toBe('What is this?')
161
+ expect(schema.mainEntity[0].acceptedAnswer['@type']).toBe('Answer')
162
+ expect(schema.mainEntity[0].acceptedAnswer.text).toBe('A test.')
163
+ })
164
+ })
165
+
166
+ describe('ArticleSEO', () => {
167
+ const baseArticle: Article = {
168
+ slug: 'test-article',
169
+ title: 'Test Article',
170
+ excerpt: 'A test excerpt.',
171
+ date: '2025-03-01',
172
+ author: 'Jane Doe',
173
+ category: 'Campaigns',
174
+ categories: ['Campaigns'],
175
+ readTime: '3 min read',
176
+ featuredImage: '/articles/test/image.jpg',
177
+ tags: ['campaigns'],
178
+ }
179
+
180
+ it('renders Article JSON-LD with correct fields', () => {
181
+ const { container } = render(
182
+ <ArticleSEO
183
+ article={baseArticle}
184
+ articleUrl="https://example.com/articles/test-article"
185
+ siteName="Example Site"
186
+ />
187
+ )
188
+ const schemas = parseAllSchemaScripts(container)
189
+ const article = schemas.find((s) => s['@type'] === 'Article')
190
+ expect(article).toBeDefined()
191
+ expect(article.headline).toBe('Test Article')
192
+ expect(article.description).toBe('A test excerpt.')
193
+ expect(article.author.name).toBe('Jane Doe')
194
+ expect(article.publisher.name).toBe('Example Site')
195
+ expect(article.mainEntityOfPage['@id']).toBe('https://example.com/articles/test-article')
196
+ expect(article.datePublished).toBe(new Date('2025-03-01').toISOString())
197
+ })
198
+
199
+ it('uses articleType when provided', () => {
200
+ const { container } = render(
201
+ <ArticleSEO
202
+ article={{ ...baseArticle, articleType: 'TechArticle' }}
203
+ articleUrl="https://example.com/articles/test-article"
204
+ siteName="Example Site"
205
+ />
206
+ )
207
+ const schemas = parseAllSchemaScripts(container)
208
+ const article = schemas.find((s) => s['@type'] === 'TechArticle')
209
+ expect(article).toBeDefined()
210
+ })
211
+
212
+ it('includes dateModified when lastmod is set', () => {
213
+ const { container } = render(
214
+ <ArticleSEO
215
+ article={{ ...baseArticle, lastmod: '2025-06-01' }}
216
+ articleUrl="https://example.com/articles/test-article"
217
+ siteName="Example Site"
218
+ />
219
+ )
220
+ const schemas = parseAllSchemaScripts(container)
221
+ const article = schemas[0]
222
+ expect(article.dateModified).toBe(new Date('2025-06-01').toISOString())
223
+ })
224
+
225
+ it('includes isPartOf when series is set', () => {
226
+ const { container } = render(
227
+ <ArticleSEO
228
+ article={{ ...baseArticle, series: 'Campaign Guides' }}
229
+ articleUrl="https://example.com/articles/test-article"
230
+ siteName="Example Site"
231
+ />
232
+ )
233
+ const schemas = parseAllSchemaScripts(container)
234
+ const article = schemas[0]
235
+ expect(article.isPartOf).toEqual({ '@type': 'Blog', name: 'Campaign Guides' })
236
+ })
237
+
238
+ it('includes logo in publisher when siteLogo is provided', () => {
239
+ const { container } = render(
240
+ <ArticleSEO
241
+ article={baseArticle}
242
+ articleUrl="https://example.com/articles/test-article"
243
+ siteName="Example Site"
244
+ siteLogo="https://example.com/logo.png"
245
+ />
246
+ )
247
+ const schemas = parseAllSchemaScripts(container)
248
+ const article = schemas[0]
249
+ expect(article.publisher.logo).toEqual({
250
+ '@type': 'ImageObject',
251
+ url: 'https://example.com/logo.png',
252
+ })
253
+ })
254
+
255
+ it('renders FAQPage JSON-LD when faq items are present', () => {
256
+ const faq = [{ question: 'What?', answer: 'This.' }]
257
+ const { container } = render(
258
+ <ArticleSEO
259
+ article={{ ...baseArticle, faq }}
260
+ articleUrl="https://example.com/articles/test-article"
261
+ siteName="Example Site"
262
+ />
263
+ )
264
+ const schemas = parseAllSchemaScripts(container)
265
+ const faqSchema = schemas.find((s) => s['@type'] === 'FAQPage')
266
+ expect(faqSchema).toBeDefined()
267
+ expect(faqSchema.mainEntity[0].name).toBe('What?')
268
+ expect(faqSchema.mainEntity[0].acceptedAnswer.text).toBe('This.')
269
+ })
270
+
271
+ it('does not render FAQPage JSON-LD when faq is absent', () => {
272
+ const { container } = render(
273
+ <ArticleSEO
274
+ article={baseArticle}
275
+ articleUrl="https://example.com/articles/test-article"
276
+ siteName="Example Site"
277
+ />
278
+ )
279
+ const schemas = parseAllSchemaScripts(container)
280
+ expect(schemas.find((s) => s['@type'] === 'FAQPage')).toBeUndefined()
281
+ })
282
+
283
+ it('renders HowTo JSON-LD when howTo steps are present', () => {
284
+ const howTo = [
285
+ { name: 'Step 1', text: 'Do this.' },
286
+ { name: 'Step 2', text: 'Then that.' },
287
+ ]
288
+ const { container } = render(
289
+ <ArticleSEO
290
+ article={{ ...baseArticle, howTo }}
291
+ articleUrl="https://example.com/articles/test-article"
292
+ siteName="Example Site"
293
+ />
294
+ )
295
+ const schemas = parseAllSchemaScripts(container)
296
+ const howToSchema = schemas.find((s) => s['@type'] === 'HowTo')
297
+ expect(howToSchema).toBeDefined()
298
+ expect(howToSchema.name).toBe('Test Article')
299
+ expect(howToSchema.step).toHaveLength(2)
300
+ expect(howToSchema.step[0].name).toBe('Step 1')
301
+ })
302
+
303
+ it('does not render HowTo JSON-LD when howTo is absent', () => {
304
+ const { container } = render(
305
+ <ArticleSEO
306
+ article={baseArticle}
307
+ articleUrl="https://example.com/articles/test-article"
308
+ siteName="Example Site"
309
+ />
310
+ )
311
+ const schemas = parseAllSchemaScripts(container)
312
+ expect(schemas.find((s) => s['@type'] === 'HowTo')).toBeUndefined()
313
+ })
314
+
315
+ it('omits datePublished and dateModified when article has no date', () => {
316
+ const { container } = render(
317
+ <ArticleSEO
318
+ article={{ ...baseArticle, date: undefined }}
319
+ articleUrl="https://example.com/articles/test-article"
320
+ siteName="Example Site"
321
+ />
322
+ )
323
+ const schemas = parseAllSchemaScripts(container)
324
+ const schema = schemas[0]
325
+ expect(schema.datePublished).toBeUndefined()
326
+ expect(schema.dateModified).toBeUndefined()
327
+ })
328
+
329
+ it('omits image when featuredImage is empty', () => {
330
+ const { container } = render(
331
+ <ArticleSEO
332
+ article={{ ...baseArticle, featuredImage: '' }}
333
+ articleUrl="https://example.com/articles/test-article"
334
+ siteName="Example Site"
335
+ />
336
+ )
337
+ const schemas = parseAllSchemaScripts(container)
338
+ const schema = schemas[0]
339
+ expect(schema.image).toBeUndefined()
340
+ })
341
+ })
@@ -10,7 +10,7 @@ const mockWriteText = jest.fn()
10
10
 
11
11
  beforeEach(() => {
12
12
  jest.clearAllMocks()
13
- window.open = mockOpen
13
+ globalThis.open = mockOpen
14
14
  Object.defineProperty(navigator, 'clipboard', {
15
15
  value: { writeText: mockWriteText },
16
16
  writable: true,
@@ -46,7 +46,7 @@ describe('ArticleSocialShare', () => {
46
46
  })
47
47
 
48
48
  describe('share window buttons', () => {
49
- it('clicking LinkedIn calls window.open with the correct LinkedIn URL', () => {
49
+ it('clicking LinkedIn calls globalThis.open with the correct LinkedIn URL', () => {
50
50
  render(<ArticleSocialShare {...defaultProps} />)
51
51
  fireEvent.click(screen.getByRole('button', { name: /linkedin/i }))
52
52
  const encodedUrl = encodeURIComponent(defaultProps.url)
@@ -57,7 +57,7 @@ describe('ArticleSocialShare', () => {
57
57
  )
58
58
  })
59
59
 
60
- it('clicking Facebook calls window.open with the correct Facebook URL', () => {
60
+ it('clicking Facebook calls globalThis.open with the correct Facebook URL', () => {
61
61
  render(<ArticleSocialShare {...defaultProps} />)
62
62
  fireEvent.click(screen.getByRole('button', { name: /facebook/i }))
63
63
  const encodedUrl = encodeURIComponent(defaultProps.url)
@@ -68,7 +68,7 @@ describe('ArticleSocialShare', () => {
68
68
  )
69
69
  })
70
70
 
71
- it('clicking Twitter calls window.open with correct URL and encoded title', () => {
71
+ it('clicking Twitter calls globalThis.open with correct URL and encoded title', () => {
72
72
  render(<ArticleSocialShare {...defaultProps} />)
73
73
  fireEvent.click(screen.getByRole('button', { name: /twitter/i }))
74
74
  const encodedTitle = encodeURIComponent(defaultProps.title)
@@ -80,7 +80,7 @@ describe('ArticleSocialShare', () => {
80
80
  )
81
81
  })
82
82
 
83
- it('clicking Reddit calls window.open with correct URL and encoded title', () => {
83
+ it('clicking Reddit calls globalThis.open with correct URL and encoded title', () => {
84
84
  render(<ArticleSocialShare {...defaultProps} />)
85
85
  fireEvent.click(screen.getByRole('button', { name: /reddit/i }))
86
86
  const encodedTitle = encodeURIComponent(defaultProps.title)
@@ -92,7 +92,7 @@ describe('ArticleSocialShare', () => {
92
92
  )
93
93
  })
94
94
 
95
- it('clicking WhatsApp calls window.open with the correct WhatsApp URL', () => {
95
+ it('clicking WhatsApp calls globalThis.open with the correct WhatsApp URL', () => {
96
96
  render(<ArticleSocialShare {...defaultProps} />)
97
97
  fireEvent.click(screen.getByRole('button', { name: /whatsapp/i }))
98
98
  const encodedTitle = encodeURIComponent(defaultProps.title)
@@ -104,7 +104,7 @@ describe('ArticleSocialShare', () => {
104
104
  )
105
105
  })
106
106
 
107
- it('clicking Telegram calls window.open with the correct Telegram URL', () => {
107
+ it('clicking Telegram calls globalThis.open with the correct Telegram URL', () => {
108
108
  render(<ArticleSocialShare {...defaultProps} />)
109
109
  fireEvent.click(screen.getByRole('button', { name: /telegram/i }))
110
110
  const encodedTitle = encodeURIComponent(defaultProps.title)
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react'
5
+ import { render, screen } from '@testing-library/react'
6
+ import { ArticleTOC } from '../ArticleTOC'
7
+ import type { TocItem } from '../articleTypes'
8
+
9
+ const twoItems: TocItem[] = [
10
+ { id: 'intro', depth: 2, text: 'Introduction' },
11
+ { id: 'details', depth: 3, text: 'Details' },
12
+ ]
13
+
14
+ describe('ArticleTOC', () => {
15
+ describe('empty toc', () => {
16
+ it('returns null when toc is an empty array', () => {
17
+ const { container } = render(<ArticleTOC toc={[]} />)
18
+ expect(container.firstChild).toBeNull()
19
+ })
20
+ })
21
+
22
+ describe('nav element', () => {
23
+ it('renders nav with aria-label "Table of contents" when toc has items', () => {
24
+ render(<ArticleTOC toc={twoItems} />)
25
+ expect(screen.getByRole('navigation', { name: 'Table of contents' })).toBeInTheDocument()
26
+ })
27
+
28
+ it('renders "On this page" header', () => {
29
+ render(<ArticleTOC toc={twoItems} />)
30
+ expect(screen.getByText('On this page')).toBeInTheDocument()
31
+ })
32
+
33
+ it('applies custom className to the nav element', () => {
34
+ render(<ArticleTOC toc={twoItems} className="custom-class" />)
35
+ const nav = screen.getByRole('navigation', { name: 'Table of contents' })
36
+ expect(nav.className).toContain('custom-class')
37
+ })
38
+ })
39
+
40
+ describe('list items', () => {
41
+ it('renders the correct number of list items', () => {
42
+ render(<ArticleTOC toc={twoItems} />)
43
+ expect(screen.getAllByRole('listitem')).toHaveLength(2)
44
+ })
45
+
46
+ it('each anchor href is #item.id', () => {
47
+ render(<ArticleTOC toc={twoItems} />)
48
+ const links = screen.getAllByRole('link')
49
+ expect(links[0]).toHaveAttribute('href', '#intro')
50
+ expect(links[1]).toHaveAttribute('href', '#details')
51
+ })
52
+
53
+ it('each anchor text matches item.text', () => {
54
+ render(<ArticleTOC toc={twoItems} />)
55
+ expect(screen.getByText('Introduction')).toBeInTheDocument()
56
+ expect(screen.getByText('Details')).toBeInTheDocument()
57
+ })
58
+ })
59
+
60
+ describe('indentation', () => {
61
+ it('h2 items (depth 2) have 0rem padding', () => {
62
+ const items: TocItem[] = [{ id: 'h2-item', depth: 2, text: 'H2 Heading' }]
63
+ render(<ArticleTOC toc={items} />)
64
+ const li = screen.getByRole('listitem')
65
+ expect(li).toHaveStyle({ paddingLeft: '0rem' })
66
+ })
67
+
68
+ it('h3 items (depth 3) have 1rem padding', () => {
69
+ const items: TocItem[] = [{ id: 'h3-item', depth: 3, text: 'H3 Heading' }]
70
+ render(<ArticleTOC toc={items} />)
71
+ const li = screen.getByRole('listitem')
72
+ expect(li).toHaveStyle({ paddingLeft: '1rem' })
73
+ })
74
+ })
75
+ })
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react'
5
+ import { render, screen, fireEvent, act } from '@testing-library/react'
6
+ import { ScrollToTop } from '../ScrollToTop'
7
+
8
+ beforeEach(() => {
9
+ Object.defineProperty(globalThis, 'scrollY', { value: 0, writable: true, configurable: true })
10
+ })
11
+
12
+ describe('ScrollToTop', () => {
13
+ it('does not render the button initially when scrollY is 0', () => {
14
+ render(<ScrollToTop />)
15
+ expect(screen.queryByRole('button', { name: 'Scroll to top' })).toBeNull()
16
+ })
17
+
18
+ it('renders the button with aria-label "Scroll to top" after scroll event with scrollY > 300', () => {
19
+ render(<ScrollToTop />)
20
+ act(() => {
21
+ Object.defineProperty(globalThis, 'scrollY', {
22
+ value: 350,
23
+ writable: true,
24
+ configurable: true,
25
+ })
26
+ globalThis.dispatchEvent(new Event('scroll'))
27
+ })
28
+ expect(screen.getByRole('button', { name: 'Scroll to top' })).toBeInTheDocument()
29
+ })
30
+
31
+ it('hides the button after scroll event brings scrollY back to <= 300', () => {
32
+ render(<ScrollToTop />)
33
+ act(() => {
34
+ Object.defineProperty(globalThis, 'scrollY', {
35
+ value: 350,
36
+ writable: true,
37
+ configurable: true,
38
+ })
39
+ globalThis.dispatchEvent(new Event('scroll'))
40
+ })
41
+ expect(screen.getByRole('button', { name: 'Scroll to top' })).toBeInTheDocument()
42
+ act(() => {
43
+ Object.defineProperty(globalThis, 'scrollY', {
44
+ value: 100,
45
+ writable: true,
46
+ configurable: true,
47
+ })
48
+ globalThis.dispatchEvent(new Event('scroll'))
49
+ })
50
+ expect(screen.queryByRole('button', { name: 'Scroll to top' })).toBeNull()
51
+ })
52
+
53
+ it('clicking the button calls globalThis.scrollTo with { top: 0, behavior: "smooth" }', () => {
54
+ const mockScrollTo = jest.fn()
55
+ globalThis.scrollTo = mockScrollTo
56
+
57
+ render(<ScrollToTop />)
58
+ act(() => {
59
+ Object.defineProperty(globalThis, 'scrollY', {
60
+ value: 350,
61
+ writable: true,
62
+ configurable: true,
63
+ })
64
+ globalThis.dispatchEvent(new Event('scroll'))
65
+ })
66
+ fireEvent.click(screen.getByRole('button', { name: 'Scroll to top' }))
67
+ expect(mockScrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' })
68
+ })
69
+
70
+ it('removes the scroll event listener on unmount', () => {
71
+ const addSpy = jest.spyOn(globalThis, 'addEventListener')
72
+ const removeSpy = jest.spyOn(globalThis, 'removeEventListener')
73
+
74
+ const { unmount } = render(<ScrollToTop />)
75
+ unmount()
76
+
77
+ const addCall = addSpy.mock.calls.find((c) => c[0] === 'scroll')
78
+ const removeCall = removeSpy.mock.calls.find((c) => c[0] === 'scroll')
79
+
80
+ expect(addCall).toBeDefined()
81
+ expect(removeCall).toBeDefined()
82
+ expect(addCall![1]).toBe(removeCall![1])
83
+
84
+ addSpy.mockRestore()
85
+ removeSpy.mockRestore()
86
+ })
87
+ })