@fullstackdatasolutions/articles 0.9.0 → 0.11.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 (48) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/README.md +226 -29
  3. package/dist/index.cjs +635 -274
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +165 -56
  6. package/dist/index.d.ts +165 -56
  7. package/dist/index.js +614 -250
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +40 -5
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +72 -0
  12. package/dist/nextjs.d.ts +72 -0
  13. package/dist/nextjs.js +40 -5
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +280 -16
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +92 -3
  18. package/dist/server.d.ts +92 -3
  19. package/dist/server.js +269 -16
  20. package/dist/server.js.map +1 -1
  21. package/package.json +8 -5
  22. package/src/ArticleDetailHero.tsx +27 -2
  23. package/src/ArticleSchemas.tsx +27 -27
  24. package/src/AuthorArticlesPage.tsx +60 -0
  25. package/src/AuthorCard.tsx +112 -0
  26. package/src/AuthorDetailHero.tsx +56 -0
  27. package/src/Breadcrumb.tsx +78 -0
  28. package/src/CategoryArticlesPage.tsx +62 -11
  29. package/src/__tests__/ArticleDetailHero.test.tsx +21 -1
  30. package/src/__tests__/ArticleSchemas.test.tsx +47 -2
  31. package/src/__tests__/AuthorArticlesPage.test.tsx +74 -0
  32. package/src/__tests__/AuthorCard.test.tsx +98 -0
  33. package/src/__tests__/AuthorDetailHero.test.tsx +51 -0
  34. package/src/__tests__/CategoryArticlesPage.test.tsx +31 -5
  35. package/src/__tests__/articlesConfig.test.ts +20 -1
  36. package/src/__tests__/authorUtils.test.ts +89 -0
  37. package/src/__tests__/renderMdx.test.tsx +113 -0
  38. package/src/__tests__/seoUtils-authors.test.ts +160 -0
  39. package/src/__tests__/seoUtils.test.ts +4 -0
  40. package/src/__tests__/server-articles.test.ts +159 -2
  41. package/src/articleTypes.ts +33 -0
  42. package/src/articlesConfig.ts +68 -0
  43. package/src/authorUtils.ts +95 -0
  44. package/src/index.ts +32 -9
  45. package/src/renderMdx.tsx +8 -2
  46. package/src/seoUtils.ts +226 -7
  47. package/src/server-articles.ts +98 -10
  48. package/src/server.ts +19 -1
@@ -0,0 +1,98 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import type React from 'react'
3
+ import { AuthorCard } from '../AuthorCard'
4
+
5
+ jest.mock('next/link', () => ({
6
+ __esModule: true,
7
+ default: ({
8
+ href,
9
+ children,
10
+ ...rest
11
+ }: {
12
+ href: string
13
+ children: React.ReactNode
14
+ [key: string]: unknown
15
+ }) => (
16
+ <a href={href} {...rest}>
17
+ {children}
18
+ </a>
19
+ ),
20
+ }))
21
+
22
+ const author = {
23
+ name: 'Andrew Blase',
24
+ slug: 'andrew-blase',
25
+ bio: 'Writer focused on civic technology.',
26
+ social: {
27
+ github: 'blazestudios23',
28
+ website: 'https://fullstackdatasolutions.com',
29
+ },
30
+ }
31
+
32
+ describe('AuthorCard', () => {
33
+ it('renders author name as a link by default', () => {
34
+ render(<AuthorCard author={author} />)
35
+
36
+ expect(screen.getByRole('link', { name: 'Andrew Blase' })).toHaveAttribute(
37
+ 'href',
38
+ '/articles/authors/andrew-blase'
39
+ )
40
+ })
41
+
42
+ it('can show bio and social links', () => {
43
+ render(<AuthorCard author={author} showBio showSocial />)
44
+
45
+ expect(screen.getByText('Writer focused on civic technology.')).toBeInTheDocument()
46
+ expect(screen.getByRole('link', { name: 'Andrew Blase on GitHub' })).toHaveAttribute(
47
+ 'href',
48
+ 'https://github.com/blazestudios23'
49
+ )
50
+ expect(screen.getByRole('link', { name: 'Andrew Blase on Website' })).toHaveAttribute(
51
+ 'href',
52
+ 'https://fullstackdatasolutions.com'
53
+ )
54
+ })
55
+
56
+ it('only renders social links that are provided', () => {
57
+ render(
58
+ <AuthorCard
59
+ author={{
60
+ ...author,
61
+ social: {
62
+ facebook: 'andrew.blase',
63
+ linkedin: 'andrewblase',
64
+ instagram: '@andrewblase',
65
+ other: {
66
+ Podcast: 'https://example.com/podcast',
67
+ },
68
+ },
69
+ }}
70
+ showSocial
71
+ />
72
+ )
73
+
74
+ expect(screen.getByRole('link', { name: 'Andrew Blase on Facebook' })).toHaveAttribute(
75
+ 'href',
76
+ 'https://www.facebook.com/andrew.blase'
77
+ )
78
+ expect(screen.getByRole('link', { name: 'Andrew Blase on LinkedIn' })).toHaveAttribute(
79
+ 'href',
80
+ 'https://www.linkedin.com/in/andrewblase'
81
+ )
82
+ expect(screen.getByRole('link', { name: 'Andrew Blase on Instagram' })).toHaveAttribute(
83
+ 'href',
84
+ 'https://www.instagram.com/andrewblase'
85
+ )
86
+ expect(screen.getByRole('link', { name: 'Andrew Blase on Podcast' })).toHaveAttribute(
87
+ 'href',
88
+ 'https://example.com/podcast'
89
+ )
90
+ expect(screen.queryByRole('link', { name: 'Andrew Blase on GitHub' })).not.toBeInTheDocument()
91
+ })
92
+
93
+ it('does not render social links when no social fields are provided', () => {
94
+ render(<AuthorCard author={{ ...author, social: undefined }} showSocial />)
95
+
96
+ expect(screen.queryByLabelText(/Andrew Blase on/i)).not.toBeInTheDocument()
97
+ })
98
+ })
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { render, screen } from '@testing-library/react'
5
+ import { AuthorDetailHero } from '../AuthorDetailHero'
6
+ import type { AuthorProfile } from '../articleTypes'
7
+
8
+ jest.mock('next/image', () => ({
9
+ __esModule: true,
10
+ default: (props: React.ComponentProps<'img'>) => <img {...props} alt={props.alt ?? ''} />,
11
+ }))
12
+
13
+ const author: AuthorProfile = {
14
+ name: 'Andrew Blase',
15
+ slug: 'andrew-blase',
16
+ bio: 'Writer focused on civic technology.',
17
+ social: {
18
+ website: 'https://fullstackdatasolutions.com/',
19
+ },
20
+ }
21
+
22
+ describe('AuthorDetailHero', () => {
23
+ it('renders author details, plural article count, and social links', () => {
24
+ render(<AuthorDetailHero author={author} articleCount={2} />)
25
+
26
+ expect(screen.getByRole('heading', { name: 'Andrew Blase' })).toBeInTheDocument()
27
+ expect(screen.getByText('Writer focused on civic technology.')).toBeInTheDocument()
28
+ expect(screen.getByText('2 articles')).toBeInTheDocument()
29
+ expect(screen.getByRole('link', { name: 'Andrew Blase on Website' })).toHaveAttribute(
30
+ 'href',
31
+ 'https://fullstackdatasolutions.com/'
32
+ )
33
+ })
34
+
35
+ it('renders initials and singular article count when no avatar is configured', () => {
36
+ render(<AuthorDetailHero author={author} articleCount={1} />)
37
+
38
+ expect(screen.getByText('AB')).toBeInTheDocument()
39
+ expect(screen.getByText('1 article')).toBeInTheDocument()
40
+ })
41
+
42
+ it('renders the author avatar and hides count when articleCount is omitted', () => {
43
+ render(<AuthorDetailHero author={{ ...author, avatar: 'avatar.jpg' }} />)
44
+
45
+ expect(screen.getByAltText('Andrew Blase')).toHaveAttribute(
46
+ 'src',
47
+ '/articles/authors/andrew-blase/avatar.jpg'
48
+ )
49
+ expect(screen.queryByText(/article/i)).not.toBeInTheDocument()
50
+ })
51
+ })
@@ -41,10 +41,6 @@ jest.mock('../LatestArticles', () => ({
41
41
  ),
42
42
  }))
43
43
 
44
- jest.mock('../ArticleSchemas', () => ({
45
- BreadcrumbSchema: () => null,
46
- }))
47
-
48
44
  const baseConfig: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Test Site' }
49
45
 
50
46
  const makeArticle = (overrides: Partial<Article> = {}): Article => ({
@@ -76,7 +72,37 @@ describe('CategoryArticlesPage', () => {
76
72
  config={baseConfig}
77
73
  />
78
74
  )
79
- expect(screen.getByText('Campaigns')).toBeInTheDocument()
75
+ expect(screen.getAllByText('Campaigns').length).toBeGreaterThan(0)
76
+ })
77
+
78
+ it('renders visible breadcrumb navigation', () => {
79
+ render(
80
+ <CategoryArticlesPage
81
+ category="campaigns"
82
+ articles={[makeArticle({ category: 'Campaigns' })]}
83
+ config={baseConfig}
84
+ />
85
+ )
86
+ expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toBeInTheDocument()
87
+ })
88
+
89
+ it('renders custom category breadcrumb links from config', () => {
90
+ render(
91
+ <CategoryArticlesPage
92
+ category="campaigns"
93
+ articles={[makeArticle({ category: 'Campaigns' })]}
94
+ config={{
95
+ ...baseConfig,
96
+ breadcrumbs: {
97
+ category: ['home', { name: 'Resources', url: '/resources' }, 'category'],
98
+ },
99
+ }}
100
+ />
101
+ )
102
+ expect(screen.getByRole('link', { name: 'Resources' })).toHaveAttribute(
103
+ 'href',
104
+ 'https://example.com/resources'
105
+ )
80
106
  })
81
107
 
82
108
  it('renders plural article count in the hero', () => {
@@ -1,4 +1,9 @@
1
- import { DEFAULT_PAGE_SIZE, DEFAULT_CATEGORIES_PAGE_SIZE, DEFAULT_LAYOUT } from '../articlesConfig'
1
+ import {
2
+ DEFAULT_PAGE_SIZE,
3
+ DEFAULT_CATEGORIES_PAGE_SIZE,
4
+ DEFAULT_LAYOUT,
5
+ type ArticlesConfig,
6
+ } from '../articlesConfig'
2
7
 
3
8
  describe('articlesConfig constants', () => {
4
9
  describe('DEFAULT_PAGE_SIZE', () => {
@@ -54,4 +59,18 @@ describe('articlesConfig constants', () => {
54
59
  expect(DEFAULT_LAYOUT).toContain('categories')
55
60
  })
56
61
  })
62
+
63
+ describe('mdxComponents', () => {
64
+ it('accepts custom components keyed by their MDX tag names', () => {
65
+ const config: ArticlesConfig = {
66
+ siteUrl: 'https://example.com',
67
+ siteName: 'Example',
68
+ mdxComponents: {
69
+ LeadMagnetCTA: () => null,
70
+ },
71
+ }
72
+
73
+ expect(config.mdxComponents?.LeadMagnetCTA).toEqual(expect.any(Function))
74
+ })
75
+ })
57
76
  })
@@ -0,0 +1,89 @@
1
+ import {
2
+ getAuthorAvatar,
3
+ getAuthorSameAs,
4
+ getAuthorSocialLinks,
5
+ getAuthorUrl,
6
+ } from '../authorUtils'
7
+ import type { AuthorProfile } from '../articleTypes'
8
+
9
+ const author: AuthorProfile = {
10
+ name: 'Andrew Blase',
11
+ slug: 'andrew-blase',
12
+ bio: 'Writer.',
13
+ }
14
+
15
+ describe('authorUtils', () => {
16
+ it('builds author URLs and avatar URLs', () => {
17
+ expect(getAuthorUrl(author)).toBe('/articles/authors/andrew-blase')
18
+ expect(getAuthorUrl({ ...author, url: 'https://example.com/andrew' })).toBe(
19
+ 'https://example.com/andrew'
20
+ )
21
+ expect(getAuthorAvatar(author)).toBeUndefined()
22
+ expect(getAuthorAvatar({ ...author, avatar: 'avatar.jpg' })).toBe(
23
+ '/articles/authors/andrew-blase/avatar.jpg'
24
+ )
25
+ expect(getAuthorAvatar({ ...author, avatar: '/avatar.jpg' })).toBe(
26
+ '/articles/authors/andrew-blase/avatar.jpg'
27
+ )
28
+ expect(getAuthorAvatar({ ...author, avatar: 'https://example.com/avatar.jpg' })).toBe(
29
+ 'https://example.com/avatar.jpg'
30
+ )
31
+ expect(
32
+ getAuthorAvatar(
33
+ { ...author, avatar: 'avatar.jpg' },
34
+ { siteUrl: 'https://example.com/', siteName: 'Example' }
35
+ )
36
+ ).toBe('https://example.com/articles/authors/andrew-blase/avatar.jpg')
37
+ })
38
+
39
+ it('normalizes configured social links and sameAs URLs', () => {
40
+ const socialAuthor: AuthorProfile = {
41
+ ...author,
42
+ social: {
43
+ website: 'https://fullstackdatasolutions.com/',
44
+ facebook: 'andrew.blase',
45
+ twitter: '@andrewblase',
46
+ x: 'https://x.com/andrewblase',
47
+ linkedin: 'andrewobrigewitsch',
48
+ instagram: '@andrewblase',
49
+ youtube: '@andrewblase',
50
+ tiktok: '@andrewblase',
51
+ github: 'blazestudios23',
52
+ bluesky: 'andrewblase.bsky.social',
53
+ threads: '@andrewblase',
54
+ mastodon: 'https://mastodon.social/@andrewblase',
55
+ medium: '@andrewblase',
56
+ newsletter: 'https://example.com/newsletter',
57
+ other: {
58
+ Podcast: 'https://example.com/podcast',
59
+ Empty: ' ',
60
+ },
61
+ },
62
+ }
63
+
64
+ expect(getAuthorSocialLinks(socialAuthor)).toEqual([
65
+ { label: 'Website', href: 'https://fullstackdatasolutions.com/' },
66
+ { label: 'Facebook', href: 'https://www.facebook.com/andrew.blase' },
67
+ { label: 'Twitter', href: 'https://twitter.com/andrewblase' },
68
+ { label: 'X', href: 'https://x.com/andrewblase' },
69
+ { label: 'LinkedIn', href: 'https://www.linkedin.com/in/andrewobrigewitsch' },
70
+ { label: 'Instagram', href: 'https://www.instagram.com/andrewblase' },
71
+ { label: 'YouTube', href: 'https://www.youtube.com/andrewblase' },
72
+ { label: 'TikTok', href: 'https://www.tiktok.com/@andrewblase' },
73
+ { label: 'GitHub', href: 'https://github.com/blazestudios23' },
74
+ { label: 'Bluesky', href: 'https://bsky.app/profile/andrewblase.bsky.social' },
75
+ { label: 'Threads', href: 'https://www.threads.net/@andrewblase' },
76
+ { label: 'Mastodon', href: 'https://mastodon.social/@andrewblase' },
77
+ { label: 'Medium', href: 'https://medium.com/@andrewblase' },
78
+ { label: 'Newsletter', href: 'https://example.com/newsletter' },
79
+ { label: 'Podcast', href: 'https://example.com/podcast' },
80
+ ])
81
+ expect(getAuthorSameAs(socialAuthor)).toEqual(
82
+ getAuthorSocialLinks(socialAuthor).map((link) => link.href)
83
+ )
84
+ })
85
+
86
+ it('returns no social links when no social fields are configured', () => {
87
+ expect(getAuthorSocialLinks(author)).toEqual([])
88
+ })
89
+ })
@@ -283,6 +283,41 @@ describe('renderMdxSource', () => {
283
283
  expect(img?.getAttribute('src')).toBe('https://example.com/img.png')
284
284
  })
285
285
 
286
+ it('does not modify non-string img src values', async () => {
287
+ const imageBlob = new Blob(['image'])
288
+ let capturedSrc: unknown
289
+
290
+ mockEvaluate.mockResolvedValue({
291
+ default: ({
292
+ components,
293
+ }: {
294
+ components?: Record<string, React.ComponentType<unknown>>
295
+ }) => {
296
+ const Img = components?.img as React.ComponentType<{
297
+ alt: string
298
+ ref?: React.Ref<HTMLImageElement>
299
+ src: Blob | string
300
+ }>
301
+ return Img
302
+ ? React.createElement(Img, {
303
+ src: imageBlob,
304
+ alt: 'test',
305
+ ref: (node: HTMLImageElement | null) => {
306
+ capturedSrc = node?.src
307
+ },
308
+ })
309
+ : React.createElement('img', { alt: 'test' })
310
+ },
311
+ })
312
+ Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
313
+
314
+ const { renderMdxSource } = await import('../renderMdx')
315
+ const el = await renderMdxSource('![test](photo.png)', '/articles/my-article')
316
+ render(el as React.ReactElement)
317
+
318
+ expect(capturedSrc).not.toBe('/articles/my-article/[object Blob]')
319
+ })
320
+
286
321
  it('does not inject img component when basePath is not provided', async () => {
287
322
  let capturedComponents: Record<string, unknown> | undefined
288
323
  mockEvaluate.mockResolvedValue({
@@ -299,4 +334,82 @@ describe('renderMdxSource', () => {
299
334
  expect(capturedComponents).toBeUndefined()
300
335
  })
301
336
  })
337
+
338
+ describe('custom MDX components', () => {
339
+ it('renders a registered custom component with its MDX props', async () => {
340
+ type LeadMagnetCTAProps = Readonly<{
341
+ system: string
342
+ segment: string
343
+ }>
344
+ const LeadMagnetCTA = ({ system, segment }: LeadMagnetCTAProps) =>
345
+ React.createElement('div', { 'data-testid': 'lead-magnet' }, `${system}:${segment}`)
346
+
347
+ mockEvaluate.mockResolvedValue({
348
+ default: ({
349
+ components,
350
+ }: {
351
+ components?: Record<string, React.ComponentType<unknown>>
352
+ }) => {
353
+ const CustomCTA = components?.LeadMagnetCTA as React.ComponentType<LeadMagnetCTAProps>
354
+ return React.createElement(CustomCTA, { system: 'DND', segment: 'new-players' })
355
+ },
356
+ })
357
+ Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
358
+
359
+ const { renderMdxSource } = await import('../renderMdx')
360
+ const source = 'Article intro.\n\n<LeadMagnetCTA system="DND" segment="new-players" />'
361
+ const el = await renderMdxSource(source, undefined, {
362
+ siteUrl: 'https://example.com',
363
+ siteName: 'Example',
364
+ mdxComponents: { LeadMagnetCTA },
365
+ })
366
+ const { getByTestId } = render(el as React.ReactElement)
367
+
368
+ expect(getByTestId('lead-magnet')).toHaveTextContent('DND:new-players')
369
+ expect(mockEvaluate).toHaveBeenCalledWith(source, expect.any(Object))
370
+ })
371
+
372
+ it('merges registered custom components with the relative image override', async () => {
373
+ type LeadMagnetCTAProps = Readonly<{ label: string }>
374
+ const LeadMagnetCTA = ({ label }: LeadMagnetCTAProps) =>
375
+ React.createElement('div', { 'data-testid': 'lead-magnet' }, label)
376
+
377
+ mockEvaluate.mockResolvedValue({
378
+ default: ({
379
+ components,
380
+ }: {
381
+ components?: Record<string, React.ComponentType<unknown>>
382
+ }) => {
383
+ const CustomCTA = components?.LeadMagnetCTA as React.ComponentType<LeadMagnetCTAProps>
384
+ const Img = components?.img as React.ComponentType<
385
+ React.ImgHTMLAttributes<HTMLImageElement>
386
+ >
387
+ return React.createElement(
388
+ 'div',
389
+ null,
390
+ React.createElement(CustomCTA, { label: 'Download now' }),
391
+ React.createElement(Img, { src: 'photo.png', alt: 'Article image' })
392
+ )
393
+ },
394
+ })
395
+ Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
396
+
397
+ const { renderMdxSource } = await import('../renderMdx')
398
+ const el = await renderMdxSource(
399
+ '<LeadMagnetCTA label="Download now" />\n\n![Article image](photo.png)',
400
+ '/articles/my-article',
401
+ {
402
+ siteUrl: 'https://example.com',
403
+ siteName: 'Example',
404
+ mdxComponents: { LeadMagnetCTA },
405
+ }
406
+ )
407
+ const { container, getByTestId } = render(el as React.ReactElement)
408
+
409
+ expect(getByTestId('lead-magnet')).toHaveTextContent('Download now')
410
+ expect(container.querySelector('img')?.getAttribute('src')).toBe(
411
+ '/articles/my-article/photo.png'
412
+ )
413
+ })
414
+ })
302
415
  })
@@ -0,0 +1,160 @@
1
+ import {
2
+ buildArticleBreadcrumbs,
3
+ buildAuthorBreadcrumbs,
4
+ buildCategoryBreadcrumbs,
5
+ generateAuthorMetadata,
6
+ generateAuthorStaticParams,
7
+ } from '../seoUtils'
8
+
9
+ jest.mock('react', () => ({ cache: (fn: Function) => fn }))
10
+ jest.mock('../markdown', () => ({
11
+ markdownToHtml: jest.fn(async () => '<p>content</p>'),
12
+ extractToc: jest.fn(async () => []),
13
+ }))
14
+
15
+ const config = {
16
+ siteUrl: 'https://example.com',
17
+ siteName: 'Example',
18
+ authors: {
19
+ 'andrew-blase': {
20
+ name: 'Andrew Blase',
21
+ slug: 'andrew-blase',
22
+ bio: 'Writer.',
23
+ social: { website: 'https://fullstackdatasolutions.com' },
24
+ },
25
+ },
26
+ }
27
+
28
+ describe('breadcrumb builders', () => {
29
+ it('builds article breadcrumbs with a primary category', () => {
30
+ expect(
31
+ buildArticleBreadcrumbs(
32
+ { slug: 'guides/test-article', title: 'Test Article', category: 'Civic Tech' },
33
+ config
34
+ )
35
+ ).toEqual([
36
+ { name: 'Home', url: 'https://example.com' },
37
+ { name: 'Articles', url: 'https://example.com/articles' },
38
+ { name: 'Civic Tech', url: 'https://example.com/articles/category/civic-tech' },
39
+ { name: 'Test Article' },
40
+ ])
41
+ })
42
+
43
+ it('builds configured article breadcrumbs from category, folder path, and title', () => {
44
+ expect(
45
+ buildArticleBreadcrumbs(
46
+ { slug: 'guides/field/test-article', title: 'Test Article', category: 'Civic Tech' },
47
+ {
48
+ ...config,
49
+ breadcrumbs: {
50
+ article: ['primaryCategory', 'folderPath', 'articleTitle'],
51
+ },
52
+ }
53
+ )
54
+ ).toEqual([
55
+ { name: 'Civic Tech', url: 'https://example.com/articles/category/civic-tech' },
56
+ { name: 'Guides', url: 'https://example.com/articles/guides' },
57
+ { name: 'Field', url: 'https://example.com/articles/guides/field' },
58
+ { name: 'Test Article' },
59
+ ])
60
+ })
61
+
62
+ it('supports custom URL entries anywhere in article breadcrumbs', () => {
63
+ expect(
64
+ buildArticleBreadcrumbs(
65
+ { slug: 'guides/test-article', title: 'Test Article', category: 'Civic Tech' },
66
+ {
67
+ ...config,
68
+ breadcrumbs: {
69
+ article: [
70
+ { name: 'Resources', url: '/resources' },
71
+ 'primaryCategory',
72
+ { name: 'External Docs', url: 'https://docs.example.com' },
73
+ 'articleTitle',
74
+ ],
75
+ },
76
+ }
77
+ )
78
+ ).toEqual([
79
+ { name: 'Resources', url: 'https://example.com/resources' },
80
+ { name: 'Civic Tech', url: 'https://example.com/articles/category/civic-tech' },
81
+ { name: 'External Docs', url: 'https://docs.example.com' },
82
+ { name: 'Test Article' },
83
+ ])
84
+ })
85
+
86
+ it('returns no breadcrumbs when breadcrumbs are disabled', () => {
87
+ expect(
88
+ buildArticleBreadcrumbs(
89
+ { slug: 'test-article', title: 'Test Article', category: 'Civic Tech' },
90
+ { ...config, breadcrumbs: false }
91
+ )
92
+ ).toEqual([])
93
+ })
94
+
95
+ it('builds category and author breadcrumbs', () => {
96
+ expect(buildCategoryBreadcrumbs('civic-tech', config)).toEqual([
97
+ { name: 'Home', url: 'https://example.com' },
98
+ { name: 'Articles', url: 'https://example.com/articles' },
99
+ { name: 'Civic Tech' },
100
+ ])
101
+ expect(buildAuthorBreadcrumbs(config.authors['andrew-blase'], config)).toEqual([
102
+ { name: 'Home', url: 'https://example.com' },
103
+ { name: 'Articles', url: 'https://example.com/articles' },
104
+ { name: 'Authors', url: 'https://example.com/articles/authors' },
105
+ { name: 'Andrew Blase' },
106
+ ])
107
+ })
108
+
109
+ it('supports custom URL entries in category and author breadcrumbs', () => {
110
+ expect(
111
+ buildCategoryBreadcrumbs('civic-tech', {
112
+ ...config,
113
+ breadcrumbs: {
114
+ category: ['home', { name: 'Knowledge Base', url: '/knowledge' }, 'category'],
115
+ },
116
+ })
117
+ ).toEqual([
118
+ { name: 'Home', url: 'https://example.com' },
119
+ { name: 'Knowledge Base', url: 'https://example.com/knowledge' },
120
+ { name: 'Civic Tech' },
121
+ ])
122
+
123
+ expect(
124
+ buildAuthorBreadcrumbs(config.authors['andrew-blase'], {
125
+ ...config,
126
+ breadcrumbs: {
127
+ author: [
128
+ { name: 'Team', url: '/team' },
129
+ { name: 'Editorial', url: 'https://example.org/editorial' },
130
+ 'authorName',
131
+ ],
132
+ },
133
+ })
134
+ ).toEqual([
135
+ { name: 'Team', url: 'https://example.com/team' },
136
+ { name: 'Editorial', url: 'https://example.org/editorial' },
137
+ { name: 'Andrew Blase' },
138
+ ])
139
+ })
140
+ })
141
+
142
+ describe('author metadata utilities', () => {
143
+ it('generates author static params from configured authors', () => {
144
+ expect(generateAuthorStaticParams(config)).toEqual([{ author: 'andrew-blase' }])
145
+ })
146
+
147
+ it('returns no author static params when author pages are disabled', () => {
148
+ expect(generateAuthorStaticParams({ ...config, showAuthorPage: false })).toEqual([])
149
+ })
150
+
151
+ it('generates author metadata', async () => {
152
+ await expect(generateAuthorMetadata('andrew-blase', config)).resolves.toMatchObject({
153
+ title: 'Andrew Blase Articles | Example',
154
+ description: 'Writer.',
155
+ alternates: {
156
+ canonical: 'https://example.com/articles/authors/andrew-blase',
157
+ },
158
+ })
159
+ })
160
+ })
@@ -15,6 +15,10 @@ jest.mock('../server-articles', () => ({
15
15
  { name: 'Campaigns', slug: 'campaigns', count: 2, featuredImage: '/campaigns.jpg' },
16
16
  { name: 'Volunteers', slug: 'volunteers', count: 1, featuredImage: '/volunteers.jpg' },
17
17
  ]),
18
+ getAllAuthors: jest.fn(() => []),
19
+ getArticleAuthors: jest.fn((article: { author?: string }) =>
20
+ article.author ? [{ name: article.author, slug: article.author, bio: '' }] : []
21
+ ),
18
22
  getArticleMetadata: jest.fn(async (slug: string) => {
19
23
  if (slug === 'article-one') {
20
24
  return {