@fullstackdatasolutions/articles 0.9.0 → 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.
- package/CHANGELOG.md +237 -0
- package/README.md +199 -29
- package/dist/index.cjs +635 -274
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +160 -56
- package/dist/index.d.ts +160 -56
- package/dist/index.js +614 -250
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +40 -5
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +67 -0
- package/dist/nextjs.d.ts +67 -0
- package/dist/nextjs.js +40 -5
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +278 -15
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +87 -3
- package/dist/server.d.ts +87 -3
- package/dist/server.js +267 -15
- package/dist/server.js.map +1 -1
- package/package.json +8 -5
- package/src/ArticleDetailHero.tsx +27 -2
- package/src/ArticleSchemas.tsx +27 -27
- package/src/AuthorArticlesPage.tsx +60 -0
- package/src/AuthorCard.tsx +112 -0
- package/src/AuthorDetailHero.tsx +56 -0
- package/src/Breadcrumb.tsx +78 -0
- package/src/CategoryArticlesPage.tsx +62 -11
- package/src/__tests__/ArticleDetailHero.test.tsx +21 -1
- package/src/__tests__/ArticleSchemas.test.tsx +47 -2
- package/src/__tests__/AuthorArticlesPage.test.tsx +74 -0
- package/src/__tests__/AuthorCard.test.tsx +98 -0
- package/src/__tests__/AuthorDetailHero.test.tsx +51 -0
- package/src/__tests__/CategoryArticlesPage.test.tsx +31 -5
- package/src/__tests__/authorUtils.test.ts +89 -0
- package/src/__tests__/renderMdx.test.tsx +35 -0
- package/src/__tests__/seoUtils-authors.test.ts +160 -0
- package/src/__tests__/seoUtils.test.ts +4 -0
- package/src/__tests__/server-articles.test.ts +159 -2
- package/src/articleTypes.ts +33 -0
- package/src/articlesConfig.ts +62 -0
- package/src/authorUtils.ts +95 -0
- package/src/index.ts +31 -9
- package/src/renderMdx.tsx +3 -1
- package/src/seoUtils.ts +226 -7
- package/src/server-articles.ts +98 -10
- 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.
|
|
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
|
+
})
|
|
@@ -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('', '/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({
|
|
@@ -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 {
|