@fullstackdatasolutions/articles 0.12.0 → 1.1.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 +34 -0
- package/README.md +559 -11
- package/dist/index.cjs +984 -388
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +383 -21
- package/dist/index.d.ts +383 -21
- package/dist/index.js +968 -376
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +74 -6
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +149 -0
- package/dist/nextjs.d.ts +149 -0
- package/dist/nextjs.js +74 -6
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +665 -27
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +357 -3
- package/dist/server.d.ts +357 -3
- package/dist/server.js +643 -29
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleCard.tsx +42 -6
- package/src/ArticleContent.tsx +144 -5
- package/src/ArticleDetailHero.tsx +33 -1
- package/src/ArticleNavigation.tsx +32 -1
- package/src/ArticleSchemas.tsx +43 -39
- package/src/ArticleSocialShare.tsx +54 -10
- package/src/ArticlesPage.tsx +56 -5
- package/src/AuthorArticlesPage.tsx +308 -14
- package/src/AuthorCard.tsx +1 -1
- package/src/CategoryArticlesPage.tsx +34 -2
- package/src/LatestArticles.tsx +28 -1
- package/src/LatestArticlesSection.tsx +15 -1
- package/src/PaginationNav.tsx +78 -0
- package/src/RelatedArticlesSection.tsx +58 -0
- package/src/SeriesArticlesPage.tsx +66 -0
- package/src/__tests__/ArticleCard.test.tsx +63 -3
- package/src/__tests__/ArticleContent.test.tsx +143 -0
- package/src/__tests__/ArticleDetailHero.test.tsx +30 -0
- package/src/__tests__/ArticleNavigation.test.tsx +81 -3
- package/src/__tests__/ArticleSchemas.test.tsx +155 -81
- package/src/__tests__/ArticleSocialShare.test.tsx +54 -0
- package/src/__tests__/ArticlesPage.test.tsx +131 -0
- package/src/__tests__/AuthorArticlesPage.test.tsx +304 -3
- package/src/__tests__/CategoryArticlesPage.test.tsx +116 -1
- package/src/__tests__/LatestArticles.test.tsx +52 -0
- package/src/__tests__/LatestArticlesSection.test.tsx +28 -0
- package/src/__tests__/PaginationNav.test.tsx +73 -0
- package/src/__tests__/RelatedArticlesSection.test.tsx +132 -0
- package/src/__tests__/SeriesArticlesPage.test.tsx +121 -0
- package/src/__tests__/eventTracking.test.tsx +145 -0
- package/src/__tests__/events.test.ts +82 -0
- package/src/__tests__/markdown.test.ts +78 -1
- package/src/__tests__/pagination.test.ts +178 -0
- package/src/__tests__/seoUtils-authors.test.ts +37 -0
- package/src/__tests__/seoUtils.test.ts +246 -0
- package/src/__tests__/server-articles.test.ts +356 -1
- package/src/__tests__/validateArticles.test.ts +312 -0
- package/src/articleTypes.ts +109 -0
- package/src/articlesConfig.ts +45 -1
- package/src/eventTracking.tsx +97 -0
- package/src/events.ts +105 -0
- package/src/index.ts +26 -1
- package/src/markdown.ts +41 -0
- package/src/pagination.ts +93 -0
- package/src/seoUtils.ts +198 -11
- package/src/server-articles.ts +199 -6
- package/src/server.ts +46 -2
- package/src/validateArticles.ts +260 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import Link from 'next/link'
|
|
2
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
3
|
+
import { buildPaginationLinks } from './pagination'
|
|
4
|
+
|
|
5
|
+
interface PaginationNavProps {
|
|
6
|
+
readonly basePath: string
|
|
7
|
+
readonly page: number
|
|
8
|
+
readonly totalPages: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Renders real `<a href>` prev/next links between listing pages (used only
|
|
13
|
+
* in `listingPagination: 'pages'` mode), plus `<link rel="prev"/"next">`
|
|
14
|
+
* tags for crawlers/tools that still read them.
|
|
15
|
+
*
|
|
16
|
+
* Google stopped using rel=next/prev as an indexing signal in 2019 (Google
|
|
17
|
+
* Search Central's pagination guidance now recommends a unique
|
|
18
|
+
* self-referencing canonical per page - see `generateArticlesIndexPageMetadata`
|
|
19
|
+
* et al in seoUtils.ts - plus real crawlable links between pages, which is
|
|
20
|
+
* what this component's visible Previous/Next links provide). rel=next/prev
|
|
21
|
+
* remains valid HTML and is still read by Bing and some third-party tools,
|
|
22
|
+
* so it's emitted here for free: React 19 hoists `<link>`/`<meta>` elements
|
|
23
|
+
* rendered anywhere in a Server Component tree into `<head>` automatically
|
|
24
|
+
* (not just from layout.js/page.js directly), so no separate Head API call
|
|
25
|
+
* is needed.
|
|
26
|
+
*/
|
|
27
|
+
export function PaginationNav({ basePath, page, totalPages }: PaginationNavProps) {
|
|
28
|
+
if (totalPages <= 1) return null
|
|
29
|
+
|
|
30
|
+
const { prevUrl, nextUrl } = buildPaginationLinks(basePath, page, totalPages)
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<nav
|
|
34
|
+
className="mt-10 flex items-center justify-center gap-4"
|
|
35
|
+
aria-label="Article listing pagination"
|
|
36
|
+
>
|
|
37
|
+
{prevUrl && <link rel="prev" href={prevUrl} />}
|
|
38
|
+
{nextUrl && <link rel="next" href={nextUrl} />}
|
|
39
|
+
{prevUrl ? (
|
|
40
|
+
<Link
|
|
41
|
+
href={prevUrl}
|
|
42
|
+
className="inline-flex items-center gap-1 px-4 py-2 border border-input bg-background hover:bg-accent rounded-md font-medium text-sm transition-colors"
|
|
43
|
+
>
|
|
44
|
+
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
|
45
|
+
Previous
|
|
46
|
+
</Link>
|
|
47
|
+
) : (
|
|
48
|
+
<span
|
|
49
|
+
aria-hidden="true"
|
|
50
|
+
className="inline-flex items-center gap-1 px-4 py-2 rounded-md font-medium text-sm text-muted-foreground opacity-50"
|
|
51
|
+
>
|
|
52
|
+
<ChevronLeft className="h-4 w-4" />
|
|
53
|
+
Previous
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
<span className="text-sm text-muted-foreground">
|
|
57
|
+
Page {page} of {totalPages}
|
|
58
|
+
</span>
|
|
59
|
+
{nextUrl ? (
|
|
60
|
+
<Link
|
|
61
|
+
href={nextUrl}
|
|
62
|
+
className="inline-flex items-center gap-1 px-4 py-2 border border-input bg-background hover:bg-accent rounded-md font-medium text-sm transition-colors"
|
|
63
|
+
>
|
|
64
|
+
Next
|
|
65
|
+
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
|
66
|
+
</Link>
|
|
67
|
+
) : (
|
|
68
|
+
<span
|
|
69
|
+
aria-hidden="true"
|
|
70
|
+
className="inline-flex items-center gap-1 px-4 py-2 rounded-md font-medium text-sm text-muted-foreground opacity-50"
|
|
71
|
+
>
|
|
72
|
+
Next
|
|
73
|
+
<ChevronRight className="h-4 w-4" />
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
</nav>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Article } from './articleTypes'
|
|
2
|
+
import type { ArticlesConfig } from './articlesConfig'
|
|
3
|
+
import { ArticleCard } from './ArticleCard'
|
|
4
|
+
import { emitArticleEvent } from './events'
|
|
5
|
+
import type { RelatedContentSource } from './server-articles'
|
|
6
|
+
|
|
7
|
+
type RelatedArticlesSectionProps = Readonly<{
|
|
8
|
+
articles: Article[]
|
|
9
|
+
category: string
|
|
10
|
+
/** Overrides the default "More in {category}" heading - used by `getRelatedContent` callers when `source` is `'path'`/`'series'`. Omit to keep the default category-based heading. */
|
|
11
|
+
heading?: string
|
|
12
|
+
/** Optional (Phase 27F). Enables `related_article_clicked` events - requires `fromSlug` too. */
|
|
13
|
+
config?: ArticlesConfig
|
|
14
|
+
/** The article slug this related section is shown on. Required to emit `related_article_clicked`. */
|
|
15
|
+
fromSlug?: string
|
|
16
|
+
/** Selection source that produced `articles`, from `getRelatedContent`. Defaults to `'category'`. */
|
|
17
|
+
source?: RelatedContentSource
|
|
18
|
+
}>
|
|
19
|
+
|
|
20
|
+
export function RelatedArticlesSection({
|
|
21
|
+
articles,
|
|
22
|
+
category,
|
|
23
|
+
heading,
|
|
24
|
+
config,
|
|
25
|
+
fromSlug,
|
|
26
|
+
source = 'category',
|
|
27
|
+
}: RelatedArticlesSectionProps) {
|
|
28
|
+
if (articles.length === 0) return null
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<section className="mt-12 pt-8 border-t border-border" aria-label="Related articles">
|
|
32
|
+
<h2
|
|
33
|
+
className="text-xl font-semibold text-foreground mb-6"
|
|
34
|
+
style={{ fontFamily: config?.theme?.headerFontFamily }}
|
|
35
|
+
>
|
|
36
|
+
{heading ?? `More in ${category}`}
|
|
37
|
+
</h2>
|
|
38
|
+
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
39
|
+
{articles.map((article) => (
|
|
40
|
+
<div
|
|
41
|
+
key={article.slug}
|
|
42
|
+
onClickCapture={() => {
|
|
43
|
+
if (!fromSlug) return
|
|
44
|
+
emitArticleEvent(config?.onEvent, {
|
|
45
|
+
name: 'related_article_clicked',
|
|
46
|
+
fromSlug,
|
|
47
|
+
toSlug: article.slug,
|
|
48
|
+
source,
|
|
49
|
+
})
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
<ArticleCard article={article} config={config} />
|
|
53
|
+
</div>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
</section>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { CollectionPageSchema } from './ArticleSchemas'
|
|
5
|
+
import { LatestArticles } from './LatestArticles'
|
|
6
|
+
import { DEFAULT_PAGE_SIZE, type ArticlesConfig } from './articlesConfig'
|
|
7
|
+
import type { Article } from './articleTypes'
|
|
8
|
+
|
|
9
|
+
type SeriesArticlesPageProps = Readonly<{
|
|
10
|
+
/** Machine-safe series identifier - matches `Article.seriesSlug`. */
|
|
11
|
+
seriesSlug: string
|
|
12
|
+
/** Already resolved via `getArticlesBySeries` (seriesOrder-sorted). */
|
|
13
|
+
articles: Article[]
|
|
14
|
+
config: ArticlesConfig
|
|
15
|
+
}>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Lightweight series landing page (Phase 27F) - a thinner sibling of
|
|
19
|
+
* `CategoryArticlesPage` for a `seriesSlug`'s ordered reader journey rather
|
|
20
|
+
* than a category's chronological listing. Displays series position badges
|
|
21
|
+
* (1-indexed, following `getArticlesBySeries`' order) so a reader can see
|
|
22
|
+
* where in the journey each article sits; doesn't attempt real ('pages'
|
|
23
|
+
* mode) pagination - series are expected to be short, curated lists rather
|
|
24
|
+
* than open-ended listings, so `LatestArticles` is used purely for its
|
|
25
|
+
* card grid, sized to show every article without a "Load more" step.
|
|
26
|
+
*/
|
|
27
|
+
export function SeriesArticlesPage({ seriesSlug, articles, config }: SeriesArticlesPageProps) {
|
|
28
|
+
if (articles.length === 0) return null
|
|
29
|
+
|
|
30
|
+
const seriesName = articles[0].series ?? seriesSlug
|
|
31
|
+
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
32
|
+
const seriesUrl = `${siteUrl}/articles/series/${seriesSlug}`
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div>
|
|
36
|
+
<CollectionPageSchema
|
|
37
|
+
title={`${seriesName} | ${config.siteName}`}
|
|
38
|
+
description={`Follow the ${seriesName} series on ${config.siteName}.`}
|
|
39
|
+
url={seriesUrl}
|
|
40
|
+
articleCount={articles.length}
|
|
41
|
+
items={articles.map((article, index) => ({
|
|
42
|
+
position: index + 1,
|
|
43
|
+
url: `${siteUrl}/articles/${article.slug}`,
|
|
44
|
+
name: article.title,
|
|
45
|
+
}))}
|
|
46
|
+
/>
|
|
47
|
+
<section className="py-16 bg-muted/50 flex-1">
|
|
48
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
49
|
+
<div className="mb-8 flex items-center justify-between">
|
|
50
|
+
<Link href="/articles" className="text-sm text-primary hover:underline">
|
|
51
|
+
← All Articles
|
|
52
|
+
</Link>
|
|
53
|
+
<p className="text-sm text-muted-foreground">
|
|
54
|
+
{articles.length} article{articles.length === 1 ? '' : 's'} in this series
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
<h1 className="text-3xl font-bold text-foreground mb-8">{seriesName}</h1>
|
|
58
|
+
<LatestArticles
|
|
59
|
+
articles={articles}
|
|
60
|
+
pageSize={Math.max(articles.length, DEFAULT_PAGE_SIZE)}
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
</section>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -2,14 +2,21 @@
|
|
|
2
2
|
* @jest-environment jsdom
|
|
3
3
|
*/
|
|
4
4
|
import React from 'react'
|
|
5
|
-
import { render, screen } from '@testing-library/react'
|
|
5
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
6
6
|
import type { Article } from '../articleTypes'
|
|
7
7
|
import { ArticleCard } from '../ArticleCard'
|
|
8
|
+
import type { ArticlesConfig } from '../articlesConfig'
|
|
8
9
|
|
|
9
10
|
jest.mock('next/link', () => ({
|
|
10
11
|
__esModule: true,
|
|
11
|
-
default: ({
|
|
12
|
-
|
|
12
|
+
default: ({
|
|
13
|
+
href,
|
|
14
|
+
children,
|
|
15
|
+
...rest
|
|
16
|
+
}: { href: string; children: React.ReactNode } & Record<string, unknown>) => (
|
|
17
|
+
<a href={href} {...rest}>
|
|
18
|
+
{children}
|
|
19
|
+
</a>
|
|
13
20
|
),
|
|
14
21
|
}))
|
|
15
22
|
|
|
@@ -75,4 +82,57 @@ describe('ArticleCard', () => {
|
|
|
75
82
|
.filter((l) => l.getAttribute('href') === '/articles/test-article')
|
|
76
83
|
expect(links.length).toBeGreaterThanOrEqual(2)
|
|
77
84
|
})
|
|
85
|
+
|
|
86
|
+
it('does not render an author link when authorSlug is unset', () => {
|
|
87
|
+
render(<ArticleCard article={baseArticle} />)
|
|
88
|
+
expect(screen.queryByRole('link', { name: /Jane Doe/i })).not.toBeInTheDocument()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('renders an author avatar image linked to the author page when authorAvatar is set', () => {
|
|
92
|
+
render(
|
|
93
|
+
<ArticleCard
|
|
94
|
+
article={{
|
|
95
|
+
...baseArticle,
|
|
96
|
+
authorSlug: 'jane-doe',
|
|
97
|
+
authorAvatar: '/articles/authors/jane-doe/avatar.jpg',
|
|
98
|
+
}}
|
|
99
|
+
/>
|
|
100
|
+
)
|
|
101
|
+
const link = screen.getByRole('link', { name: 'Jane Doe' })
|
|
102
|
+
expect(link).toHaveAttribute('href', '/articles/authors/jane-doe')
|
|
103
|
+
// Avatar image is decorative (alt="") since the adjacent name text
|
|
104
|
+
// already conveys it - query the DOM directly rather than by alt text.
|
|
105
|
+
expect(link.querySelector('img')).toHaveAttribute(
|
|
106
|
+
'src',
|
|
107
|
+
'/articles/authors/jane-doe/avatar.jpg'
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('falls back to initials when authorSlug is set but authorAvatar is not', () => {
|
|
112
|
+
render(<ArticleCard article={{ ...baseArticle, authorSlug: 'jane-doe' }} />)
|
|
113
|
+
const link = screen.getByRole('link', { name: 'Jane Doe' })
|
|
114
|
+
expect(link).toHaveAttribute('href', '/articles/authors/jane-doe')
|
|
115
|
+
expect(screen.getByText('JD')).toBeInTheDocument()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('author_clicked event (Phase 27F)', () => {
|
|
119
|
+
it('fires author_clicked when the author link is clicked and config.onEvent is set', () => {
|
|
120
|
+
const onEvent = jest.fn()
|
|
121
|
+
const config: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Test', onEvent }
|
|
122
|
+
render(<ArticleCard article={{ ...baseArticle, authorSlug: 'jane-doe' }} config={config} />)
|
|
123
|
+
fireEvent.click(screen.getByRole('link', { name: 'Jane Doe' }))
|
|
124
|
+
expect(onEvent).toHaveBeenCalledWith(
|
|
125
|
+
expect.objectContaining({
|
|
126
|
+
name: 'author_clicked',
|
|
127
|
+
articleSlug: 'test-article',
|
|
128
|
+
authorSlug: 'jane-doe',
|
|
129
|
+
})
|
|
130
|
+
)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('does not throw when config is omitted and the author link is clicked', () => {
|
|
134
|
+
render(<ArticleCard article={{ ...baseArticle, authorSlug: 'jane-doe' }} />)
|
|
135
|
+
expect(() => fireEvent.click(screen.getByRole('link', { name: 'Jane Doe' }))).not.toThrow()
|
|
136
|
+
})
|
|
137
|
+
})
|
|
78
138
|
})
|
|
@@ -14,6 +14,22 @@ jest.mock('../renderMdx', () => ({
|
|
|
14
14
|
renderMdxSource: (...args: unknown[]) => mockRenderMdxSource(...args),
|
|
15
15
|
}))
|
|
16
16
|
|
|
17
|
+
// `../markdown` pulls in ESM-only unified/rehype packages ts-jest's CJS
|
|
18
|
+
// transform can't load (see markdown.test.ts) - mocked wholesale here since
|
|
19
|
+
// ArticleContent only needs markdownToHtml/getContentSlotBoundaries's
|
|
20
|
+
// *contract*, not real markdown parsing, for these component-level tests.
|
|
21
|
+
// Real paragraph-boundary parsing is unit-tested directly in
|
|
22
|
+
// contentSlots.test.ts against a controlled fake AST.
|
|
23
|
+
const mockMarkdownToHtml = jest.fn(async (markdown: string) => `<p>${markdown.trim()}</p>`)
|
|
24
|
+
const mockGetContentSlotBoundaries = jest.fn()
|
|
25
|
+
|
|
26
|
+
jest.mock('../markdown', () => ({
|
|
27
|
+
markdownToHtml: (...args: unknown[]) =>
|
|
28
|
+
(mockMarkdownToHtml as (...a: unknown[]) => Promise<string>)(...args),
|
|
29
|
+
getContentSlotBoundaries: (...args: unknown[]) =>
|
|
30
|
+
(mockGetContentSlotBoundaries as (...a: unknown[]) => unknown)(...args),
|
|
31
|
+
}))
|
|
32
|
+
|
|
17
33
|
const baseArticle: Article = {
|
|
18
34
|
slug: 'test-slug',
|
|
19
35
|
title: 'Test Title',
|
|
@@ -27,9 +43,14 @@ const baseArticle: Article = {
|
|
|
27
43
|
|
|
28
44
|
describe('ArticleContent', () => {
|
|
29
45
|
beforeEach(() => {
|
|
46
|
+
mockRenderMdxSource.mockClear()
|
|
30
47
|
mockRenderMdxSource.mockResolvedValue(
|
|
31
48
|
React.createElement('div', { 'data-testid': 'mdx-content' }, 'MDX rendered')
|
|
32
49
|
)
|
|
50
|
+
mockMarkdownToHtml.mockClear()
|
|
51
|
+
mockMarkdownToHtml.mockImplementation(async (markdown: string) => `<p>${markdown.trim()}</p>`)
|
|
52
|
+
mockGetContentSlotBoundaries.mockReset()
|
|
53
|
+
mockGetContentSlotBoundaries.mockReturnValue(null)
|
|
33
54
|
})
|
|
34
55
|
|
|
35
56
|
describe('HTML path', () => {
|
|
@@ -148,4 +169,126 @@ describe('ArticleContent', () => {
|
|
|
148
169
|
expect(mockRenderMdxSource).toHaveBeenCalledWith('# Hello MDX', '/articles/test-slug', config)
|
|
149
170
|
})
|
|
150
171
|
})
|
|
172
|
+
|
|
173
|
+
describe('Phase 27F slots', () => {
|
|
174
|
+
it('renders afterHero and afterContent around the body without needing paragraph boundaries', async () => {
|
|
175
|
+
const article: Article = { ...baseArticle, contentType: 'md', htmlContent: '<p>Body</p>' }
|
|
176
|
+
const el = await ArticleContent({
|
|
177
|
+
article,
|
|
178
|
+
afterHero: <div data-testid="after-hero">Hero slot</div>,
|
|
179
|
+
afterContent: <div data-testid="after-content">Content slot</div>,
|
|
180
|
+
})
|
|
181
|
+
render(el as React.ReactElement)
|
|
182
|
+
expect(screen.getByTestId('after-hero')).toBeInTheDocument()
|
|
183
|
+
expect(screen.getByTestId('after-content')).toBeInTheDocument()
|
|
184
|
+
expect(mockGetContentSlotBoundaries).not.toHaveBeenCalled()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('resolves function slots with sanitized article context', async () => {
|
|
188
|
+
const article: Article = {
|
|
189
|
+
...baseArticle,
|
|
190
|
+
contentType: 'md',
|
|
191
|
+
htmlContent: '<p>Body</p>',
|
|
192
|
+
authorSlug: 'jane-doe',
|
|
193
|
+
seriesSlug: 'new-gm',
|
|
194
|
+
primaryAction: { actionId: 'download-kit' },
|
|
195
|
+
}
|
|
196
|
+
let receivedContext: unknown
|
|
197
|
+
const el = await ArticleContent({
|
|
198
|
+
article,
|
|
199
|
+
afterHero: (ctx) => {
|
|
200
|
+
receivedContext = ctx
|
|
201
|
+
return <div data-testid="ctx">{ctx.slug}</div>
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
render(el as React.ReactElement)
|
|
205
|
+
expect(receivedContext).toEqual({
|
|
206
|
+
slug: 'test-slug',
|
|
207
|
+
title: 'Test Title',
|
|
208
|
+
category: 'test',
|
|
209
|
+
tags: [],
|
|
210
|
+
readTime: '3 min',
|
|
211
|
+
wordCount: undefined,
|
|
212
|
+
authorSlug: 'jane-doe',
|
|
213
|
+
seriesSlug: 'new-gm',
|
|
214
|
+
primaryActionId: 'download-kit',
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('splits at AST-derived paragraph boundaries when afterIntro/midContent are provided', async () => {
|
|
219
|
+
mockGetContentSlotBoundaries.mockReturnValue({ introEnd: 10, mid: 25, paragraphCount: 3 })
|
|
220
|
+
const article: Article = {
|
|
221
|
+
...baseArticle,
|
|
222
|
+
contentType: 'md',
|
|
223
|
+
content: '0123456789 more content up to twenty-five then more.',
|
|
224
|
+
htmlContent: '<p>Whole body (unused when splitting)</p>',
|
|
225
|
+
}
|
|
226
|
+
const el = await ArticleContent({
|
|
227
|
+
article,
|
|
228
|
+
afterIntro: <div data-testid="after-intro">Intro slot</div>,
|
|
229
|
+
midContent: <div data-testid="mid-content">Mid slot</div>,
|
|
230
|
+
})
|
|
231
|
+
const { container } = render(el as React.ReactElement)
|
|
232
|
+
expect(mockGetContentSlotBoundaries).toHaveBeenCalledWith(article.content)
|
|
233
|
+
expect(mockMarkdownToHtml).toHaveBeenCalledTimes(3)
|
|
234
|
+
expect(screen.getByTestId('after-intro')).toBeInTheDocument()
|
|
235
|
+
expect(screen.getByTestId('mid-content')).toBeInTheDocument()
|
|
236
|
+
// afterIntro slot appears before midContent slot in document order
|
|
237
|
+
const introIndex = container.innerHTML.indexOf('after-intro')
|
|
238
|
+
const midIndex = container.innerHTML.indexOf('mid-content')
|
|
239
|
+
expect(introIndex).toBeGreaterThan(-1)
|
|
240
|
+
expect(midIndex).toBeGreaterThan(introIndex)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('falls back to whole-body rendering (afterIntro/midContent omitted) when boundaries are null', async () => {
|
|
244
|
+
mockGetContentSlotBoundaries.mockReturnValue(null)
|
|
245
|
+
const article: Article = {
|
|
246
|
+
...baseArticle,
|
|
247
|
+
contentType: 'md',
|
|
248
|
+
content: 'Some raw markdown with no detectable paragraphs.',
|
|
249
|
+
htmlContent: '<p>Whole body</p>',
|
|
250
|
+
}
|
|
251
|
+
const el = await ArticleContent({
|
|
252
|
+
article,
|
|
253
|
+
afterIntro: <div data-testid="after-intro">Intro slot</div>,
|
|
254
|
+
afterHero: <div data-testid="after-hero">Hero slot</div>,
|
|
255
|
+
})
|
|
256
|
+
const { container } = render(el as React.ReactElement)
|
|
257
|
+
expect(screen.getByTestId('after-hero')).toBeInTheDocument()
|
|
258
|
+
expect(screen.queryByTestId('after-intro')).not.toBeInTheDocument()
|
|
259
|
+
expect(container.innerHTML).toContain('Whole body')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('falls back to whole-body rendering when the article has no raw content to split', async () => {
|
|
263
|
+
const article: Article = {
|
|
264
|
+
...baseArticle,
|
|
265
|
+
contentType: 'md',
|
|
266
|
+
htmlContent: '<p>Whole body</p>',
|
|
267
|
+
// no `content` field set
|
|
268
|
+
}
|
|
269
|
+
const el = await ArticleContent({
|
|
270
|
+
article,
|
|
271
|
+
midContent: <div data-testid="mid-content">Mid slot</div>,
|
|
272
|
+
})
|
|
273
|
+
render(el as React.ReactElement)
|
|
274
|
+
expect(mockGetContentSlotBoundaries).not.toHaveBeenCalled()
|
|
275
|
+
expect(screen.queryByTestId('mid-content')).not.toBeInTheDocument()
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('supports slot splitting for MDX articles via renderMdxSource per segment', async () => {
|
|
279
|
+
mockGetContentSlotBoundaries.mockReturnValue({ introEnd: 5, mid: 12, paragraphCount: 2 })
|
|
280
|
+
const article: Article = {
|
|
281
|
+
...baseArticle,
|
|
282
|
+
contentType: 'mdx',
|
|
283
|
+
mdxSource: 'Hello world, this is MDX content.',
|
|
284
|
+
}
|
|
285
|
+
const el = await ArticleContent({
|
|
286
|
+
article,
|
|
287
|
+
afterIntro: <div data-testid="after-intro">Intro slot</div>,
|
|
288
|
+
})
|
|
289
|
+
render(el as React.ReactElement)
|
|
290
|
+
expect(mockRenderMdxSource).toHaveBeenCalledTimes(3)
|
|
291
|
+
expect(screen.getByTestId('after-intro')).toBeInTheDocument()
|
|
292
|
+
})
|
|
293
|
+
})
|
|
151
294
|
})
|
|
@@ -163,4 +163,34 @@ describe('ArticleDetailHero', () => {
|
|
|
163
163
|
expect(screen.queryByText('By Jane Doe')).not.toBeInTheDocument()
|
|
164
164
|
})
|
|
165
165
|
})
|
|
166
|
+
|
|
167
|
+
describe('author authority statement (Phase 27E)', () => {
|
|
168
|
+
it('does not render a statement for a legacy configured author with no promise', () => {
|
|
169
|
+
render(<ArticleDetailHero article={baseArticle} authors={[configuredAuthor]} />)
|
|
170
|
+
expect(screen.queryByText(/Helping/)).not.toBeInTheDocument()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('renders the single configured author promise as a one-sentence statement', () => {
|
|
174
|
+
const authorWithPromise: AuthorProfile = {
|
|
175
|
+
...configuredAuthor,
|
|
176
|
+
promise: 'Helping new GMs run confident first sessions.',
|
|
177
|
+
}
|
|
178
|
+
render(<ArticleDetailHero article={baseArticle} authors={[authorWithPromise]} />)
|
|
179
|
+
expect(screen.getByText('Helping new GMs run confident first sessions.')).toBeInTheDocument()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('does not render a statement when there are multiple configured authors, even with a promise', () => {
|
|
183
|
+
const authorWithPromise: AuthorProfile = {
|
|
184
|
+
...configuredAuthor,
|
|
185
|
+
promise: 'Helping new GMs run confident first sessions.',
|
|
186
|
+
}
|
|
187
|
+
const secondAuthor: AuthorProfile = { name: 'Jamie Rivera', slug: 'jamie-rivera', bio: '' }
|
|
188
|
+
render(
|
|
189
|
+
<ArticleDetailHero article={baseArticle} authors={[authorWithPromise, secondAuthor]} />
|
|
190
|
+
)
|
|
191
|
+
expect(
|
|
192
|
+
screen.queryByText('Helping new GMs run confident first sessions.')
|
|
193
|
+
).not.toBeInTheDocument()
|
|
194
|
+
})
|
|
195
|
+
})
|
|
166
196
|
})
|
|
@@ -2,13 +2,20 @@
|
|
|
2
2
|
* @jest-environment jsdom
|
|
3
3
|
*/
|
|
4
4
|
import React from 'react'
|
|
5
|
-
import { render, screen } from '@testing-library/react'
|
|
5
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
6
6
|
import { ArticleNavigation } from '../ArticleNavigation'
|
|
7
|
+
import type { ArticlesConfig } from '../articlesConfig'
|
|
7
8
|
|
|
8
9
|
jest.mock('next/link', () => ({
|
|
9
10
|
__esModule: true,
|
|
10
|
-
default: ({
|
|
11
|
-
|
|
11
|
+
default: ({
|
|
12
|
+
href,
|
|
13
|
+
children,
|
|
14
|
+
...rest
|
|
15
|
+
}: { href: string; children: React.ReactNode } & Record<string, unknown>) => (
|
|
16
|
+
<a href={href} {...rest}>
|
|
17
|
+
{children}
|
|
18
|
+
</a>
|
|
12
19
|
),
|
|
13
20
|
}))
|
|
14
21
|
|
|
@@ -124,4 +131,75 @@ describe('ArticleNavigation', () => {
|
|
|
124
131
|
)
|
|
125
132
|
})
|
|
126
133
|
})
|
|
134
|
+
|
|
135
|
+
describe('path_step_advanced event (Phase 27F)', () => {
|
|
136
|
+
it('fires path_step_advanced on next-link click when pathKey/fromSlug/config are set', () => {
|
|
137
|
+
const onEvent = jest.fn()
|
|
138
|
+
const config: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Test', onEvent }
|
|
139
|
+
render(
|
|
140
|
+
<ArticleNavigation
|
|
141
|
+
previous={null}
|
|
142
|
+
next={nextArticle}
|
|
143
|
+
basePath={basePath}
|
|
144
|
+
pathKey="new-gm"
|
|
145
|
+
fromSlug="current-article"
|
|
146
|
+
config={config}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
fireEvent.click(screen.getByRole('link', { name: /next article title/i }))
|
|
150
|
+
expect(onEvent).toHaveBeenCalledWith(
|
|
151
|
+
expect.objectContaining({
|
|
152
|
+
name: 'path_step_advanced',
|
|
153
|
+
pathKey: 'new-gm',
|
|
154
|
+
fromSlug: 'current-article',
|
|
155
|
+
toSlug: 'next-article',
|
|
156
|
+
direction: 'next',
|
|
157
|
+
})
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('fires path_step_advanced on previous-link click with direction "previous"', () => {
|
|
162
|
+
const onEvent = jest.fn()
|
|
163
|
+
const config: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Test', onEvent }
|
|
164
|
+
render(
|
|
165
|
+
<ArticleNavigation
|
|
166
|
+
previous={prevArticle}
|
|
167
|
+
next={null}
|
|
168
|
+
basePath={basePath}
|
|
169
|
+
pathKey="new-gm"
|
|
170
|
+
fromSlug="current-article"
|
|
171
|
+
config={config}
|
|
172
|
+
/>
|
|
173
|
+
)
|
|
174
|
+
fireEvent.click(screen.getByRole('link', { name: /previous article title/i }))
|
|
175
|
+
expect(onEvent).toHaveBeenCalledWith(
|
|
176
|
+
expect.objectContaining({ direction: 'previous', toSlug: 'previous-article' })
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('does not fire an event when pathKey is omitted (ordinary navigation)', () => {
|
|
181
|
+
const onEvent = jest.fn()
|
|
182
|
+
const config: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Test', onEvent }
|
|
183
|
+
render(
|
|
184
|
+
<ArticleNavigation previous={null} next={nextArticle} basePath={basePath} config={config} />
|
|
185
|
+
)
|
|
186
|
+
fireEvent.click(screen.getByRole('link', { name: /next article title/i }))
|
|
187
|
+
expect(onEvent).not.toHaveBeenCalled()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('does not throw when config is omitted', () => {
|
|
191
|
+
render(
|
|
192
|
+
<ArticleNavigation
|
|
193
|
+
previous={null}
|
|
194
|
+
next={nextArticle}
|
|
195
|
+
basePath={basePath}
|
|
196
|
+
pathKey="new-gm"
|
|
197
|
+
fromSlug="current-article"
|
|
198
|
+
/>
|
|
199
|
+
)
|
|
200
|
+
expect(() =>
|
|
201
|
+
fireEvent.click(screen.getByRole('link', { name: /next article title/i }))
|
|
202
|
+
).not.toThrow()
|
|
203
|
+
})
|
|
204
|
+
})
|
|
127
205
|
})
|