@fullstackdatasolutions/articles 0.11.0 → 1.0.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 (71) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +568 -6
  3. package/dist/index.cjs +970 -389
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +375 -21
  6. package/dist/index.d.ts +375 -21
  7. package/dist/index.js +954 -377
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +74 -6
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +141 -0
  12. package/dist/nextjs.d.ts +141 -0
  13. package/dist/nextjs.js +74 -6
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +665 -27
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +349 -3
  18. package/dist/server.d.ts +349 -3
  19. package/dist/server.js +643 -29
  20. package/dist/server.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/ArticleCard.tsx +37 -1
  23. package/src/ArticleContent.tsx +144 -5
  24. package/src/ArticleDetailHero.tsx +23 -0
  25. package/src/ArticleNavigation.tsx +32 -1
  26. package/src/ArticleSchemas.tsx +43 -39
  27. package/src/ArticleSocialShare.tsx +54 -10
  28. package/src/ArticlesPage.tsx +65 -7
  29. package/src/AuthorArticlesPage.tsx +308 -14
  30. package/src/AuthorCard.tsx +1 -1
  31. package/src/CategoryArticlesPage.tsx +34 -2
  32. package/src/LatestArticles.tsx +28 -1
  33. package/src/LatestArticlesSection.tsx +15 -1
  34. package/src/PaginationNav.tsx +78 -0
  35. package/src/RelatedArticlesSection.tsx +55 -0
  36. package/src/SeriesArticlesPage.tsx +66 -0
  37. package/src/__tests__/ArticleCard.test.tsx +63 -3
  38. package/src/__tests__/ArticleContent.test.tsx +143 -0
  39. package/src/__tests__/ArticleDetailHero.test.tsx +30 -0
  40. package/src/__tests__/ArticleNavigation.test.tsx +81 -3
  41. package/src/__tests__/ArticleSchemas.test.tsx +155 -81
  42. package/src/__tests__/ArticleSocialShare.test.tsx +54 -0
  43. package/src/__tests__/ArticlesPage.test.tsx +148 -0
  44. package/src/__tests__/AuthorArticlesPage.test.tsx +304 -3
  45. package/src/__tests__/CategoryArticlesPage.test.tsx +116 -1
  46. package/src/__tests__/LatestArticles.test.tsx +52 -0
  47. package/src/__tests__/LatestArticlesSection.test.tsx +28 -0
  48. package/src/__tests__/PaginationNav.test.tsx +73 -0
  49. package/src/__tests__/RelatedArticlesSection.test.tsx +132 -0
  50. package/src/__tests__/SeriesArticlesPage.test.tsx +121 -0
  51. package/src/__tests__/eventTracking.test.tsx +145 -0
  52. package/src/__tests__/events.test.ts +82 -0
  53. package/src/__tests__/markdown.test.ts +78 -1
  54. package/src/__tests__/pagination.test.ts +178 -0
  55. package/src/__tests__/seoUtils-authors.test.ts +37 -0
  56. package/src/__tests__/seoUtils.test.ts +246 -0
  57. package/src/__tests__/server-articles.test.ts +356 -1
  58. package/src/__tests__/useArticles.test.ts +28 -0
  59. package/src/__tests__/validateArticles.test.ts +312 -0
  60. package/src/articleTypes.ts +109 -0
  61. package/src/articlesConfig.ts +37 -1
  62. package/src/eventTracking.tsx +97 -0
  63. package/src/events.ts +105 -0
  64. package/src/index.ts +26 -1
  65. package/src/markdown.ts +41 -0
  66. package/src/pagination.ts +93 -0
  67. package/src/seoUtils.ts +198 -11
  68. package/src/server-articles.ts +199 -6
  69. package/src/server.ts +46 -2
  70. package/src/useArticles.ts +10 -5
  71. 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,55 @@
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 className="text-xl font-semibold text-foreground mb-6">
33
+ {heading ?? `More in ${category}`}
34
+ </h2>
35
+ <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
36
+ {articles.map((article) => (
37
+ <div
38
+ key={article.slug}
39
+ onClickCapture={() => {
40
+ if (!fromSlug) return
41
+ emitArticleEvent(config?.onEvent, {
42
+ name: 'related_article_clicked',
43
+ fromSlug,
44
+ toSlug: article.slug,
45
+ source,
46
+ })
47
+ }}
48
+ >
49
+ <ArticleCard article={article} config={config} />
50
+ </div>
51
+ ))}
52
+ </div>
53
+ </section>
54
+ )
55
+ }
@@ -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: ({ href, children }: { href: string; children: React.ReactNode }) => (
12
- <a href={href}>{children}</a>
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: ({ href, children }: { href: string; children: React.ReactNode }) => (
11
- <a href={href}>{children}</a>
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
  })