@fullstackdatasolutions/articles 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/README.md +226 -29
  3. package/dist/index.cjs +635 -274
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +165 -56
  6. package/dist/index.d.ts +165 -56
  7. package/dist/index.js +614 -250
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +40 -5
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +72 -0
  12. package/dist/nextjs.d.ts +72 -0
  13. package/dist/nextjs.js +40 -5
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +280 -16
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +92 -3
  18. package/dist/server.d.ts +92 -3
  19. package/dist/server.js +269 -16
  20. package/dist/server.js.map +1 -1
  21. package/package.json +8 -5
  22. package/src/ArticleDetailHero.tsx +27 -2
  23. package/src/ArticleSchemas.tsx +27 -27
  24. package/src/AuthorArticlesPage.tsx +60 -0
  25. package/src/AuthorCard.tsx +112 -0
  26. package/src/AuthorDetailHero.tsx +56 -0
  27. package/src/Breadcrumb.tsx +78 -0
  28. package/src/CategoryArticlesPage.tsx +62 -11
  29. package/src/__tests__/ArticleDetailHero.test.tsx +21 -1
  30. package/src/__tests__/ArticleSchemas.test.tsx +47 -2
  31. package/src/__tests__/AuthorArticlesPage.test.tsx +74 -0
  32. package/src/__tests__/AuthorCard.test.tsx +98 -0
  33. package/src/__tests__/AuthorDetailHero.test.tsx +51 -0
  34. package/src/__tests__/CategoryArticlesPage.test.tsx +31 -5
  35. package/src/__tests__/articlesConfig.test.ts +20 -1
  36. package/src/__tests__/authorUtils.test.ts +89 -0
  37. package/src/__tests__/renderMdx.test.tsx +113 -0
  38. package/src/__tests__/seoUtils-authors.test.ts +160 -0
  39. package/src/__tests__/seoUtils.test.ts +4 -0
  40. package/src/__tests__/server-articles.test.ts +159 -2
  41. package/src/articleTypes.ts +33 -0
  42. package/src/articlesConfig.ts +68 -0
  43. package/src/authorUtils.ts +95 -0
  44. package/src/index.ts +32 -9
  45. package/src/renderMdx.tsx +8 -2
  46. package/src/seoUtils.ts +226 -7
  47. package/src/server-articles.ts +98 -10
  48. package/src/server.ts +19 -1
@@ -0,0 +1,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 { BreadcrumbSchema } from './ArticleSchemas'
5
+ import { Breadcrumb } from './Breadcrumb'
6
6
  import { LatestArticles } from './LatestArticles'
7
- import { ArticlesConfig, DEFAULT_PAGE_SIZE } from './articlesConfig'
8
- import type { Article } from './articleTypes'
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 siteUrl = config.siteUrl.replace(/\/$/, '')
84
+ const breadcrumbConfig = getBreadcrumbsConfig(config)
85
+ const breadcrumbItems = buildCategoryBreadcrumbItems(config, categoryName)
35
86
 
36
87
  return (
37
88
  <div>
38
- <BreadcrumbSchema
39
- items={[
40
- { name: 'Home', url: siteUrl },
41
- { name: 'Articles', url: `${siteUrl}/articles` },
42
- { name: categoryName, url: `${siteUrl}/articles/category/${category}` },
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
+ })