@fullstackdatasolutions/articles 0.8.2 → 0.10.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 (51) hide show
  1. package/CHANGELOG.md +237 -0
  2. package/README.md +209 -78
  3. package/dist/index.cjs +635 -274
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +164 -56
  6. package/dist/index.d.ts +164 -56
  7. package/dist/index.js +614 -250
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +113 -38
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +71 -0
  12. package/dist/nextjs.d.ts +71 -0
  13. package/dist/nextjs.js +113 -38
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +394 -52
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +96 -6
  18. package/dist/server.d.ts +96 -6
  19. package/dist/server.js +382 -52
  20. package/dist/server.js.map +1 -1
  21. package/package.json +8 -5
  22. package/src/ArticleContent.tsx +8 -3
  23. package/src/ArticleDetailHero.tsx +27 -2
  24. package/src/ArticleSchemas.tsx +27 -27
  25. package/src/AuthorArticlesPage.tsx +60 -0
  26. package/src/AuthorCard.tsx +112 -0
  27. package/src/AuthorDetailHero.tsx +56 -0
  28. package/src/Breadcrumb.tsx +78 -0
  29. package/src/CategoryArticlesPage.tsx +62 -11
  30. package/src/__tests__/ArticleContent.test.tsx +18 -2
  31. package/src/__tests__/ArticleDetailHero.test.tsx +21 -1
  32. package/src/__tests__/ArticleSchemas.test.tsx +47 -2
  33. package/src/__tests__/AuthorArticlesPage.test.tsx +74 -0
  34. package/src/__tests__/AuthorCard.test.tsx +98 -0
  35. package/src/__tests__/AuthorDetailHero.test.tsx +51 -0
  36. package/src/__tests__/CategoryArticlesPage.test.tsx +31 -5
  37. package/src/__tests__/authorUtils.test.ts +89 -0
  38. package/src/__tests__/markdown.test.ts +79 -3
  39. package/src/__tests__/renderMdx.test.tsx +57 -0
  40. package/src/__tests__/seoUtils-authors.test.ts +160 -0
  41. package/src/__tests__/seoUtils.test.ts +106 -0
  42. package/src/__tests__/server-articles.test.ts +174 -3
  43. package/src/articleTypes.ts +33 -0
  44. package/src/articlesConfig.ts +67 -0
  45. package/src/authorUtils.ts +95 -0
  46. package/src/index.ts +32 -9
  47. package/src/markdown.ts +67 -8
  48. package/src/renderMdx.tsx +6 -3
  49. package/src/seoUtils.ts +279 -6
  50. package/src/server-articles.ts +124 -34
  51. package/src/server.ts +21 -2
@@ -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', () => {
@@ -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
+ })
@@ -9,11 +9,16 @@ jest.mock('remark-github-blockquote-alert', () => () => () => {})
9
9
  jest.mock('remark-parse', () => () => () => {})
10
10
  jest.mock('remark-rehype', () => () => () => {})
11
11
 
12
+ const mockRemarkUseCalls: unknown[][] = []
13
+
12
14
  // Mock remark() to return a chainable processor whose .process() resolves to ''
13
15
  jest.mock('remark', () => {
14
16
  const makeChain = (): Record<string, unknown> => {
15
17
  const chain: Record<string, unknown> = {
16
- use: () => chain,
18
+ use: (...args: unknown[]) => {
19
+ mockRemarkUseCalls.push(args)
20
+ return chain
21
+ },
17
22
  process: async () => ({ toString: () => '' }),
18
23
  }
19
24
  return chain
@@ -80,8 +85,15 @@ function root(...nodes: HastElement[]): HastRoot {
80
85
  return { type: 'root', children: nodes }
81
86
  }
82
87
 
83
- function runRenderer(tree: HastRoot) {
84
- const transform = (customRenderer as unknown as () => (tree: HastRoot) => void)()
88
+ type LinkTargetOptions = Readonly<{
89
+ strategy?: 'external-new-tab' | 'all-new-tab' | 'same-tab'
90
+ siteUrl?: string
91
+ }>
92
+
93
+ function runRenderer(tree: HastRoot, options?: LinkTargetOptions) {
94
+ const transform = (
95
+ customRenderer as unknown as (options?: LinkTargetOptions) => (tree: HastRoot) => void
96
+ )(options)
85
97
  transform(tree)
86
98
  }
87
99
 
@@ -212,6 +224,13 @@ describe('customRenderer', () => {
212
224
  expect(node.properties.rel).toBe('noopener noreferrer')
213
225
  })
214
226
 
227
+ it('does NOT add target="_blank" to root-relative internal links by default', () => {
228
+ const node = el('a', { href: '/internal' })
229
+ runRenderer(root(node))
230
+ expect(node.properties.target).toBeUndefined()
231
+ expect(node.properties.rel).toBeUndefined()
232
+ })
233
+
215
234
  it('does NOT add target="_blank" to internal anchor links (#)', () => {
216
235
  const node = el('a', { href: '#section-id' })
217
236
  runRenderer(root(node))
@@ -225,6 +244,46 @@ describe('customRenderer', () => {
225
244
  expect(node.properties.target).toBeUndefined()
226
245
  })
227
246
 
247
+ it('does NOT add target="_blank" to same-origin absolute links', () => {
248
+ const node = el('a', { href: 'https://example.com/articles/internal' })
249
+ runRenderer(root(node), { siteUrl: 'https://example.com' })
250
+ expect(node.properties.target).toBeUndefined()
251
+ expect(node.properties.rel).toBeUndefined()
252
+ })
253
+
254
+ it('adds target="_blank" and rel to different-origin absolute links', () => {
255
+ const node = el('a', { href: 'https://different-site.com' })
256
+ runRenderer(root(node), { siteUrl: 'https://example.com' })
257
+ expect(node.properties.target).toBe('_blank')
258
+ expect(node.properties.rel).toBe('noopener noreferrer')
259
+ })
260
+
261
+ it('opens browser navigation links in a new tab when strategy is all-new-tab', () => {
262
+ const node = el('a', { href: '/internal' })
263
+ runRenderer(root(node), { strategy: 'all-new-tab', siteUrl: 'https://example.com' })
264
+ expect(node.properties.target).toBe('_blank')
265
+ expect(node.properties.rel).toBe('noopener noreferrer')
266
+ })
267
+
268
+ it('does not open links in a new tab when strategy is same-tab', () => {
269
+ const node = el('a', { href: 'https://different-site.com' })
270
+ runRenderer(root(node), { strategy: 'same-tab', siteUrl: 'https://example.com' })
271
+ expect(node.properties.target).toBeUndefined()
272
+ expect(node.properties.rel).toBeUndefined()
273
+ })
274
+
275
+ it('leaves mailto and tel links in the same tab', () => {
276
+ const nodes = [
277
+ el('a', { href: 'mailto:test@example.com' }),
278
+ el('a', { href: 'tel:+15555555555' }),
279
+ ]
280
+ runRenderer(root(...nodes), { strategy: 'all-new-tab', siteUrl: 'https://example.com' })
281
+ nodes.forEach((node) => {
282
+ expect(node.properties.target).toBeUndefined()
283
+ expect(node.properties.rel).toBeUndefined()
284
+ })
285
+ })
286
+
228
287
  it('applies link className to external anchors', () => {
229
288
  const node = el('a', { href: 'https://external.com' })
230
289
  runRenderer(root(node))
@@ -248,6 +307,10 @@ describe('customRenderer', () => {
248
307
  // ---------------------------------------------------------------------------
249
308
 
250
309
  describe('markdownToHtml', () => {
310
+ beforeEach(() => {
311
+ mockRemarkUseCalls.length = 0
312
+ })
313
+
251
314
  it('returns a string', async () => {
252
315
  const result = await markdownToHtml('# Hello')
253
316
  expect(typeof result).toBe('string')
@@ -260,6 +323,19 @@ describe('markdownToHtml', () => {
260
323
  it('resolves without throwing when articleSlug is provided', async () => {
261
324
  await expect(markdownToHtml('![alt](photo.png)', 'my-article')).resolves.toBeDefined()
262
325
  })
326
+
327
+ it('passes link target config to customRenderer', async () => {
328
+ await markdownToHtml('# Hello', 'my-article', {
329
+ siteUrl: 'https://example.com',
330
+ siteName: 'Example',
331
+ linkTargetStrategy: 'external-new-tab',
332
+ })
333
+
334
+ expect(mockRemarkUseCalls).toContainEqual([
335
+ customRenderer,
336
+ { strategy: 'external-new-tab', siteUrl: 'https://example.com' },
337
+ ])
338
+ })
263
339
  })
264
340
 
265
341
  // ---------------------------------------------------------------------------
@@ -179,6 +179,28 @@ describe('renderMdxSource', () => {
179
179
  expect(container.querySelector('a[href="#section-one"]')).not.toBeNull()
180
180
  expect(container.querySelector('a[href="#section-two"]')).not.toBeNull()
181
181
  })
182
+
183
+ it('passes link target config into the markdown renderer plugin', async () => {
184
+ mockEvaluate.mockResolvedValue({
185
+ default: () => React.createElement('a', { href: 'https://external.com' }, 'External'),
186
+ })
187
+ Object.defineProperty(process.env, 'NODE_ENV', { value: 'production', writable: true })
188
+
189
+ const config = {
190
+ siteUrl: 'https://example.com',
191
+ siteName: 'Example',
192
+ linkTargetStrategy: 'external-new-tab' as const,
193
+ }
194
+ const { renderMdxSource } = await import('../renderMdx')
195
+ await renderMdxSource('[External](https://external.com)', '/articles/my-article', config)
196
+
197
+ const callArgs = mockEvaluate.mock.calls[0][1]
198
+ expect(callArgs.rehypePlugins).toEqual(
199
+ expect.arrayContaining([
200
+ [expect.any(Function), { strategy: 'external-new-tab', siteUrl: 'https://example.com' }],
201
+ ])
202
+ )
203
+ })
182
204
  })
183
205
 
184
206
  describe('basePath image resolution', () => {
@@ -261,6 +283,41 @@ describe('renderMdxSource', () => {
261
283
  expect(img?.getAttribute('src')).toBe('https://example.com/img.png')
262
284
  })
263
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
+
264
321
  it('does not inject img component when basePath is not provided', async () => {
265
322
  let capturedComponents: Record<string, unknown> | undefined
266
323
  mockEvaluate.mockResolvedValue({
@@ -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
+ })