@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.
- package/CHANGELOG.md +243 -0
- package/README.md +226 -29
- package/dist/index.cjs +635 -274
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +165 -56
- package/dist/index.d.ts +165 -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 +72 -0
- package/dist/nextjs.d.ts +72 -0
- package/dist/nextjs.js +40 -5
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +280 -16
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +92 -3
- package/dist/server.d.ts +92 -3
- package/dist/server.js +269 -16
- 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__/articlesConfig.test.ts +20 -1
- package/src/__tests__/authorUtils.test.ts +89 -0
- package/src/__tests__/renderMdx.test.tsx +113 -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 +68 -0
- package/src/authorUtils.ts +95 -0
- package/src/index.ts +32 -9
- package/src/renderMdx.tsx +8 -2
- package/src/seoUtils.ts +226 -7
- package/src/server-articles.ts +98 -10
- package/src/server.ts +19 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { LatestArticles } from './LatestArticles'
|
|
5
|
+
import { getAuthorAvatar, getAuthorSameAs, getAuthorUrl } from './authorUtils'
|
|
6
|
+
import { DEFAULT_PAGE_SIZE } from './articlesConfig'
|
|
7
|
+
import type { ArticlesConfig } from './articlesConfig'
|
|
8
|
+
import type { Article, AuthorProfile } from './articleTypes'
|
|
9
|
+
|
|
10
|
+
type AuthorArticlesPageProps = Readonly<{
|
|
11
|
+
author: AuthorProfile
|
|
12
|
+
articles: Article[]
|
|
13
|
+
config: ArticlesConfig
|
|
14
|
+
}>
|
|
15
|
+
|
|
16
|
+
function getPersonSchema(author: AuthorProfile, config: ArticlesConfig, articleCount: number) {
|
|
17
|
+
return {
|
|
18
|
+
'@context': 'https://schema.org',
|
|
19
|
+
'@type': 'Person',
|
|
20
|
+
name: author.name,
|
|
21
|
+
description: author.bio,
|
|
22
|
+
url: author.url ?? getAuthorUrl(author),
|
|
23
|
+
...(getAuthorAvatar(author, config) && { image: getAuthorAvatar(author, config) }),
|
|
24
|
+
...(getAuthorSameAs(author).length > 0 && { sameAs: getAuthorSameAs(author) }),
|
|
25
|
+
knowsAbout: config.siteName,
|
|
26
|
+
mainEntityOfPage:
|
|
27
|
+
author.url ?? `${config.siteUrl.replace(/\/$/, '')}/articles/authors/${author.slug}`,
|
|
28
|
+
interactionStatistic: {
|
|
29
|
+
'@type': 'InteractionCounter',
|
|
30
|
+
interactionType: 'https://schema.org/WriteAction',
|
|
31
|
+
userInteractionCount: articleCount,
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function AuthorArticlesPage({ author, articles, config }: AuthorArticlesPageProps) {
|
|
37
|
+
const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div>
|
|
41
|
+
<script
|
|
42
|
+
type="application/ld+json"
|
|
43
|
+
dangerouslySetInnerHTML={{
|
|
44
|
+
__html: JSON.stringify(getPersonSchema(author, config, articles.length)),
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
<section className="bg-background py-16">
|
|
48
|
+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
49
|
+
<div className="mb-8 flex items-center justify-between gap-4">
|
|
50
|
+
<Link href="/articles" className="text-sm text-primary hover:underline">
|
|
51
|
+
All Articles
|
|
52
|
+
</Link>
|
|
53
|
+
<p className="text-sm text-muted-foreground">Articles by {author.name}</p>
|
|
54
|
+
</div>
|
|
55
|
+
<LatestArticles articles={articles} pageSize={pageSize} />
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import Image from 'next/image'
|
|
2
|
+
import Link from 'next/link'
|
|
3
|
+
import {
|
|
4
|
+
Facebook,
|
|
5
|
+
Github,
|
|
6
|
+
Globe,
|
|
7
|
+
Instagram,
|
|
8
|
+
Linkedin,
|
|
9
|
+
Mail,
|
|
10
|
+
MessageCircle,
|
|
11
|
+
Twitter,
|
|
12
|
+
Youtube,
|
|
13
|
+
type LucideIcon,
|
|
14
|
+
} from 'lucide-react'
|
|
15
|
+
import type { AuthorProfile } from './articleTypes'
|
|
16
|
+
import { getAuthorAvatar, getAuthorSocialLinks, getAuthorUrl } from './authorUtils'
|
|
17
|
+
|
|
18
|
+
type AuthorCardProps = Readonly<{
|
|
19
|
+
author: AuthorProfile
|
|
20
|
+
linkToPage?: boolean
|
|
21
|
+
showBio?: boolean
|
|
22
|
+
showSocial?: boolean
|
|
23
|
+
}>
|
|
24
|
+
|
|
25
|
+
function getInitials(name: string): string {
|
|
26
|
+
return name
|
|
27
|
+
.split(' ')
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.slice(0, 2)
|
|
30
|
+
.map((part) => part.charAt(0).toUpperCase())
|
|
31
|
+
.join('')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function AuthorCard({
|
|
35
|
+
author,
|
|
36
|
+
linkToPage = true,
|
|
37
|
+
showBio = false,
|
|
38
|
+
showSocial = false,
|
|
39
|
+
}: AuthorCardProps) {
|
|
40
|
+
const avatar = getAuthorAvatar(author)
|
|
41
|
+
const name = linkToPage ? (
|
|
42
|
+
<Link href={getAuthorUrl(author)} className="font-medium text-foreground hover:text-primary">
|
|
43
|
+
{author.name}
|
|
44
|
+
</Link>
|
|
45
|
+
) : (
|
|
46
|
+
<span className="font-medium text-foreground">{author.name}</span>
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex items-start gap-3 rounded-lg border border-border bg-card p-4 text-card-foreground">
|
|
51
|
+
{avatar ? (
|
|
52
|
+
<Image
|
|
53
|
+
src={avatar}
|
|
54
|
+
alt={author.name}
|
|
55
|
+
width={48}
|
|
56
|
+
height={48}
|
|
57
|
+
className="h-12 w-12 rounded-full object-cover"
|
|
58
|
+
/>
|
|
59
|
+
) : (
|
|
60
|
+
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-semibold text-muted-foreground">
|
|
61
|
+
{getInitials(author.name)}
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
<div className="min-w-0 flex-1">
|
|
65
|
+
{name}
|
|
66
|
+
{showBio && author.bio && (
|
|
67
|
+
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{author.bio}</p>
|
|
68
|
+
)}
|
|
69
|
+
{showSocial && <AuthorSocialLinks author={author} />}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SOCIAL_ICONS: Record<string, LucideIcon> = {
|
|
76
|
+
Website: Globe,
|
|
77
|
+
Facebook: Facebook,
|
|
78
|
+
Twitter: Twitter,
|
|
79
|
+
X: Twitter,
|
|
80
|
+
LinkedIn: Linkedin,
|
|
81
|
+
Instagram: Instagram,
|
|
82
|
+
YouTube: Youtube,
|
|
83
|
+
GitHub: Github,
|
|
84
|
+
Newsletter: Mail,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getSocialIcon(label: string): LucideIcon {
|
|
88
|
+
return SOCIAL_ICONS[label] ?? MessageCircle
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function AuthorSocialLinks({ author }: Readonly<{ author: AuthorProfile }>) {
|
|
92
|
+
const links = getAuthorSocialLinks(author)
|
|
93
|
+
if (links.length === 0) return null
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="mt-3 flex items-center gap-2">
|
|
97
|
+
{links.map(({ href, label }) => {
|
|
98
|
+
const Icon = getSocialIcon(label)
|
|
99
|
+
return (
|
|
100
|
+
<Link
|
|
101
|
+
key={`${label}-${href}`}
|
|
102
|
+
href={href}
|
|
103
|
+
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
104
|
+
aria-label={`${author.name} on ${label}`}
|
|
105
|
+
>
|
|
106
|
+
<Icon className="h-4 w-4" />
|
|
107
|
+
</Link>
|
|
108
|
+
)
|
|
109
|
+
})}
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import Image from 'next/image'
|
|
2
|
+
import type { AuthorProfile } from './articleTypes'
|
|
3
|
+
import { AuthorSocialLinks } from './AuthorCard'
|
|
4
|
+
import { getAuthorAvatar } from './authorUtils'
|
|
5
|
+
|
|
6
|
+
type AuthorDetailHeroProps = Readonly<{
|
|
7
|
+
author: AuthorProfile
|
|
8
|
+
articleCount?: number
|
|
9
|
+
}>
|
|
10
|
+
|
|
11
|
+
function getInitials(name: string): string {
|
|
12
|
+
return name
|
|
13
|
+
.split(' ')
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.slice(0, 2)
|
|
16
|
+
.map((part) => part.charAt(0).toUpperCase())
|
|
17
|
+
.join('')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function AuthorDetailHero({ author, articleCount }: AuthorDetailHeroProps) {
|
|
21
|
+
const avatar = getAuthorAvatar(author)
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<section className="bg-muted/40 py-16">
|
|
25
|
+
<div className="mx-auto flex max-w-4xl flex-col items-center gap-6 px-4 text-center sm:px-6 lg:px-8">
|
|
26
|
+
{avatar ? (
|
|
27
|
+
<Image
|
|
28
|
+
src={avatar}
|
|
29
|
+
alt={author.name}
|
|
30
|
+
width={120}
|
|
31
|
+
height={120}
|
|
32
|
+
className="h-28 w-28 rounded-full object-cover"
|
|
33
|
+
priority
|
|
34
|
+
/>
|
|
35
|
+
) : (
|
|
36
|
+
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-card text-3xl font-semibold text-muted-foreground shadow-sm">
|
|
37
|
+
{getInitials(author.name)}
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
40
|
+
<div>
|
|
41
|
+
<p className="mb-3 text-sm font-semibold uppercase tracking-widest text-muted-foreground">
|
|
42
|
+
Author
|
|
43
|
+
</p>
|
|
44
|
+
<h1 className="text-4xl font-bold text-foreground md:text-5xl">{author.name}</h1>
|
|
45
|
+
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">{author.bio}</p>
|
|
46
|
+
{typeof articleCount === 'number' && (
|
|
47
|
+
<p className="mt-3 text-sm text-muted-foreground">
|
|
48
|
+
{articleCount} article{articleCount === 1 ? '' : 's'}
|
|
49
|
+
</p>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
<AuthorSocialLinks author={author} />
|
|
53
|
+
</div>
|
|
54
|
+
</section>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import Link from 'next/link'
|
|
2
|
+
import type { BreadcrumbItem } from './articleTypes'
|
|
3
|
+
|
|
4
|
+
type BreadcrumbProps = Readonly<{
|
|
5
|
+
items: BreadcrumbItem[]
|
|
6
|
+
className?: string
|
|
7
|
+
showSchema?: boolean
|
|
8
|
+
separator?: string
|
|
9
|
+
}>
|
|
10
|
+
|
|
11
|
+
function buildBreadcrumbSchema(items: readonly BreadcrumbItem[]) {
|
|
12
|
+
return {
|
|
13
|
+
'@context': 'https://schema.org',
|
|
14
|
+
'@type': 'BreadcrumbList',
|
|
15
|
+
itemListElement: items.map((item, index) => ({
|
|
16
|
+
'@type': 'ListItem',
|
|
17
|
+
position: index + 1,
|
|
18
|
+
name: item.name,
|
|
19
|
+
...(item.url && { item: item.url }),
|
|
20
|
+
})),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getDisplayItems(items: readonly BreadcrumbItem[]): BreadcrumbItem[] {
|
|
25
|
+
if (items.length <= 4) return [...items]
|
|
26
|
+
return [items[0], { name: '...' }, ...items.slice(-2)]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Breadcrumb({
|
|
30
|
+
items,
|
|
31
|
+
className = '',
|
|
32
|
+
showSchema = true,
|
|
33
|
+
separator = '>',
|
|
34
|
+
}: BreadcrumbProps) {
|
|
35
|
+
if (items.length === 0) return null
|
|
36
|
+
|
|
37
|
+
const displayItems = getDisplayItems(items)
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<nav
|
|
42
|
+
aria-label="Breadcrumb"
|
|
43
|
+
className={`border-b border-border bg-background/80 px-4 py-3 text-sm text-muted-foreground ${className}`}
|
|
44
|
+
>
|
|
45
|
+
<ol className="mx-auto flex max-w-7xl items-center gap-2 overflow-hidden">
|
|
46
|
+
{displayItems.map((item, index) => {
|
|
47
|
+
const isLast = index === displayItems.length - 1
|
|
48
|
+
const key = `${item.name}-${index}`
|
|
49
|
+
return (
|
|
50
|
+
<li key={key} className="flex min-w-0 items-center gap-2">
|
|
51
|
+
{index > 0 && <span aria-hidden="true">{separator}</span>}
|
|
52
|
+
{item.url && !isLast ? (
|
|
53
|
+
<Link href={item.url} className="truncate hover:text-foreground">
|
|
54
|
+
{item.name}
|
|
55
|
+
</Link>
|
|
56
|
+
) : (
|
|
57
|
+
<span className="truncate" aria-current={isLast ? 'page' : undefined}>
|
|
58
|
+
{item.name}
|
|
59
|
+
</span>
|
|
60
|
+
)}
|
|
61
|
+
</li>
|
|
62
|
+
)
|
|
63
|
+
})}
|
|
64
|
+
</ol>
|
|
65
|
+
</nav>
|
|
66
|
+
{showSchema && (
|
|
67
|
+
<script
|
|
68
|
+
type="application/ld+json"
|
|
69
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(buildBreadcrumbSchema(items)) }}
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
</>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function BreadcrumbSchema({ items }: Readonly<{ items: BreadcrumbItem[] }>) {
|
|
77
|
+
return <Breadcrumb items={items} showSchema className="sr-only" />
|
|
78
|
+
}
|
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
import Image from 'next/image'
|
|
4
4
|
import Link from 'next/link'
|
|
5
|
-
import {
|
|
5
|
+
import { Breadcrumb } from './Breadcrumb'
|
|
6
6
|
import { LatestArticles } from './LatestArticles'
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
breadcrumbsAreEnabled,
|
|
9
|
+
getBreadcrumbsConfig,
|
|
10
|
+
ArticlesConfig,
|
|
11
|
+
DEFAULT_PAGE_SIZE,
|
|
12
|
+
type CategoryBreadcrumbEntry,
|
|
13
|
+
type CustomBreadcrumbItem,
|
|
14
|
+
} from './articlesConfig'
|
|
15
|
+
import type { Article, BreadcrumbItem } from './articleTypes'
|
|
9
16
|
|
|
10
17
|
function getCategoryDescription(
|
|
11
18
|
config: ArticlesConfig,
|
|
@@ -24,6 +31,49 @@ type CategoryArticlesPageProps = Readonly<{
|
|
|
24
31
|
config: ArticlesConfig
|
|
25
32
|
}>
|
|
26
33
|
|
|
34
|
+
function buildCategoryBreadcrumbItems(
|
|
35
|
+
config: ArticlesConfig,
|
|
36
|
+
categoryName: string
|
|
37
|
+
): BreadcrumbItem[] {
|
|
38
|
+
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
39
|
+
const breadcrumbConfig = getBreadcrumbsConfig(config)
|
|
40
|
+
const trail = breadcrumbConfig.category ?? ['home', 'articles', 'category']
|
|
41
|
+
return trail.flatMap((entry): BreadcrumbItem[] =>
|
|
42
|
+
buildCategoryBreadcrumbEntry(entry, {
|
|
43
|
+
categoryName,
|
|
44
|
+
siteUrl,
|
|
45
|
+
labels: breadcrumbConfig.labels ?? {},
|
|
46
|
+
})
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isCustomBreadcrumbItem(entry: unknown): entry is CustomBreadcrumbItem {
|
|
51
|
+
return typeof entry === 'object' && entry !== null && 'name' in entry && 'url' in entry
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveCustomBreadcrumbItem(item: CustomBreadcrumbItem, siteUrl: string): BreadcrumbItem {
|
|
55
|
+
if (item.url.startsWith('/')) return { name: item.name, url: `${siteUrl}${item.url}` }
|
|
56
|
+
return { name: item.name, url: item.url }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildCategoryBreadcrumbEntry(
|
|
60
|
+
entry: CategoryBreadcrumbEntry,
|
|
61
|
+
context: Readonly<{
|
|
62
|
+
categoryName: string
|
|
63
|
+
siteUrl: string
|
|
64
|
+
labels: NonNullable<ReturnType<typeof getBreadcrumbsConfig>['labels']>
|
|
65
|
+
}>
|
|
66
|
+
): BreadcrumbItem[] {
|
|
67
|
+
if (isCustomBreadcrumbItem(entry)) {
|
|
68
|
+
return [resolveCustomBreadcrumbItem(entry, context.siteUrl)]
|
|
69
|
+
}
|
|
70
|
+
if (entry === 'home') return [{ name: context.labels.home ?? 'Home', url: context.siteUrl }]
|
|
71
|
+
if (entry === 'articles') {
|
|
72
|
+
return [{ name: context.labels.articles ?? 'Articles', url: `${context.siteUrl}/articles` }]
|
|
73
|
+
}
|
|
74
|
+
return [{ name: context.categoryName }]
|
|
75
|
+
}
|
|
76
|
+
|
|
27
77
|
export function CategoryArticlesPage({ category, articles, config }: CategoryArticlesPageProps) {
|
|
28
78
|
if (articles.length === 0) return null
|
|
29
79
|
|
|
@@ -31,17 +81,18 @@ export function CategoryArticlesPage({ category, articles, config }: CategoryArt
|
|
|
31
81
|
const heroImage = articles[0].featuredImage
|
|
32
82
|
const description = getCategoryDescription(config, category, categoryName)
|
|
33
83
|
const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE
|
|
34
|
-
const
|
|
84
|
+
const breadcrumbConfig = getBreadcrumbsConfig(config)
|
|
85
|
+
const breadcrumbItems = buildCategoryBreadcrumbItems(config, categoryName)
|
|
35
86
|
|
|
36
87
|
return (
|
|
37
88
|
<div>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
{
|
|
41
|
-
{
|
|
42
|
-
{
|
|
43
|
-
|
|
44
|
-
|
|
89
|
+
{breadcrumbsAreEnabled(config) && (
|
|
90
|
+
<Breadcrumb
|
|
91
|
+
items={breadcrumbItems}
|
|
92
|
+
separator={breadcrumbConfig.separator}
|
|
93
|
+
showSchema={breadcrumbConfig.showSchema !== false}
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
45
96
|
{/* Hero */}
|
|
46
97
|
<section
|
|
47
98
|
style={{
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import React from 'react'
|
|
5
5
|
import { render, screen } from '@testing-library/react'
|
|
6
|
-
import type { Article } from '../articleTypes'
|
|
6
|
+
import type { Article, AuthorProfile } from '../articleTypes'
|
|
7
7
|
import { ArticleDetailHero } from '../ArticleDetailHero'
|
|
8
8
|
|
|
9
9
|
jest.mock('next/link', () => ({
|
|
@@ -39,6 +39,12 @@ const articleWith3Categories: Article = {
|
|
|
39
39
|
categories: ['Politics', 'Elections', 'Voting'],
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
const configuredAuthor: AuthorProfile = {
|
|
43
|
+
name: 'Andrew Blase',
|
|
44
|
+
slug: 'andrew-blase',
|
|
45
|
+
bio: 'Author bio',
|
|
46
|
+
}
|
|
47
|
+
|
|
42
48
|
describe('ArticleDetailHero', () => {
|
|
43
49
|
describe('category cap at 4', () => {
|
|
44
50
|
it('renders at most 4 category tags when the article has more than 4 categories', () => {
|
|
@@ -138,6 +144,20 @@ describe('ArticleDetailHero', () => {
|
|
|
138
144
|
expect(screen.getByText('By Jane Doe')).toBeInTheDocument()
|
|
139
145
|
})
|
|
140
146
|
|
|
147
|
+
it('renders configured authors in the byline', () => {
|
|
148
|
+
render(<ArticleDetailHero article={baseArticle} authors={[configuredAuthor]} />)
|
|
149
|
+
expect(screen.getByText(/^By$/)).toBeInTheDocument()
|
|
150
|
+
expect(screen.getByRole('link', { name: 'Andrew Blase' })).toHaveAttribute(
|
|
151
|
+
'href',
|
|
152
|
+
'/articles/authors/andrew-blase'
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('does not render an empty legacy author byline', () => {
|
|
157
|
+
render(<ArticleDetailHero article={{ ...baseArticle, author: '' }} />)
|
|
158
|
+
expect(screen.queryByText('By')).not.toBeInTheDocument()
|
|
159
|
+
})
|
|
160
|
+
|
|
141
161
|
it('hides the author when showAuthor is false', () => {
|
|
142
162
|
render(<ArticleDetailHero article={baseArticle} showAuthor={false} />)
|
|
143
163
|
expect(screen.queryByText('By Jane Doe')).not.toBeInTheDocument()
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
CollectionPageSchema,
|
|
8
8
|
FAQPageSchema,
|
|
9
9
|
} from '../ArticleSchemas'
|
|
10
|
+
import { Breadcrumb } from '../Breadcrumb'
|
|
10
11
|
|
|
11
12
|
const mockArticle: Article = {
|
|
12
13
|
slug: 'test-article',
|
|
@@ -56,7 +57,7 @@ describe('ArticleSchema', () => {
|
|
|
56
57
|
expect(schema['@type']).toBe('Article')
|
|
57
58
|
expect(schema.headline).toBe('Test Article')
|
|
58
59
|
expect(schema.description).toBe('A test excerpt.')
|
|
59
|
-
expect(schema.author.name).toBe('Jane Doe')
|
|
60
|
+
expect(schema.author[0].name).toBe('Jane Doe')
|
|
60
61
|
expect(schema.publisher.name).toBe('Example Site')
|
|
61
62
|
expect(schema.mainEntityOfPage['@id']).toBe('https://example.com/articles/test-article')
|
|
62
63
|
})
|
|
@@ -123,6 +124,23 @@ describe('BreadcrumbSchema', () => {
|
|
|
123
124
|
})
|
|
124
125
|
})
|
|
125
126
|
|
|
127
|
+
describe('Breadcrumb', () => {
|
|
128
|
+
const items = [
|
|
129
|
+
{ name: 'Home', url: 'https://example.com' },
|
|
130
|
+
{ name: 'Articles', url: 'https://example.com/articles' },
|
|
131
|
+
{ name: 'Test Article' },
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
it('renders visible breadcrumb navigation and marks the current page', () => {
|
|
135
|
+
const { container, getByRole, getByText } = render(<Breadcrumb items={items} />)
|
|
136
|
+
|
|
137
|
+
expect(getByRole('navigation', { name: 'Breadcrumb' })).toBeInTheDocument()
|
|
138
|
+
expect(getByRole('link', { name: 'Home' })).toHaveAttribute('href', 'https://example.com')
|
|
139
|
+
expect(getByText('Test Article')).toHaveAttribute('aria-current', 'page')
|
|
140
|
+
expect(container.querySelector('script[type="application/ld+json"]')).not.toBeNull()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
126
144
|
describe('CollectionPageSchema', () => {
|
|
127
145
|
it('renders a ld+json script tag', () => {
|
|
128
146
|
const { container } = render(
|
|
@@ -203,7 +221,7 @@ describe('ArticleSEO', () => {
|
|
|
203
221
|
expect(article).toBeDefined()
|
|
204
222
|
expect(article.headline).toBe('Test Article')
|
|
205
223
|
expect(article.description).toBe('A test excerpt.')
|
|
206
|
-
expect(article.author.name).toBe('Jane Doe')
|
|
224
|
+
expect(article.author[0].name).toBe('Jane Doe')
|
|
207
225
|
expect(article.publisher.name).toBe('Example Site')
|
|
208
226
|
expect(article.mainEntityOfPage['@id']).toBe('https://example.com/articles/test-article')
|
|
209
227
|
expect(article.datePublished).toBe(new Date('2025-03-01').toISOString())
|
|
@@ -224,6 +242,33 @@ describe('ArticleSEO', () => {
|
|
|
224
242
|
expect(article.author).toBeUndefined()
|
|
225
243
|
})
|
|
226
244
|
|
|
245
|
+
it('renders configured author Person schema fields', () => {
|
|
246
|
+
const { container } = render(
|
|
247
|
+
<ArticleSEO
|
|
248
|
+
article={{ ...baseArticle, author: 'andrew-blase' }}
|
|
249
|
+
articleUrl="https://example.com/articles/test-article"
|
|
250
|
+
siteName="Example Site"
|
|
251
|
+
authors={[
|
|
252
|
+
{
|
|
253
|
+
name: 'Andrew Blase',
|
|
254
|
+
slug: 'andrew-blase',
|
|
255
|
+
bio: 'Writer.',
|
|
256
|
+
url: 'https://example.com/articles/authors/andrew-blase',
|
|
257
|
+
social: { github: 'blazestudios23' },
|
|
258
|
+
},
|
|
259
|
+
]}
|
|
260
|
+
/>
|
|
261
|
+
)
|
|
262
|
+
const schemas = parseAllSchemaScripts(container)
|
|
263
|
+
const article = schemas.find((s) => s['@type'] === 'Article')
|
|
264
|
+
expect(article.author[0]).toEqual({
|
|
265
|
+
'@type': 'Person',
|
|
266
|
+
name: 'Andrew Blase',
|
|
267
|
+
url: 'https://example.com/articles/authors/andrew-blase',
|
|
268
|
+
sameAs: ['https://github.com/blazestudios23'],
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
227
272
|
it('uses articleType when provided', () => {
|
|
228
273
|
const { container } = render(
|
|
229
274
|
<ArticleSEO
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import type React from 'react'
|
|
3
|
+
import { AuthorArticlesPage } from '../AuthorArticlesPage'
|
|
4
|
+
|
|
5
|
+
jest.mock('next/link', () => ({
|
|
6
|
+
__esModule: true,
|
|
7
|
+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
|
8
|
+
<a href={href}>{children}</a>
|
|
9
|
+
),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
jest.mock('next/image', () => ({
|
|
13
|
+
__esModule: true,
|
|
14
|
+
default: ({ alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
|
15
|
+
<img alt={alt} {...props} />
|
|
16
|
+
),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
function parseSchemaScript(container: HTMLElement) {
|
|
20
|
+
const scripts = container.querySelectorAll('script[type="application/ld+json"]')
|
|
21
|
+
return Array.from(scripts).map((script) => JSON.parse(script.innerHTML))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const author = {
|
|
25
|
+
name: 'Andrew Blase',
|
|
26
|
+
slug: 'andrew-blase',
|
|
27
|
+
bio: 'Writer focused on civic technology.',
|
|
28
|
+
social: {
|
|
29
|
+
website: 'https://fullstackdatasolutions.com',
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const config = {
|
|
34
|
+
siteUrl: 'https://example.com',
|
|
35
|
+
siteName: 'Example',
|
|
36
|
+
pageSize: 6,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const articles = [
|
|
40
|
+
{
|
|
41
|
+
slug: 'test-article',
|
|
42
|
+
title: 'Test Article',
|
|
43
|
+
excerpt: 'Excerpt.',
|
|
44
|
+
author: 'Andrew Blase',
|
|
45
|
+
category: 'Campaigns',
|
|
46
|
+
categories: ['Campaigns'],
|
|
47
|
+
readTime: '3 min read',
|
|
48
|
+
featuredImage: '/image.jpg',
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
describe('AuthorArticlesPage', () => {
|
|
53
|
+
it('renders articles and Person schema', () => {
|
|
54
|
+
const { container } = render(
|
|
55
|
+
<AuthorArticlesPage author={author} articles={articles} config={config} />
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
expect(screen.getByText('Articles by Andrew Blase')).toBeInTheDocument()
|
|
59
|
+
expect(screen.getByRole('link', { name: 'Test Article' })).toHaveAttribute(
|
|
60
|
+
'href',
|
|
61
|
+
'/articles/test-article'
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const schemas = parseSchemaScript(container)
|
|
65
|
+
const person = schemas.find((schema) => schema['@type'] === 'Person')
|
|
66
|
+
expect(person).toEqual(
|
|
67
|
+
expect.objectContaining({
|
|
68
|
+
name: 'Andrew Blase',
|
|
69
|
+
description: 'Writer focused on civic technology.',
|
|
70
|
+
sameAs: ['https://fullstackdatasolutions.com'],
|
|
71
|
+
})
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
})
|