@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.
- package/CHANGELOG.md +237 -0
- package/README.md +209 -78
- package/dist/index.cjs +635 -274
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +164 -56
- package/dist/index.d.ts +164 -56
- package/dist/index.js +614 -250
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +113 -38
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +71 -0
- package/dist/nextjs.d.ts +71 -0
- package/dist/nextjs.js +113 -38
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +394 -52
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +96 -6
- package/dist/server.d.ts +96 -6
- package/dist/server.js +382 -52
- package/dist/server.js.map +1 -1
- package/package.json +8 -5
- package/src/ArticleContent.tsx +8 -3
- 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__/ArticleContent.test.tsx +18 -2
- 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__/markdown.test.ts +79 -3
- package/src/__tests__/renderMdx.test.tsx +57 -0
- package/src/__tests__/seoUtils-authors.test.ts +160 -0
- package/src/__tests__/seoUtils.test.ts +106 -0
- package/src/__tests__/server-articles.test.ts +174 -3
- package/src/articleTypes.ts +33 -0
- package/src/articlesConfig.ts +67 -0
- package/src/authorUtils.ts +95 -0
- package/src/index.ts +32 -9
- package/src/markdown.ts +67 -8
- package/src/renderMdx.tsx +6 -3
- package/src/seoUtils.ts +279 -6
- package/src/server-articles.ts +124 -34
- 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.
|
|
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: () =>
|
|
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
|
-
|
|
84
|
-
|
|
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('', '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('', '/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
|
+
})
|