@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.
- package/CHANGELOG.md +30 -0
- package/README.md +568 -6
- package/dist/index.cjs +970 -389
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +375 -21
- package/dist/index.d.ts +375 -21
- package/dist/index.js +954 -377
- 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 +141 -0
- package/dist/nextjs.d.ts +141 -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 +349 -3
- package/dist/server.d.ts +349 -3
- package/dist/server.js +643 -29
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleCard.tsx +37 -1
- package/src/ArticleContent.tsx +144 -5
- package/src/ArticleDetailHero.tsx +23 -0
- package/src/ArticleNavigation.tsx +32 -1
- package/src/ArticleSchemas.tsx +43 -39
- package/src/ArticleSocialShare.tsx +54 -10
- package/src/ArticlesPage.tsx +65 -7
- 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 +55 -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 +148 -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__/useArticles.test.ts +28 -0
- package/src/__tests__/validateArticles.test.ts +312 -0
- package/src/articleTypes.ts +109 -0
- package/src/articlesConfig.ts +37 -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/useArticles.ts +10 -5
- package/src/validateArticles.ts +260 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
6
|
+
import type { Article } from '../articleTypes'
|
|
7
|
+
import { RelatedArticlesSection } from '../RelatedArticlesSection'
|
|
8
|
+
import type { ArticlesConfig } from '../articlesConfig'
|
|
9
|
+
|
|
10
|
+
jest.mock('next/link', () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
|
13
|
+
<a href={href}>{children}</a>
|
|
14
|
+
),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
jest.mock('next/image', () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
default: (props: React.ComponentProps<'img'>) => <img alt="test" {...props} />,
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
const articles: Article[] = [
|
|
23
|
+
{
|
|
24
|
+
slug: 'one',
|
|
25
|
+
title: 'Article One',
|
|
26
|
+
excerpt: 'Excerpt one.',
|
|
27
|
+
author: 'Jane Doe',
|
|
28
|
+
category: 'Civic Tech',
|
|
29
|
+
categories: ['Civic Tech'],
|
|
30
|
+
readTime: '3 min read',
|
|
31
|
+
featuredImage: '/one.jpg',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
slug: 'two',
|
|
35
|
+
title: 'Article Two',
|
|
36
|
+
excerpt: 'Excerpt two.',
|
|
37
|
+
author: 'Jane Doe',
|
|
38
|
+
category: 'Civic Tech',
|
|
39
|
+
categories: ['Civic Tech'],
|
|
40
|
+
readTime: '4 min read',
|
|
41
|
+
featuredImage: '/two.jpg',
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
describe('RelatedArticlesSection', () => {
|
|
46
|
+
it('renders nothing when there are no related articles', () => {
|
|
47
|
+
const { container } = render(<RelatedArticlesSection articles={[]} category="Civic Tech" />)
|
|
48
|
+
expect(container).toBeEmptyDOMElement()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('renders a heading naming the category', () => {
|
|
52
|
+
render(<RelatedArticlesSection articles={articles} category="Civic Tech" />)
|
|
53
|
+
expect(screen.getByRole('heading', { name: 'More in Civic Tech' })).toBeInTheDocument()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('renders a real link for every related article', () => {
|
|
57
|
+
render(<RelatedArticlesSection articles={articles} category="Civic Tech" />)
|
|
58
|
+
expect(screen.getByRole('link', { name: 'Article One' })).toHaveAttribute(
|
|
59
|
+
'href',
|
|
60
|
+
'/articles/one'
|
|
61
|
+
)
|
|
62
|
+
expect(screen.getByRole('link', { name: 'Article Two' })).toHaveAttribute(
|
|
63
|
+
'href',
|
|
64
|
+
'/articles/two'
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('heading override (Phase 27F)', () => {
|
|
69
|
+
it('uses a custom heading when provided, instead of "More in {category}"', () => {
|
|
70
|
+
render(
|
|
71
|
+
<RelatedArticlesSection articles={articles} category="Civic Tech" heading="New GM Path" />
|
|
72
|
+
)
|
|
73
|
+
expect(screen.getByRole('heading', { name: 'New GM Path' })).toBeInTheDocument()
|
|
74
|
+
expect(screen.queryByText('More in Civic Tech')).not.toBeInTheDocument()
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('related_article_clicked event (Phase 27F)', () => {
|
|
79
|
+
it('fires related_article_clicked with fromSlug/toSlug/source on card click', () => {
|
|
80
|
+
const onEvent = jest.fn()
|
|
81
|
+
const config: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Test', onEvent }
|
|
82
|
+
render(
|
|
83
|
+
<RelatedArticlesSection
|
|
84
|
+
articles={articles}
|
|
85
|
+
category="Civic Tech"
|
|
86
|
+
config={config}
|
|
87
|
+
fromSlug="current-article"
|
|
88
|
+
source="series"
|
|
89
|
+
/>
|
|
90
|
+
)
|
|
91
|
+
fireEvent.click(screen.getByRole('link', { name: 'Article One' }))
|
|
92
|
+
expect(onEvent).toHaveBeenCalledWith(
|
|
93
|
+
expect.objectContaining({
|
|
94
|
+
name: 'related_article_clicked',
|
|
95
|
+
fromSlug: 'current-article',
|
|
96
|
+
toSlug: 'one',
|
|
97
|
+
source: 'series',
|
|
98
|
+
})
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('defaults source to "category" when omitted', () => {
|
|
103
|
+
const onEvent = jest.fn()
|
|
104
|
+
const config: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Test', onEvent }
|
|
105
|
+
render(
|
|
106
|
+
<RelatedArticlesSection
|
|
107
|
+
articles={articles}
|
|
108
|
+
category="Civic Tech"
|
|
109
|
+
config={config}
|
|
110
|
+
fromSlug="current-article"
|
|
111
|
+
/>
|
|
112
|
+
)
|
|
113
|
+
fireEvent.click(screen.getByRole('link', { name: 'Article Two' }))
|
|
114
|
+
expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ source: 'category' }))
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('does not fire an event when fromSlug is omitted', () => {
|
|
118
|
+
const onEvent = jest.fn()
|
|
119
|
+
const config: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Test', onEvent }
|
|
120
|
+
render(<RelatedArticlesSection articles={articles} category="Civic Tech" config={config} />)
|
|
121
|
+
fireEvent.click(screen.getByRole('link', { name: 'Article One' }))
|
|
122
|
+
expect(onEvent).not.toHaveBeenCalled()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('does not throw when config is omitted', () => {
|
|
126
|
+
render(
|
|
127
|
+
<RelatedArticlesSection articles={articles} category="Civic Tech" fromSlug="current" />
|
|
128
|
+
)
|
|
129
|
+
expect(() => fireEvent.click(screen.getByRole('link', { name: 'Article One' }))).not.toThrow()
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render, screen } from '@testing-library/react'
|
|
6
|
+
import type { Article } from '../articleTypes'
|
|
7
|
+
import { SeriesArticlesPage } from '../SeriesArticlesPage'
|
|
8
|
+
import type { ArticlesConfig } from '../articlesConfig'
|
|
9
|
+
|
|
10
|
+
jest.mock('next/link', () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
|
13
|
+
<a href={href}>{children}</a>
|
|
14
|
+
),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
jest.mock('../LatestArticles', () => ({
|
|
18
|
+
LatestArticles: ({ articles }: { articles: Article[] }) => (
|
|
19
|
+
<div data-testid="latest-articles">
|
|
20
|
+
{articles.map((a) => (
|
|
21
|
+
<div key={a.slug} data-testid="article-card">
|
|
22
|
+
{a.title}
|
|
23
|
+
</div>
|
|
24
|
+
))}
|
|
25
|
+
</div>
|
|
26
|
+
),
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
jest.mock('../ArticleSchemas', () => ({
|
|
30
|
+
CollectionPageSchema: ({ title }: { title: string }) => (
|
|
31
|
+
<script data-testid="collection-schema" data-title={title} />
|
|
32
|
+
),
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
const baseConfig: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Test Site' }
|
|
36
|
+
|
|
37
|
+
const makeArticle = (overrides: Partial<Article> = {}): Article => ({
|
|
38
|
+
slug: 'step-1',
|
|
39
|
+
title: 'Step One',
|
|
40
|
+
excerpt: 'An excerpt.',
|
|
41
|
+
author: 'Jane Doe',
|
|
42
|
+
category: 'Campaigns',
|
|
43
|
+
categories: ['Campaigns'],
|
|
44
|
+
readTime: '3 min read',
|
|
45
|
+
featuredImage: '/img.jpg',
|
|
46
|
+
series: 'New GM Path',
|
|
47
|
+
seriesSlug: 'new-gm',
|
|
48
|
+
seriesOrder: 1,
|
|
49
|
+
...overrides,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('SeriesArticlesPage', () => {
|
|
53
|
+
it('renders nothing when articles is empty', () => {
|
|
54
|
+
const { container } = render(
|
|
55
|
+
<SeriesArticlesPage seriesSlug="new-gm" articles={[]} config={baseConfig} />
|
|
56
|
+
)
|
|
57
|
+
expect(container).toBeEmptyDOMElement()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('renders the series name from the first article label', () => {
|
|
61
|
+
render(
|
|
62
|
+
<SeriesArticlesPage seriesSlug="new-gm" articles={[makeArticle()]} config={baseConfig} />
|
|
63
|
+
)
|
|
64
|
+
expect(screen.getByRole('heading', { name: 'New GM Path' })).toBeInTheDocument()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('falls back to the seriesSlug when no article has a series label', () => {
|
|
68
|
+
render(
|
|
69
|
+
<SeriesArticlesPage
|
|
70
|
+
seriesSlug="new-gm"
|
|
71
|
+
articles={[makeArticle({ series: undefined })]}
|
|
72
|
+
config={baseConfig}
|
|
73
|
+
/>
|
|
74
|
+
)
|
|
75
|
+
expect(screen.getByRole('heading', { name: 'new-gm' })).toBeInTheDocument()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('renders every article via LatestArticles', () => {
|
|
79
|
+
render(
|
|
80
|
+
<SeriesArticlesPage
|
|
81
|
+
seriesSlug="new-gm"
|
|
82
|
+
articles={[
|
|
83
|
+
makeArticle(),
|
|
84
|
+
makeArticle({ slug: 'step-2', title: 'Step Two', seriesOrder: 2 }),
|
|
85
|
+
]}
|
|
86
|
+
config={baseConfig}
|
|
87
|
+
/>
|
|
88
|
+
)
|
|
89
|
+
expect(screen.getAllByTestId('article-card')).toHaveLength(2)
|
|
90
|
+
expect(screen.getByText('Step One')).toBeInTheDocument()
|
|
91
|
+
expect(screen.getByText('Step Two')).toBeInTheDocument()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('renders a CollectionPageSchema', () => {
|
|
95
|
+
render(
|
|
96
|
+
<SeriesArticlesPage seriesSlug="new-gm" articles={[makeArticle()]} config={baseConfig} />
|
|
97
|
+
)
|
|
98
|
+
expect(screen.getByTestId('collection-schema')).toHaveAttribute(
|
|
99
|
+
'data-title',
|
|
100
|
+
'New GM Path | Test Site'
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('shows the article count', () => {
|
|
105
|
+
render(
|
|
106
|
+
<SeriesArticlesPage
|
|
107
|
+
seriesSlug="new-gm"
|
|
108
|
+
articles={[makeArticle(), makeArticle({ slug: 'step-2' })]}
|
|
109
|
+
config={baseConfig}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
expect(screen.getByText('2 articles in this series')).toBeInTheDocument()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('uses singular "article" for a single-article series', () => {
|
|
116
|
+
render(
|
|
117
|
+
<SeriesArticlesPage seriesSlug="new-gm" articles={[makeArticle()]} config={baseConfig} />
|
|
118
|
+
)
|
|
119
|
+
expect(screen.getByText('1 article in this series')).toBeInTheDocument()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render, screen } from '@testing-library/react'
|
|
6
|
+
import { ArticleViewTracker, CtaViewTracker } from '../eventTracking'
|
|
7
|
+
import type { ArticlesConfig } from '../articlesConfig'
|
|
8
|
+
|
|
9
|
+
describe('ArticleViewTracker', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
jest.useFakeTimers()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
jest.useRealTimers()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('renders nothing', () => {
|
|
19
|
+
const { container } = render(
|
|
20
|
+
<ArticleViewTracker article={{ slug: 'a' }} config={{ siteUrl: 'x', siteName: 'y' }} />
|
|
21
|
+
)
|
|
22
|
+
expect(container).toBeEmptyDOMElement()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('fires article_viewed on mount with category/seriesSlug', () => {
|
|
26
|
+
const onEvent = jest.fn()
|
|
27
|
+
const config: ArticlesConfig = { siteUrl: 'x', siteName: 'y', onEvent }
|
|
28
|
+
render(
|
|
29
|
+
<ArticleViewTracker
|
|
30
|
+
article={{ slug: 'a', category: 'Campaigns', seriesSlug: 'new-gm' }}
|
|
31
|
+
config={config}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
expect(onEvent).toHaveBeenCalledWith(
|
|
35
|
+
expect.objectContaining({
|
|
36
|
+
name: 'article_viewed',
|
|
37
|
+
articleSlug: 'a',
|
|
38
|
+
category: 'Campaigns',
|
|
39
|
+
seriesSlug: 'new-gm',
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('does not throw when config/onEvent is omitted', () => {
|
|
45
|
+
expect(() => render(<ArticleViewTracker article={{ slug: 'a' }} />)).not.toThrow()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('fires meaningful_read once after the estimated read-time timer elapses', () => {
|
|
49
|
+
const onEvent = jest.fn()
|
|
50
|
+
const config: ArticlesConfig = { siteUrl: 'x', siteName: 'y', onEvent }
|
|
51
|
+
render(<ArticleViewTracker article={{ slug: 'a', wordCount: 400 }} config={config} />)
|
|
52
|
+
|
|
53
|
+
onEvent.mockClear()
|
|
54
|
+
// 400 words / 200wpm * 60000ms * 0.5 = 60000ms
|
|
55
|
+
jest.advanceTimersByTime(60_000)
|
|
56
|
+
expect(onEvent).toHaveBeenCalledWith(
|
|
57
|
+
expect.objectContaining({ name: 'meaningful_read', articleSlug: 'a' })
|
|
58
|
+
)
|
|
59
|
+
expect(onEvent).toHaveBeenCalledTimes(1)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('uses a default timer when wordCount is unset', () => {
|
|
63
|
+
const onEvent = jest.fn()
|
|
64
|
+
const config: ArticlesConfig = { siteUrl: 'x', siteName: 'y', onEvent }
|
|
65
|
+
render(<ArticleViewTracker article={{ slug: 'a' }} config={config} />)
|
|
66
|
+
onEvent.mockClear()
|
|
67
|
+
jest.advanceTimersByTime(15_000)
|
|
68
|
+
expect(onEvent).toHaveBeenCalledWith(
|
|
69
|
+
expect.objectContaining({ name: 'meaningful_read', articleSlug: 'a' })
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('CtaViewTracker', () => {
|
|
75
|
+
const originalIO = globalThis.IntersectionObserver
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
globalThis.IntersectionObserver = originalIO
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('renders children', () => {
|
|
82
|
+
render(
|
|
83
|
+
<CtaViewTracker ctaId="cta-1">
|
|
84
|
+
<button type="button">Click me</button>
|
|
85
|
+
</CtaViewTracker>
|
|
86
|
+
)
|
|
87
|
+
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('fires cta_viewed immediately when IntersectionObserver is unavailable', () => {
|
|
91
|
+
// @ts-expect-error - simulate an environment without IntersectionObserver
|
|
92
|
+
delete globalThis.IntersectionObserver
|
|
93
|
+
const onEvent = jest.fn()
|
|
94
|
+
render(
|
|
95
|
+
<CtaViewTracker
|
|
96
|
+
ctaId="cta-1"
|
|
97
|
+
articleSlug="a"
|
|
98
|
+
config={{ siteUrl: 'x', siteName: 'y', onEvent }}
|
|
99
|
+
>
|
|
100
|
+
<div>CTA</div>
|
|
101
|
+
</CtaViewTracker>
|
|
102
|
+
)
|
|
103
|
+
expect(onEvent).toHaveBeenCalledWith(
|
|
104
|
+
expect.objectContaining({ name: 'cta_viewed', ctaId: 'cta-1', articleSlug: 'a' })
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('fires cta_viewed once intersection is observed', () => {
|
|
109
|
+
let observedCallback: IntersectionObserverCallback | undefined
|
|
110
|
+
class FakeIntersectionObserver {
|
|
111
|
+
constructor(cb: IntersectionObserverCallback) {
|
|
112
|
+
observedCallback = cb
|
|
113
|
+
}
|
|
114
|
+
observe() {}
|
|
115
|
+
disconnect() {}
|
|
116
|
+
}
|
|
117
|
+
// @ts-expect-error - test double
|
|
118
|
+
globalThis.IntersectionObserver = FakeIntersectionObserver
|
|
119
|
+
|
|
120
|
+
const onEvent = jest.fn()
|
|
121
|
+
render(
|
|
122
|
+
<CtaViewTracker ctaId="cta-1" config={{ siteUrl: 'x', siteName: 'y', onEvent }}>
|
|
123
|
+
<div>CTA</div>
|
|
124
|
+
</CtaViewTracker>
|
|
125
|
+
)
|
|
126
|
+
expect(onEvent).not.toHaveBeenCalled()
|
|
127
|
+
observedCallback?.(
|
|
128
|
+
[{ isIntersecting: true } as IntersectionObserverEntry],
|
|
129
|
+
{} as IntersectionObserver
|
|
130
|
+
)
|
|
131
|
+
expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ name: 'cta_viewed' }))
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('does not throw when config is omitted', () => {
|
|
135
|
+
// @ts-expect-error - simulate an environment without IntersectionObserver
|
|
136
|
+
delete globalThis.IntersectionObserver
|
|
137
|
+
expect(() =>
|
|
138
|
+
render(
|
|
139
|
+
<CtaViewTracker ctaId="cta-1">
|
|
140
|
+
<div>CTA</div>
|
|
141
|
+
</CtaViewTracker>
|
|
142
|
+
)
|
|
143
|
+
).not.toThrow()
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { emitArticleEvent } from '../events'
|
|
2
|
+
import type { ArticleEvent } from '../events'
|
|
3
|
+
|
|
4
|
+
describe('emitArticleEvent', () => {
|
|
5
|
+
it('does nothing when handler is undefined', () => {
|
|
6
|
+
expect(() =>
|
|
7
|
+
emitArticleEvent(undefined, { name: 'article_viewed', articleSlug: 'test' })
|
|
8
|
+
).not.toThrow()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('calls the handler with a stamped timestamp', () => {
|
|
12
|
+
const handler = jest.fn()
|
|
13
|
+
const before = Date.now()
|
|
14
|
+
emitArticleEvent(handler, { name: 'article_viewed', articleSlug: 'test' })
|
|
15
|
+
const after = Date.now()
|
|
16
|
+
|
|
17
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
18
|
+
const event = handler.mock.calls[0][0] as ArticleEvent
|
|
19
|
+
expect(event.name).toBe('article_viewed')
|
|
20
|
+
expect(event.timestamp).toBeGreaterThanOrEqual(before)
|
|
21
|
+
expect(event.timestamp).toBeLessThanOrEqual(after)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('preserves event-specific fields for each event shape', () => {
|
|
25
|
+
const handler = jest.fn()
|
|
26
|
+
emitArticleEvent(handler, {
|
|
27
|
+
name: 'author_clicked',
|
|
28
|
+
articleSlug: 'test',
|
|
29
|
+
authorSlug: 'jane-doe',
|
|
30
|
+
})
|
|
31
|
+
expect(handler).toHaveBeenCalledWith(
|
|
32
|
+
expect.objectContaining({
|
|
33
|
+
name: 'author_clicked',
|
|
34
|
+
articleSlug: 'test',
|
|
35
|
+
authorSlug: 'jane-doe',
|
|
36
|
+
})
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('swallows errors thrown by the consuming app handler', () => {
|
|
41
|
+
const handler = jest.fn(() => {
|
|
42
|
+
throw new Error('broken analytics integration')
|
|
43
|
+
})
|
|
44
|
+
expect(() =>
|
|
45
|
+
emitArticleEvent(handler, { name: 'shared', articleSlug: 'test', channel: 'linkedin' })
|
|
46
|
+
).not.toThrow()
|
|
47
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('never includes PII-shaped fields (only slugs/ids/enums) in any event payload', () => {
|
|
51
|
+
const handler = jest.fn()
|
|
52
|
+
emitArticleEvent(handler, { name: 'article_viewed', articleSlug: 'a' })
|
|
53
|
+
emitArticleEvent(handler, { name: 'meaningful_read', articleSlug: 'a' })
|
|
54
|
+
emitArticleEvent(handler, { name: 'author_clicked', articleSlug: 'a', authorSlug: 'jane-doe' })
|
|
55
|
+
emitArticleEvent(handler, { name: 'cta_viewed', ctaId: 'kit' })
|
|
56
|
+
emitArticleEvent(handler, { name: 'cta_clicked', ctaId: 'kit' })
|
|
57
|
+
emitArticleEvent(handler, { name: 'shared', articleSlug: 'a', channel: 'copy-link' })
|
|
58
|
+
emitArticleEvent(handler, {
|
|
59
|
+
name: 'related_article_clicked',
|
|
60
|
+
fromSlug: 'a',
|
|
61
|
+
toSlug: 'b',
|
|
62
|
+
source: 'category',
|
|
63
|
+
})
|
|
64
|
+
emitArticleEvent(handler, {
|
|
65
|
+
name: 'path_step_advanced',
|
|
66
|
+
pathKey: 'p',
|
|
67
|
+
fromSlug: 'a',
|
|
68
|
+
toSlug: 'b',
|
|
69
|
+
direction: 'next',
|
|
70
|
+
})
|
|
71
|
+
const disallowedKeyFragments = ['email', 'name', 'phone', 'address']
|
|
72
|
+
for (const call of handler.mock.calls) {
|
|
73
|
+
const payload = call[0] as Record<string, unknown>
|
|
74
|
+
for (const key of Object.keys(payload)) {
|
|
75
|
+
if (key === 'name') continue // the event-type discriminant, e.g. 'shared' - not PII
|
|
76
|
+
for (const fragment of disallowedKeyFragments) {
|
|
77
|
+
expect(key.toLowerCase()).not.toContain(fragment)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -10,6 +10,11 @@ jest.mock('remark-parse', () => () => () => {})
|
|
|
10
10
|
jest.mock('remark-rehype', () => () => () => {})
|
|
11
11
|
|
|
12
12
|
const mockRemarkUseCalls: unknown[][] = []
|
|
13
|
+
// Settable by individual `getContentSlotBoundaries` tests to control what
|
|
14
|
+
// `.parse()` returns (real remark parsing isn't reachable under this
|
|
15
|
+
// ESM-mocked setup - see the file-top comment) - `undefined` makes `.parse`
|
|
16
|
+
// throw, exercising the "malformed source" fallback path.
|
|
17
|
+
let mockParseResult: unknown
|
|
13
18
|
|
|
14
19
|
// Mock remark() to return a chainable processor whose .process() resolves to ''
|
|
15
20
|
jest.mock('remark', () => {
|
|
@@ -20,6 +25,10 @@ jest.mock('remark', () => {
|
|
|
20
25
|
return chain
|
|
21
26
|
},
|
|
22
27
|
process: async () => ({ toString: () => '' }),
|
|
28
|
+
parse: () => {
|
|
29
|
+
if (mockParseResult === undefined) throw new Error('parse failed')
|
|
30
|
+
return mockParseResult
|
|
31
|
+
},
|
|
23
32
|
}
|
|
24
33
|
return chain
|
|
25
34
|
}
|
|
@@ -55,7 +64,7 @@ jest.mock('unist-util-visit', () => ({
|
|
|
55
64
|
},
|
|
56
65
|
}))
|
|
57
66
|
|
|
58
|
-
import { customRenderer, markdownToHtml, extractToc } from '../markdown'
|
|
67
|
+
import { customRenderer, markdownToHtml, extractToc, getContentSlotBoundaries } from '../markdown'
|
|
59
68
|
|
|
60
69
|
// ---------------------------------------------------------------------------
|
|
61
70
|
// customRenderer
|
|
@@ -352,3 +361,71 @@ describe('extractToc', () => {
|
|
|
352
361
|
await expect(extractToc('')).resolves.toBeDefined()
|
|
353
362
|
})
|
|
354
363
|
})
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// getContentSlotBoundaries - deterministic paragraph-offset selection logic,
|
|
367
|
+
// tested against a controlled fake mdast tree (see `mockParseResult` above)
|
|
368
|
+
// since real remark parsing isn't reachable under this ESM-mocked setup.
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
function paragraph(start: number, end: number) {
|
|
372
|
+
return { type: 'paragraph', position: { start: { offset: start }, end: { offset: end } } }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
describe('getContentSlotBoundaries', () => {
|
|
376
|
+
afterEach(() => {
|
|
377
|
+
mockParseResult = undefined
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('returns null when the source has no top-level paragraphs', () => {
|
|
381
|
+
mockParseResult = { type: 'root', children: [{ type: 'heading' }] }
|
|
382
|
+
expect(getContentSlotBoundaries('# Just a heading')).toBeNull()
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('returns null when parsing throws', () => {
|
|
386
|
+
mockParseResult = undefined
|
|
387
|
+
expect(getContentSlotBoundaries('anything')).toBeNull()
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('resolves introEnd to the end of the first paragraph', () => {
|
|
391
|
+
mockParseResult = { type: 'root', children: [paragraph(0, 10), paragraph(11, 25)] }
|
|
392
|
+
const boundaries = getContentSlotBoundaries('irrelevant, fake tree used')
|
|
393
|
+
expect(boundaries?.introEnd).toBe(10)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('resolves mid to the end of the middle paragraph for an odd count', () => {
|
|
397
|
+
mockParseResult = {
|
|
398
|
+
type: 'root',
|
|
399
|
+
children: [paragraph(0, 10), paragraph(11, 25), paragraph(26, 40)],
|
|
400
|
+
}
|
|
401
|
+
const boundaries = getContentSlotBoundaries('fake tree used')
|
|
402
|
+
expect(boundaries?.mid).toBe(25)
|
|
403
|
+
expect(boundaries?.paragraphCount).toBe(3)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('resolves mid to the end of the second-half paragraph for an even count', () => {
|
|
407
|
+
mockParseResult = {
|
|
408
|
+
type: 'root',
|
|
409
|
+
children: [paragraph(0, 10), paragraph(11, 25), paragraph(26, 40), paragraph(41, 55)],
|
|
410
|
+
}
|
|
411
|
+
const boundaries = getContentSlotBoundaries('fake tree used')
|
|
412
|
+
expect(boundaries?.mid).toBe(40)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('handles a single paragraph by pointing mid at introEnd', () => {
|
|
416
|
+
mockParseResult = { type: 'root', children: [paragraph(0, 12)] }
|
|
417
|
+
const boundaries = getContentSlotBoundaries('One paragraph.')
|
|
418
|
+
expect(boundaries?.introEnd).toBe(12)
|
|
419
|
+
expect(boundaries?.mid).toBe(12)
|
|
420
|
+
expect(boundaries?.paragraphCount).toBe(1)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('ignores non-paragraph top-level nodes', () => {
|
|
424
|
+
mockParseResult = {
|
|
425
|
+
type: 'root',
|
|
426
|
+
children: [{ type: 'heading' }, paragraph(5, 15), { type: 'thematicBreak' }],
|
|
427
|
+
}
|
|
428
|
+
const boundaries = getContentSlotBoundaries('fake tree used')
|
|
429
|
+
expect(boundaries?.paragraphCount).toBe(1)
|
|
430
|
+
})
|
|
431
|
+
})
|