@fullstackdatasolutions/articles 0.12.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 (69) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +550 -3
  3. package/dist/index.cjs +960 -383
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +372 -20
  6. package/dist/index.d.ts +372 -20
  7. package/dist/index.js +944 -371
  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 +55 -5
  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 +131 -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__/validateArticles.test.ts +312 -0
  59. package/src/articleTypes.ts +109 -0
  60. package/src/articlesConfig.ts +37 -1
  61. package/src/eventTracking.tsx +97 -0
  62. package/src/events.ts +105 -0
  63. package/src/index.ts +26 -1
  64. package/src/markdown.ts +41 -0
  65. package/src/pagination.ts +93 -0
  66. package/src/seoUtils.ts +198 -11
  67. package/src/server-articles.ts +199 -6
  68. package/src/server.ts +46 -2
  69. 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
+ })