@fullstackdatasolutions/articles 0.12.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +559 -11
- package/dist/index.cjs +984 -388
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +383 -21
- package/dist/index.d.ts +383 -21
- package/dist/index.js +968 -376
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +74 -6
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +149 -0
- package/dist/nextjs.d.ts +149 -0
- package/dist/nextjs.js +74 -6
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +665 -27
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +357 -3
- package/dist/server.d.ts +357 -3
- package/dist/server.js +643 -29
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleCard.tsx +42 -6
- package/src/ArticleContent.tsx +144 -5
- package/src/ArticleDetailHero.tsx +33 -1
- package/src/ArticleNavigation.tsx +32 -1
- package/src/ArticleSchemas.tsx +43 -39
- package/src/ArticleSocialShare.tsx +54 -10
- package/src/ArticlesPage.tsx +56 -5
- package/src/AuthorArticlesPage.tsx +308 -14
- package/src/AuthorCard.tsx +1 -1
- package/src/CategoryArticlesPage.tsx +34 -2
- package/src/LatestArticles.tsx +28 -1
- package/src/LatestArticlesSection.tsx +15 -1
- package/src/PaginationNav.tsx +78 -0
- package/src/RelatedArticlesSection.tsx +58 -0
- package/src/SeriesArticlesPage.tsx +66 -0
- package/src/__tests__/ArticleCard.test.tsx +63 -3
- package/src/__tests__/ArticleContent.test.tsx +143 -0
- package/src/__tests__/ArticleDetailHero.test.tsx +30 -0
- package/src/__tests__/ArticleNavigation.test.tsx +81 -3
- package/src/__tests__/ArticleSchemas.test.tsx +155 -81
- package/src/__tests__/ArticleSocialShare.test.tsx +54 -0
- package/src/__tests__/ArticlesPage.test.tsx +131 -0
- package/src/__tests__/AuthorArticlesPage.test.tsx +304 -3
- package/src/__tests__/CategoryArticlesPage.test.tsx +116 -1
- package/src/__tests__/LatestArticles.test.tsx +52 -0
- package/src/__tests__/LatestArticlesSection.test.tsx +28 -0
- package/src/__tests__/PaginationNav.test.tsx +73 -0
- package/src/__tests__/RelatedArticlesSection.test.tsx +132 -0
- package/src/__tests__/SeriesArticlesPage.test.tsx +121 -0
- package/src/__tests__/eventTracking.test.tsx +145 -0
- package/src/__tests__/events.test.ts +82 -0
- package/src/__tests__/markdown.test.ts +78 -1
- package/src/__tests__/pagination.test.ts +178 -0
- package/src/__tests__/seoUtils-authors.test.ts +37 -0
- package/src/__tests__/seoUtils.test.ts +246 -0
- package/src/__tests__/server-articles.test.ts +356 -1
- package/src/__tests__/validateArticles.test.ts +312 -0
- package/src/articleTypes.ts +109 -0
- package/src/articlesConfig.ts +45 -1
- package/src/eventTracking.tsx +97 -0
- package/src/events.ts +105 -0
- package/src/index.ts +26 -1
- package/src/markdown.ts +41 -0
- package/src/pagination.ts +93 -0
- package/src/seoUtils.ts +198 -11
- package/src/server-articles.ts +199 -6
- package/src/server.ts +46 -2
- package/src/validateArticles.ts +260 -0
|
@@ -0,0 +1,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
|
+
})
|
package/src/articleTypes.ts
CHANGED
|
@@ -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 {
|
package/src/articlesConfig.ts
CHANGED
|
@@ -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
|
+
}
|