@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +559 -11
  3. package/dist/index.cjs +984 -388
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +383 -21
  6. package/dist/index.d.ts +383 -21
  7. package/dist/index.js +968 -376
  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 +149 -0
  12. package/dist/nextjs.d.ts +149 -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 +357 -3
  18. package/dist/server.d.ts +357 -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 +42 -6
  23. package/src/ArticleContent.tsx +144 -5
  24. package/src/ArticleDetailHero.tsx +33 -1
  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 +56 -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 +58 -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 +45 -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,312 @@
1
+ // `../server-articles` transitively imports `../markdown`, which pulls in
2
+ // ESM-only unified/rehype packages ts-jest's CJS transform can't load (see
3
+ // markdown.test.ts) - mock it with faithful reimplementations of just the
4
+ // three pure helpers `validateArticles`/`validateAllArticles` actually use.
5
+ jest.mock('../server-articles', () => ({
6
+ getAllArticles: jest.fn(async () => []),
7
+ categoryToSlug: (category: string) =>
8
+ category
9
+ .toLowerCase()
10
+ .replaceAll(/\s+/g, '-')
11
+ .replaceAll(/[^a-z0-9-]/g, ''),
12
+ getAuthorBySlug: (slug: string, config: { authors?: Record<string, unknown> }) =>
13
+ config.authors?.[slug] ?? null,
14
+ getArticleAuthors: (article: { author?: string; authors?: string[] }) => {
15
+ const values = article.authors ?? (article.author ? [article.author] : [])
16
+ return values.map((name) => ({ name, slug: name, bio: '' }))
17
+ },
18
+ }))
19
+
20
+ import { validateArticles } from '../validateArticles'
21
+ import type { Article } from '../articleTypes'
22
+ import type { ArticlesConfig } from '../articlesConfig'
23
+
24
+ function makeArticle(overrides: Partial<Article> = {}): Article {
25
+ return {
26
+ slug: 'test-article',
27
+ title: 'Test Article',
28
+ excerpt: 'An excerpt.',
29
+ date: '2025-01-01',
30
+ author: 'Jane Doe',
31
+ category: 'Campaigns',
32
+ categories: ['Campaigns'],
33
+ readTime: '3 min read',
34
+ featuredImage: '/img.jpg',
35
+ ...overrides,
36
+ }
37
+ }
38
+
39
+ const baseConfig: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Example' }
40
+
41
+ describe('validateArticles', () => {
42
+ it('returns ok:true with no errors/warnings for a clean, fully-populated article set', () => {
43
+ const result = validateArticles([makeArticle()], baseConfig)
44
+ expect(result.ok).toBe(true)
45
+ expect(result.errors).toEqual([])
46
+ })
47
+
48
+ describe('duplicate canonical URLs', () => {
49
+ it('flags two articles sharing a canonicalUrl as an error', () => {
50
+ const result = validateArticles(
51
+ [
52
+ makeArticle({ slug: 'one', canonicalUrl: 'https://example.com/x' }),
53
+ makeArticle({ slug: 'two', canonicalUrl: 'https://example.com/x' }),
54
+ ],
55
+ baseConfig
56
+ )
57
+ expect(result.ok).toBe(false)
58
+ expect(result.errors).toEqual(
59
+ expect.arrayContaining([expect.objectContaining({ code: 'duplicate-canonical-url' })])
60
+ )
61
+ })
62
+
63
+ it('does not flag distinct canonicalUrls', () => {
64
+ const result = validateArticles(
65
+ [
66
+ makeArticle({ slug: 'one', canonicalUrl: 'https://example.com/x' }),
67
+ makeArticle({ slug: 'two', canonicalUrl: 'https://example.com/y' }),
68
+ ],
69
+ baseConfig
70
+ )
71
+ expect(result.errors).toEqual([])
72
+ })
73
+ })
74
+
75
+ describe('author references', () => {
76
+ const configWithAuthors: ArticlesConfig = {
77
+ ...baseConfig,
78
+ authors: {
79
+ 'jane-doe': { name: 'Jane Doe', slug: 'jane-doe', bio: '' },
80
+ },
81
+ }
82
+
83
+ it('does not flag an article whose author matches config.authors', () => {
84
+ const result = validateArticles([makeArticle({ author: 'jane-doe' })], configWithAuthors)
85
+ expect(result.errors).toEqual([])
86
+ })
87
+
88
+ it('flags an author that does not match any config.authors entry', () => {
89
+ const result = validateArticles(
90
+ [makeArticle({ author: 'unknown-author' })],
91
+ configWithAuthors
92
+ )
93
+ expect(result.errors).toEqual(
94
+ expect.arrayContaining([expect.objectContaining({ code: 'unknown-author-reference' })])
95
+ )
96
+ })
97
+
98
+ it('skips the check entirely when config.authors is not configured', () => {
99
+ const result = validateArticles([makeArticle({ author: 'anyone' })], baseConfig)
100
+ expect(result.errors).toEqual([])
101
+ })
102
+ })
103
+
104
+ describe('series collisions', () => {
105
+ it('flags two articles in the same series with the same seriesOrder', () => {
106
+ const result = validateArticles(
107
+ [
108
+ makeArticle({ slug: 'one', seriesSlug: 'new-gm', seriesOrder: 1 }),
109
+ makeArticle({ slug: 'two', seriesSlug: 'new-gm', seriesOrder: 1 }),
110
+ ],
111
+ baseConfig
112
+ )
113
+ expect(result.errors).toEqual(
114
+ expect.arrayContaining([expect.objectContaining({ code: 'duplicate-series-order' })])
115
+ )
116
+ })
117
+
118
+ it('does not flag distinct seriesOrder values', () => {
119
+ const result = validateArticles(
120
+ [
121
+ makeArticle({ slug: 'one', seriesSlug: 'new-gm', seriesOrder: 1 }),
122
+ makeArticle({ slug: 'two', seriesSlug: 'new-gm', seriesOrder: 2 }),
123
+ ],
124
+ baseConfig
125
+ )
126
+ expect(result.errors).toEqual([])
127
+ })
128
+
129
+ it('does not flag articles missing seriesOrder', () => {
130
+ const result = validateArticles(
131
+ [
132
+ makeArticle({ slug: 'one', seriesSlug: 'new-gm' }),
133
+ makeArticle({ slug: 'two', seriesSlug: 'new-gm' }),
134
+ ],
135
+ baseConfig
136
+ )
137
+ expect(result.errors).toEqual([])
138
+ })
139
+ })
140
+
141
+ describe('path reference integrity', () => {
142
+ const pathConfig: ArticlesConfig = {
143
+ ...baseConfig,
144
+ paths: {
145
+ 'new-gm': {
146
+ name: 'New GM Path',
147
+ promise: 'Confidence on session one.',
148
+ articles: ['one', 'missing-slug'],
149
+ nextAction: { label: 'Get the kit', href: '/kit' },
150
+ },
151
+ },
152
+ }
153
+
154
+ it('flags a path referencing a missing article', () => {
155
+ const result = validateArticles([makeArticle({ slug: 'one' })], pathConfig)
156
+ expect(result.errors).toEqual(
157
+ expect.arrayContaining([
158
+ expect.objectContaining({ code: 'path-missing-article', articleSlug: 'missing-slug' }),
159
+ ])
160
+ )
161
+ })
162
+
163
+ it('flags a path referencing a draft article', () => {
164
+ const config: ArticlesConfig = {
165
+ ...baseConfig,
166
+ paths: {
167
+ 'new-gm': {
168
+ name: 'New GM Path',
169
+ promise: 'Confidence on session one.',
170
+ articles: ['one', 'two'],
171
+ nextAction: { label: 'Get the kit', href: '/kit' },
172
+ },
173
+ },
174
+ }
175
+ const result = validateArticles(
176
+ [makeArticle({ slug: 'one' }), makeArticle({ slug: 'two', draft: true })],
177
+ config
178
+ )
179
+ expect(result.errors).toEqual(
180
+ expect.arrayContaining([
181
+ expect.objectContaining({ code: 'path-references-draft', articleSlug: 'two' }),
182
+ ])
183
+ )
184
+ })
185
+
186
+ it('flags an empty path', () => {
187
+ const config: ArticlesConfig = {
188
+ ...baseConfig,
189
+ paths: {
190
+ empty: {
191
+ name: 'Empty Path',
192
+ promise: 'Nothing here.',
193
+ articles: [],
194
+ nextAction: { label: 'Go', href: '/go' },
195
+ },
196
+ },
197
+ }
198
+ const result = validateArticles([makeArticle()], config)
199
+ expect(result.errors).toEqual(
200
+ expect.arrayContaining([expect.objectContaining({ code: 'empty-path', pathKey: 'empty' })])
201
+ )
202
+ })
203
+
204
+ it('does not flag a fully valid path', () => {
205
+ const result = validateArticles(
206
+ [makeArticle({ slug: 'one' }), makeArticle({ slug: 'missing-slug' })],
207
+ pathConfig
208
+ )
209
+ expect(result.errors).toEqual([])
210
+ })
211
+ })
212
+
213
+ describe('unsafe URLs', () => {
214
+ it('flags a javascript: URL in a Path nextAction.href', () => {
215
+ const config: ArticlesConfig = {
216
+ ...baseConfig,
217
+ paths: {
218
+ bad: {
219
+ name: 'Bad Path',
220
+ promise: 'x',
221
+ articles: ['one'],
222
+ nextAction: { label: 'Go', href: 'javascript:alert(1)' },
223
+ },
224
+ },
225
+ }
226
+ const result = validateArticles([makeArticle({ slug: 'one' })], config)
227
+ expect(result.errors).toEqual(
228
+ expect.arrayContaining([expect.objectContaining({ code: 'unsafe-url', pathKey: 'bad' })])
229
+ )
230
+ })
231
+
232
+ it('flags a javascript: URL in an author primaryCta.href', () => {
233
+ const config: ArticlesConfig = {
234
+ ...baseConfig,
235
+ authors: {
236
+ jane: {
237
+ name: 'Jane',
238
+ slug: 'jane',
239
+ bio: '',
240
+ primaryCta: { label: 'Go', href: 'javascript:alert(1)' },
241
+ },
242
+ },
243
+ }
244
+ const result = validateArticles([makeArticle()], config)
245
+ expect(result.errors).toEqual(
246
+ expect.arrayContaining([expect.objectContaining({ code: 'unsafe-url' })])
247
+ )
248
+ })
249
+
250
+ it('does not flag ordinary https:// URLs', () => {
251
+ const config: ArticlesConfig = {
252
+ ...baseConfig,
253
+ paths: {
254
+ ok: {
255
+ name: 'OK Path',
256
+ promise: 'x',
257
+ articles: ['one'],
258
+ nextAction: { label: 'Go', href: 'https://example.com/kit' },
259
+ },
260
+ },
261
+ }
262
+ const result = validateArticles([makeArticle({ slug: 'one' })], config)
263
+ expect(result.errors).toEqual([])
264
+ })
265
+ })
266
+
267
+ describe('warnings', () => {
268
+ it('warns on a missing excerpt', () => {
269
+ const result = validateArticles([makeArticle({ excerpt: '' })], baseConfig)
270
+ expect(result.warnings).toEqual(
271
+ expect.arrayContaining([expect.objectContaining({ code: 'missing-excerpt' })])
272
+ )
273
+ })
274
+
275
+ it('warns on a missing date', () => {
276
+ const result = validateArticles([makeArticle({ date: undefined })], baseConfig)
277
+ expect(result.warnings).toEqual(
278
+ expect.arrayContaining([expect.objectContaining({ code: 'missing-date' })])
279
+ )
280
+ })
281
+
282
+ it('warns when searchTitle exceeds the recommended length', () => {
283
+ const result = validateArticles([makeArticle({ searchTitle: 'x'.repeat(61) })], baseConfig)
284
+ expect(result.warnings).toEqual(
285
+ expect.arrayContaining([expect.objectContaining({ code: 'search-title-too-long' })])
286
+ )
287
+ })
288
+
289
+ it('does not warn when searchTitle is within the recommended length', () => {
290
+ const result = validateArticles([makeArticle({ searchTitle: 'Short title' })], baseConfig)
291
+ expect(result.warnings).toEqual([])
292
+ })
293
+
294
+ it('warns on category slug collisions', () => {
295
+ const result = validateArticles(
296
+ [
297
+ makeArticle({ slug: 'one', categories: ['Game Masters'] }),
298
+ makeArticle({ slug: 'two', categories: ['game-masters'] }),
299
+ ],
300
+ baseConfig
301
+ )
302
+ expect(result.warnings).toEqual(
303
+ expect.arrayContaining([expect.objectContaining({ code: 'category-slug-collision' })])
304
+ )
305
+ })
306
+
307
+ it('warnings never affect ok', () => {
308
+ const result = validateArticles([makeArticle({ excerpt: '' })], baseConfig)
309
+ expect(result.ok).toBe(true)
310
+ })
311
+ })
312
+ })
@@ -22,9 +22,18 @@ export interface Article {
22
22
  lastmod?: string
23
23
  author: string
24
24
  authors?: string[]
25
+ // Resolved from `author`/`authors` against `config.authors` at fetch time
26
+ // (see `getArticleSummary`) so cards can link/avatar without re-resolving
27
+ // client-side. Unset when no configured author profile matches.
28
+ authorSlug?: string
29
+ authorAvatar?: string
25
30
  category: string
26
31
  categories: string[]
27
32
  readTime: string
33
+ // Word count of the raw markdown body, computed alongside readTime from
34
+ // the same reading-time pass (no extra parsing cost). Used for the
35
+ // Article schema's wordCount field.
36
+ wordCount?: number
28
37
  featuredImage: string
29
38
  tags?: string[]
30
39
  content?: string
@@ -38,7 +47,66 @@ export interface Article {
38
47
  canonicalUrl?: string
39
48
  articleType?: string
40
49
  series?: string
50
+ /**
51
+ * Machine-safe series identifier (Phase 27F) - separate from the
52
+ * label-only `series` string, which stays supported unchanged for
53
+ * consumers who only set it. `seriesSlug`/`seriesOrder` turn `series` into
54
+ * a navigable reader journey via `getArticlesBySeries`/
55
+ * `getAdjacentArticlesInSeries`. Both optional; omitted on every article
56
+ * reproduces pre-27F behavior exactly.
57
+ */
58
+ seriesSlug?: string
59
+ /** Position within `seriesSlug`, ascending. Ties/omissions fall back to date order (see `getArticlesBySeries`). */
60
+ seriesOrder?: number
41
61
  aiCrawl?: boolean
62
+ /**
63
+ * Discovery metadata overrides (Phase 27F), all optional and additive.
64
+ * `searchTitle`/`searchDescription` feed `generateArticleMetadata`'s
65
+ * `<title>`/meta description ONLY - canonical URLs, JSON-LD, RSS, and
66
+ * `ArticleCard` keep reading `title`/`excerpt` unchanged. `socialTitle`/
67
+ * `socialDescription`/`socialImage` feed Open Graph/Twitter Card output
68
+ * ONLY, falling back to `title`/`excerpt`/`featuredImage`. See
69
+ * `resolveSearchMetadata`/`resolveSocialMetadata` in `seoUtils.ts` for the
70
+ * exact fallback/sanitization rules.
71
+ */
72
+ searchTitle?: string
73
+ searchDescription?: string
74
+ socialTitle?: string
75
+ socialDescription?: string
76
+ socialImage?: string
77
+ /**
78
+ * References an app-owned CTA/offer by opaque ID (Phase 27F). The package
79
+ * never interprets `actionId` - it doesn't know about forms, email
80
+ * providers, or analytics vendors. The consuming app looks `actionId` up
81
+ * in its own registry when rendering a detail-page slot (see
82
+ * `ArticleContent`'s `afterHero`/`afterIntro`/`midContent`/`afterContent`
83
+ * props); an unmatched ID must render nothing, never throw.
84
+ */
85
+ primaryAction?: {
86
+ actionId: string
87
+ }
88
+ }
89
+
90
+ /**
91
+ * A "start here" curated reader journey that can cross series/categories -
92
+ * a distinct primitive from the label-only `series` field/`seriesSlug`
93
+ * pair (Phase 27F). Configured via `ArticlesConfig.paths`, keyed by an
94
+ * app-chosen path key. `articles` is an ordered list of slugs; every
95
+ * referenced slug must exist and not be `draft: true` - enforced by
96
+ * `validateArticles`, not silently at render time.
97
+ */
98
+ export interface PathDefinition {
99
+ /** Display name, e.g. "New GM Starter Path". */
100
+ name: string
101
+ /** One-sentence value proposition shown on the path's landing/step UI. */
102
+ promise: string
103
+ /** Ordered article slugs making up the journey. */
104
+ articles: string[]
105
+ /** The one next action offered once a reader completes the path. */
106
+ nextAction: {
107
+ label: string
108
+ href: string
109
+ }
42
110
  }
43
111
 
44
112
  export interface CategoryInfo {
@@ -66,6 +134,26 @@ export interface AuthorSocial {
66
134
  other?: Record<string, string>
67
135
  }
68
136
 
137
+ /**
138
+ * One headed block of structured long-form content (used by
139
+ * `AuthorProfile.originStory`). An array of these, not a single HTML blob,
140
+ * so consuming apps can render/style each block themselves rather than
141
+ * `dangerouslySetInnerHTML`-ing raw markup.
142
+ */
143
+ export interface RichTextSection {
144
+ heading?: string
145
+ paragraphs: string[]
146
+ }
147
+
148
+ export type RichText = RichTextSection[]
149
+
150
+ /** A single sourceable claim used by `AuthorProfile.proof`. */
151
+ export interface ProofItem {
152
+ claim: string
153
+ source?: string
154
+ url?: string
155
+ }
156
+
69
157
  export interface AuthorProfile {
70
158
  name: string
71
159
  slug: string
@@ -73,6 +161,27 @@ export interface AuthorProfile {
73
161
  avatar?: string
74
162
  url?: string
75
163
  social?: AuthorSocial
164
+ /** Short one-line audience promise, e.g. "Helping new GMs run confident first sessions." Optional, additive - omitted fields never change existing rendering. */
165
+ promise?: string
166
+ /** Structured long-form origin story - see `RichText`/`RichTextSection`. */
167
+ originStory?: RichText
168
+ /** Who this author's content/work is for, e.g. "New game masters", "Streaming DMs". */
169
+ servesWho?: string[]
170
+ /** Core beliefs/approach statements. */
171
+ principles?: string[]
172
+ /**
173
+ * Experience/credential claims (e.g. "10+ years running published campaigns").
174
+ * Intentionally never included in Person JSON-LD - unverifiable claims don't
175
+ * belong in structured data (see `getPersonSchema`/`getPersonSchemas`).
176
+ */
177
+ credentials?: string[]
178
+ /** Concrete, sourceable proof points. */
179
+ proof?: ProofItem[]
180
+ /** Primary call-to-action rendered on the author's page. */
181
+ primaryCta?: {
182
+ label: string
183
+ href: string
184
+ }
76
185
  }
77
186
 
78
187
  export interface BreadcrumbItem {
@@ -1,5 +1,6 @@
1
1
  import type { ComponentType } from 'react'
2
- import type { AuthorProfile } from './articleTypes'
2
+ import type { AuthorProfile, PathDefinition } from './articleTypes'
3
+ import type { ArticleEventHandler } from './events'
3
4
 
4
5
  /** Keys for each renderable section of the articles listing page. */
5
6
  export type ArticlesSection =
@@ -17,6 +18,14 @@ export type ArticlesSection =
17
18
  export interface ArticlesTheme {
18
19
  /** Font family for the articles section. Example: `"'Inter', sans-serif"` */
19
20
  fontFamily?: string
21
+ /**
22
+ * Font family for headings only (article title, card titles, section
23
+ * headings) - falls back to `fontFamily` when omitted. Lets a site use a
24
+ * distinct display face for headings (e.g. a serif) while keeping a
25
+ * separate body font, without hardcoding either into the package.
26
+ * Example: `"'Cinzel', serif"`
27
+ */
28
+ headerFontFamily?: string
20
29
  /** Color for article card titles and section headings. Example: `'#111827'` */
21
30
  headerColor?: string
22
31
  /** Color for body and excerpt text. Example: `'#6b7280'` */
@@ -73,6 +82,19 @@ export interface HeroConfig {
73
82
  /** Controls how article body links set target/rel attributes. */
74
83
  export type LinkTargetStrategy = 'external-new-tab' | 'all-new-tab' | 'same-tab'
75
84
 
85
+ /**
86
+ * Controls how listing pages (the articles index, category pages, author
87
+ * pages) surface articles beyond the first `pageSize`.
88
+ * - `'load-more'` (default): client-only "Load more" button, no URL change.
89
+ * Byte-for-byte identical to pre-27D behavior.
90
+ * - `'pages'`: real, directly-navigable paginated routes (`/articles/page/2`,
91
+ * `/articles/category/[category]/page/2`, `/articles/authors/[author]/page/2`)
92
+ * with SSR content, prev/next links, and per-page canonical metadata. The
93
+ * route *files* live in the consuming app - see the pagination primitives
94
+ * exported from `./server` and the `PaginationNav` component.
95
+ */
96
+ export type ListingPagination = 'load-more' | 'pages'
97
+
76
98
  /** React components that article MDX bodies can reference by JSX tag name. */
77
99
  export type MdxComponents = Record<string, ComponentType<never>>
78
100
 
@@ -166,6 +188,28 @@ export interface ArticlesConfig {
166
188
  linkTargetStrategy?: LinkTargetStrategy
167
189
  /** Extra components exposed to article MDX bodies by JSX tag name. */
168
190
  mdxComponents?: MdxComponents
191
+ /**
192
+ * Chooses how listing pages surface articles beyond the first `pageSize`.
193
+ * Default: `'load-more'` (unchanged pre-27D behavior). Set to `'pages'` to
194
+ * opt into real, crawlable paginated routes instead.
195
+ */
196
+ listingPagination?: ListingPagination
197
+ /**
198
+ * "Start here" curated reader journeys, keyed by an app-chosen path key.
199
+ * Distinct from the label-only `series` field/`seriesSlug` pair - a path
200
+ * can cross series and categories. Every `PathDefinition.articles` slug
201
+ * must exist and not be `draft: true`; validate with `validateArticles`
202
+ * before publishing, since a broken reference produces a dead journey
203
+ * step rather than a build-time failure otherwise.
204
+ */
205
+ paths?: Record<string, PathDefinition>
206
+ /**
207
+ * Vendor-neutral event callback (Phase 27F). Fired by components/hooks at
208
+ * meaningful reader-journey moments (see `ArticleEvent` in `events.ts`).
209
+ * No PII in any payload. The package never talks to an analytics/email
210
+ * vendor directly - translate events to PostHog/etc. in this callback.
211
+ */
212
+ onEvent?: ArticleEventHandler
169
213
  }
170
214
 
171
215
  export const DEFAULT_PAGE_SIZE = 6
@@ -0,0 +1,97 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+ import type { ReactNode } from 'react'
5
+ import { emitArticleEvent } from './events'
6
+ import type { ArticlesConfig } from './articlesConfig'
7
+ import type { Article } from './articleTypes'
8
+
9
+ type ArticleViewTrackerProps = Readonly<{
10
+ article: Pick<Article, 'slug'> & Partial<Pick<Article, 'category' | 'seriesSlug' | 'wordCount'>>
11
+ config?: ArticlesConfig
12
+ }>
13
+
14
+ const AVERAGE_WORDS_PER_MINUTE = 200
15
+ const DEFAULT_MEANINGFUL_READ_MS = 15_000
16
+
17
+ /**
18
+ * Renders nothing - fires `article_viewed` on mount and `meaningful_read`
19
+ * once, after roughly half the article's estimated read time has elapsed
20
+ * (a deterministic, testable timing approximation rather than scroll-depth
21
+ * tracking, which this package has no reliable cross-app way to measure
22
+ * since it doesn't own the article body's scroll container). Place once on
23
+ * the article detail page alongside `ArticleContent`.
24
+ */
25
+ export function ArticleViewTracker({ article, config }: ArticleViewTrackerProps) {
26
+ const firedMeaningfulRead = useRef(false)
27
+
28
+ useEffect(() => {
29
+ emitArticleEvent(config?.onEvent, {
30
+ name: 'article_viewed',
31
+ articleSlug: article.slug,
32
+ category: article.category,
33
+ seriesSlug: article.seriesSlug,
34
+ })
35
+
36
+ const estimatedMs = article.wordCount
37
+ ? (article.wordCount / AVERAGE_WORDS_PER_MINUTE) * 60_000 * 0.5
38
+ : DEFAULT_MEANINGFUL_READ_MS
39
+ const timer = setTimeout(() => {
40
+ if (firedMeaningfulRead.current) return
41
+ firedMeaningfulRead.current = true
42
+ emitArticleEvent(config?.onEvent, { name: 'meaningful_read', articleSlug: article.slug })
43
+ }, estimatedMs)
44
+
45
+ return () => clearTimeout(timer)
46
+ // eslint-disable-next-line react-hooks/exhaustive-deps
47
+ }, [article.slug])
48
+
49
+ return null
50
+ }
51
+
52
+ type CtaViewTrackerProps = Readonly<{
53
+ /** Opaque CTA/offer ID - never label text (no PII/marketing copy in event payloads). */
54
+ ctaId: string
55
+ articleSlug?: string
56
+ config?: ArticlesConfig
57
+ children: ReactNode
58
+ }>
59
+
60
+ /**
61
+ * Wraps any CTA block and fires `cta_viewed` once, the first time at least
62
+ * half of it scrolls into the viewport (`IntersectionObserver`). Falls back
63
+ * to firing immediately when `IntersectionObserver` isn't available (older
64
+ * browsers, non-DOM test environments) rather than never firing.
65
+ */
66
+ export function CtaViewTracker({ ctaId, articleSlug, config, children }: CtaViewTrackerProps) {
67
+ const ref = useRef<HTMLDivElement | null>(null)
68
+ const fired = useRef(false)
69
+
70
+ useEffect(() => {
71
+ const node = ref.current
72
+ if (!node) return
73
+
74
+ function fire() {
75
+ if (fired.current) return
76
+ fired.current = true
77
+ emitArticleEvent(config?.onEvent, { name: 'cta_viewed', ctaId, articleSlug })
78
+ }
79
+
80
+ if (typeof IntersectionObserver === 'undefined') {
81
+ fire()
82
+ return
83
+ }
84
+
85
+ const observer = new IntersectionObserver(
86
+ (entries) => {
87
+ if (entries.some((entry) => entry.isIntersecting)) fire()
88
+ },
89
+ { threshold: 0.5 }
90
+ )
91
+ observer.observe(node)
92
+ return () => observer.disconnect()
93
+ // eslint-disable-next-line react-hooks/exhaustive-deps
94
+ }, [ctaId, articleSlug])
95
+
96
+ return <div ref={ref}>{children}</div>
97
+ }