@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.
- package/CHANGELOG.md +24 -0
- package/README.md +550 -3
- package/dist/index.cjs +960 -383
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +372 -20
- package/dist/index.d.ts +372 -20
- package/dist/index.js +944 -371
- 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 +55 -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 +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 +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 +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/validateArticles.ts +260 -0
|
@@ -16,9 +16,16 @@ import {
|
|
|
16
16
|
getArticlesByAuthor,
|
|
17
17
|
getAuthorBySlug,
|
|
18
18
|
getAvailableArticleSlugs,
|
|
19
|
+
getRelatedArticlesByCategory,
|
|
20
|
+
getArticlesBySeries,
|
|
21
|
+
getAdjacentArticlesInSeries,
|
|
22
|
+
getPath,
|
|
23
|
+
getPathArticles,
|
|
24
|
+
getRelatedContent,
|
|
19
25
|
sanitizeImagePath,
|
|
20
26
|
searchArticles,
|
|
21
27
|
} from '../server-articles'
|
|
28
|
+
import type { ArticlesConfig } from '../articlesConfig'
|
|
22
29
|
import { setArticlesErrorHandler } from '../errorReporting'
|
|
23
30
|
import { markdownToHtml } from '../markdown'
|
|
24
31
|
|
|
@@ -28,7 +35,7 @@ jest.mock('../markdown', () => ({
|
|
|
28
35
|
markdownToHtml: jest.fn(async () => '<p>content</p>'),
|
|
29
36
|
extractToc: jest.fn(async () => []),
|
|
30
37
|
}))
|
|
31
|
-
jest.mock('reading-time', () => () => ({ text: '2 min read' }))
|
|
38
|
+
jest.mock('reading-time', () => () => ({ text: '2 min read', words: 42 }))
|
|
32
39
|
|
|
33
40
|
const mockedFs = fs as jest.Mocked<typeof fs>
|
|
34
41
|
const mockedMarkdownToHtml = markdownToHtml as jest.MockedFunction<typeof markdownToHtml>
|
|
@@ -181,6 +188,14 @@ describe('getArticleMetadata - frontmatter parsing', () => {
|
|
|
181
188
|
expect(article!.lastmod).toBe('2025-06-15')
|
|
182
189
|
})
|
|
183
190
|
|
|
191
|
+
it('computes wordCount from the same reading-time pass as readTime', async () => {
|
|
192
|
+
setupArticleMock('')
|
|
193
|
+
const article = await getArticleMetadata('test-slug')
|
|
194
|
+
expect(article).not.toBeNull()
|
|
195
|
+
expect(article!.readTime).toBe('2 min read')
|
|
196
|
+
expect(article!.wordCount).toBe(42)
|
|
197
|
+
})
|
|
198
|
+
|
|
184
199
|
it('parses canonicalUrl from frontmatter', async () => {
|
|
185
200
|
setupArticleMock('canonicalUrl: "https://canonical.example.com/article"\n')
|
|
186
201
|
const article = await getArticleMetadata('test-slug')
|
|
@@ -202,6 +217,75 @@ describe('getArticleMetadata - frontmatter parsing', () => {
|
|
|
202
217
|
expect(article!.series).toBe('My Series')
|
|
203
218
|
})
|
|
204
219
|
|
|
220
|
+
it('parses seriesSlug and seriesOrder from frontmatter', async () => {
|
|
221
|
+
setupArticleMock('seriesSlug: "new-gm-path"\nseriesOrder: 2\n')
|
|
222
|
+
const article = await getArticleMetadata('test-slug')
|
|
223
|
+
expect(article).not.toBeNull()
|
|
224
|
+
expect(article!.seriesSlug).toBe('new-gm-path')
|
|
225
|
+
expect(article!.seriesOrder).toBe(2)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('leaves seriesSlug/seriesOrder undefined when omitted (legacy series field still works)', async () => {
|
|
229
|
+
setupArticleMock('series: "My Series"\n')
|
|
230
|
+
const article = await getArticleMetadata('test-slug')
|
|
231
|
+
expect(article).not.toBeNull()
|
|
232
|
+
expect(article!.series).toBe('My Series')
|
|
233
|
+
expect(article!.seriesSlug).toBeUndefined()
|
|
234
|
+
expect(article!.seriesOrder).toBeUndefined()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('parses searchTitle/searchDescription/socialTitle/socialDescription/socialImage from frontmatter', async () => {
|
|
238
|
+
setupArticleMock(
|
|
239
|
+
'searchTitle: "SEO Title"\nsearchDescription: "SEO description."\nsocialTitle: "Social Title"\nsocialDescription: "Social description."\nsocialImage: "social.jpg"\n'
|
|
240
|
+
)
|
|
241
|
+
const article = await getArticleMetadata('test-slug')
|
|
242
|
+
expect(article).not.toBeNull()
|
|
243
|
+
expect(article!.searchTitle).toBe('SEO Title')
|
|
244
|
+
expect(article!.searchDescription).toBe('SEO description.')
|
|
245
|
+
expect(article!.socialTitle).toBe('Social Title')
|
|
246
|
+
expect(article!.socialDescription).toBe('Social description.')
|
|
247
|
+
expect(article!.socialImage).toBe('social.jpg')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('leaves discovery override fields undefined when omitted', async () => {
|
|
251
|
+
setupArticleMock('')
|
|
252
|
+
const article = await getArticleMetadata('test-slug')
|
|
253
|
+
expect(article).not.toBeNull()
|
|
254
|
+
expect(article!.searchTitle).toBeUndefined()
|
|
255
|
+
expect(article!.searchDescription).toBeUndefined()
|
|
256
|
+
expect(article!.socialTitle).toBeUndefined()
|
|
257
|
+
expect(article!.socialDescription).toBeUndefined()
|
|
258
|
+
expect(article!.socialImage).toBeUndefined()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('treats a blank searchTitle as unset', async () => {
|
|
262
|
+
setupArticleMock('searchTitle: ""\n')
|
|
263
|
+
const article = await getArticleMetadata('test-slug')
|
|
264
|
+
expect(article).not.toBeNull()
|
|
265
|
+
expect(article!.searchTitle).toBeUndefined()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('parses primaryAction from a shorthand string', async () => {
|
|
269
|
+
setupArticleMock('primaryAction: "download-starter-kit"\n')
|
|
270
|
+
const article = await getArticleMetadata('test-slug')
|
|
271
|
+
expect(article).not.toBeNull()
|
|
272
|
+
expect(article!.primaryAction).toEqual({ actionId: 'download-starter-kit' })
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('parses primaryAction from an object with actionId', async () => {
|
|
276
|
+
setupArticleMock('primaryAction:\n actionId: "download-starter-kit"\n')
|
|
277
|
+
const article = await getArticleMetadata('test-slug')
|
|
278
|
+
expect(article).not.toBeNull()
|
|
279
|
+
expect(article!.primaryAction).toEqual({ actionId: 'download-starter-kit' })
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('leaves primaryAction undefined when omitted', async () => {
|
|
283
|
+
setupArticleMock('')
|
|
284
|
+
const article = await getArticleMetadata('test-slug')
|
|
285
|
+
expect(article).not.toBeNull()
|
|
286
|
+
expect(article!.primaryAction).toBeUndefined()
|
|
287
|
+
})
|
|
288
|
+
|
|
205
289
|
it('filters out malformed faq items', async () => {
|
|
206
290
|
setupArticleMock(
|
|
207
291
|
'faq:\n - question: "What is this?"\n answer: "A test."\n - question: "Missing answer"\n'
|
|
@@ -258,6 +342,49 @@ describe('getArticleMetadata - frontmatter parsing', () => {
|
|
|
258
342
|
expect(article!.author).toBe('Andrew Blase')
|
|
259
343
|
})
|
|
260
344
|
|
|
345
|
+
it('resolves authorSlug and authorAvatar from a configured author with an avatar', async () => {
|
|
346
|
+
setupArticleMock('author: andrew-blase\n')
|
|
347
|
+
const article = await getArticleMetadata('test-slug', {
|
|
348
|
+
siteUrl: 'https://example.com',
|
|
349
|
+
siteName: 'Example',
|
|
350
|
+
authors: {
|
|
351
|
+
'andrew-blase': {
|
|
352
|
+
name: 'Andrew Blase',
|
|
353
|
+
slug: 'andrew-blase',
|
|
354
|
+
bio: 'Writer.',
|
|
355
|
+
avatar: 'avatar.jpg',
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
})
|
|
359
|
+
expect(article).not.toBeNull()
|
|
360
|
+
expect(article!.authorSlug).toBe('andrew-blase')
|
|
361
|
+
expect(article!.authorAvatar).toBe(
|
|
362
|
+
'https://example.com/articles/authors/andrew-blase/avatar.jpg'
|
|
363
|
+
)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('leaves authorSlug and authorAvatar unset when no config is passed', async () => {
|
|
367
|
+
setupArticleMock('author: andrew-blase\n')
|
|
368
|
+
const article = await getArticleMetadata('test-slug')
|
|
369
|
+
expect(article).not.toBeNull()
|
|
370
|
+
expect(article!.authorSlug).toBeUndefined()
|
|
371
|
+
expect(article!.authorAvatar).toBeUndefined()
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('leaves authorAvatar unset when the configured author has no avatar', async () => {
|
|
375
|
+
setupArticleMock('author: andrew-blase\n')
|
|
376
|
+
const article = await getArticleMetadata('test-slug', {
|
|
377
|
+
siteUrl: 'https://example.com',
|
|
378
|
+
siteName: 'Example',
|
|
379
|
+
authors: {
|
|
380
|
+
'andrew-blase': { name: 'Andrew Blase', slug: 'andrew-blase', bio: 'Writer.' },
|
|
381
|
+
},
|
|
382
|
+
})
|
|
383
|
+
expect(article).not.toBeNull()
|
|
384
|
+
expect(article!.authorSlug).toBe('andrew-blase')
|
|
385
|
+
expect(article!.authorAvatar).toBeUndefined()
|
|
386
|
+
})
|
|
387
|
+
|
|
261
388
|
it('parses multi-author frontmatter', async () => {
|
|
262
389
|
setupArticleMock('authors:\n - andrew-blase\n - jane-doe\n')
|
|
263
390
|
const article = await getArticleMetadata('test-slug')
|
|
@@ -555,6 +682,37 @@ describe('article collection utilities', () => {
|
|
|
555
682
|
await expect(getAdjacentArticles('missing')).resolves.toEqual({ previous: null, next: null })
|
|
556
683
|
})
|
|
557
684
|
|
|
685
|
+
it('returns other articles in the same category, excluding the current article', async () => {
|
|
686
|
+
setupArticleTreeMock([
|
|
687
|
+
{ slug: 'one', frontmatter: 'date: 2025-01-01\ntags:\n - Civic Tech\n' },
|
|
688
|
+
{ slug: 'two', frontmatter: 'date: 2025-01-02\ntags:\n - Civic Tech\n' },
|
|
689
|
+
{ slug: 'three', frontmatter: 'date: 2025-01-03\ntags:\n - Civic Tech\n' },
|
|
690
|
+
{ slug: 'unrelated', frontmatter: 'date: 2025-01-04\ntags:\n - Field Ops\n' },
|
|
691
|
+
])
|
|
692
|
+
|
|
693
|
+
const related = await getRelatedArticlesByCategory('two', 'Civic Tech')
|
|
694
|
+
expect(related.map((article) => article.slug)).toEqual(['three', 'one'])
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('respects the limit parameter', async () => {
|
|
698
|
+
setupArticleTreeMock([
|
|
699
|
+
{ slug: 'one', frontmatter: 'date: 2025-01-01\ntags:\n - Civic Tech\n' },
|
|
700
|
+
{ slug: 'two', frontmatter: 'date: 2025-01-02\ntags:\n - Civic Tech\n' },
|
|
701
|
+
{ slug: 'three', frontmatter: 'date: 2025-01-03\ntags:\n - Civic Tech\n' },
|
|
702
|
+
])
|
|
703
|
+
|
|
704
|
+
const related = await getRelatedArticlesByCategory('one', 'Civic Tech', 1)
|
|
705
|
+
expect(related).toHaveLength(1)
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
it('returns an empty array when no other articles share the category', async () => {
|
|
709
|
+
setupArticleTreeMock([
|
|
710
|
+
{ slug: 'only', frontmatter: 'date: 2025-01-01\ntags:\n - Civic Tech\n' },
|
|
711
|
+
])
|
|
712
|
+
|
|
713
|
+
await expect(getRelatedArticlesByCategory('only', 'Civic Tech')).resolves.toEqual([])
|
|
714
|
+
})
|
|
715
|
+
|
|
558
716
|
it('searches articles by title, excerpt, author, category, and tag', async () => {
|
|
559
717
|
setupArticleTreeMock([
|
|
560
718
|
{
|
|
@@ -805,3 +963,200 @@ describe('article path helpers', () => {
|
|
|
805
963
|
expect(categoryToSlug('Civic Tech & Field Ops')).toBe('civic-tech--field-ops')
|
|
806
964
|
})
|
|
807
965
|
})
|
|
966
|
+
|
|
967
|
+
describe('getArticlesBySeries', () => {
|
|
968
|
+
beforeEach(() => {
|
|
969
|
+
jest.clearAllMocks()
|
|
970
|
+
})
|
|
971
|
+
|
|
972
|
+
it('returns only articles matching seriesSlug, sorted by seriesOrder ascending', async () => {
|
|
973
|
+
setupArticleTreeMock([
|
|
974
|
+
{ slug: 'step-2', frontmatter: 'date: 2025-01-01\nseriesSlug: "new-gm"\nseriesOrder: 2\n' },
|
|
975
|
+
{ slug: 'step-1', frontmatter: 'date: 2025-01-02\nseriesSlug: "new-gm"\nseriesOrder: 1\n' },
|
|
976
|
+
{ slug: 'unrelated', frontmatter: 'date: 2025-01-03\n' },
|
|
977
|
+
])
|
|
978
|
+
|
|
979
|
+
const result = await getArticlesBySeries('new-gm')
|
|
980
|
+
expect(result.map((a) => a.slug)).toEqual(['step-1', 'step-2'])
|
|
981
|
+
})
|
|
982
|
+
|
|
983
|
+
it('falls back to date order (stable sort) for articles with no seriesOrder', async () => {
|
|
984
|
+
setupArticleTreeMock([
|
|
985
|
+
{ slug: 'newer', frontmatter: 'date: 2025-01-02\nseriesSlug: "new-gm"\n' },
|
|
986
|
+
{ slug: 'older', frontmatter: 'date: 2025-01-01\nseriesSlug: "new-gm"\n' },
|
|
987
|
+
])
|
|
988
|
+
|
|
989
|
+
const result = await getArticlesBySeries('new-gm')
|
|
990
|
+
// getAllArticles already sorts newest-first; stable sort preserves that
|
|
991
|
+
// relative order when seriesOrder is absent on both.
|
|
992
|
+
expect(result.map((a) => a.slug)).toEqual(['newer', 'older'])
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
it('returns an empty array when no articles match the series', async () => {
|
|
996
|
+
setupArticleTreeMock([{ slug: 'unrelated', frontmatter: 'date: 2025-01-01\n' }])
|
|
997
|
+
await expect(getArticlesBySeries('nonexistent')).resolves.toEqual([])
|
|
998
|
+
})
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
describe('getAdjacentArticlesInSeries', () => {
|
|
1002
|
+
beforeEach(() => {
|
|
1003
|
+
jest.clearAllMocks()
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
it('walks seriesOrder, not global date order', async () => {
|
|
1007
|
+
setupArticleTreeMock([
|
|
1008
|
+
{ slug: 'step-1', frontmatter: 'date: 2025-01-03\nseriesSlug: "new-gm"\nseriesOrder: 1\n' },
|
|
1009
|
+
{ slug: 'step-2', frontmatter: 'date: 2025-01-02\nseriesSlug: "new-gm"\nseriesOrder: 2\n' },
|
|
1010
|
+
{ slug: 'step-3', frontmatter: 'date: 2025-01-01\nseriesSlug: "new-gm"\nseriesOrder: 3\n' },
|
|
1011
|
+
])
|
|
1012
|
+
|
|
1013
|
+
const result = await getAdjacentArticlesInSeries('step-2', 'new-gm')
|
|
1014
|
+
expect(result.previous?.slug).toBe('step-1')
|
|
1015
|
+
expect(result.next?.slug).toBe('step-3')
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
it('returns null previous/next at the series boundaries', async () => {
|
|
1019
|
+
setupArticleTreeMock([
|
|
1020
|
+
{ slug: 'step-1', frontmatter: 'date: 2025-01-02\nseriesSlug: "new-gm"\nseriesOrder: 1\n' },
|
|
1021
|
+
{ slug: 'step-2', frontmatter: 'date: 2025-01-01\nseriesSlug: "new-gm"\nseriesOrder: 2\n' },
|
|
1022
|
+
])
|
|
1023
|
+
|
|
1024
|
+
const first = await getAdjacentArticlesInSeries('step-1', 'new-gm')
|
|
1025
|
+
expect(first.previous).toBeNull()
|
|
1026
|
+
expect(first.next?.slug).toBe('step-2')
|
|
1027
|
+
|
|
1028
|
+
const last = await getAdjacentArticlesInSeries('step-2', 'new-gm')
|
|
1029
|
+
expect(last.previous?.slug).toBe('step-1')
|
|
1030
|
+
expect(last.next).toBeNull()
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('returns null/null when the current slug is not in the series', async () => {
|
|
1034
|
+
setupArticleTreeMock([
|
|
1035
|
+
{ slug: 'step-1', frontmatter: 'date: 2025-01-01\nseriesSlug: "new-gm"\nseriesOrder: 1\n' },
|
|
1036
|
+
])
|
|
1037
|
+
const result = await getAdjacentArticlesInSeries('not-in-series', 'new-gm')
|
|
1038
|
+
expect(result).toEqual({ previous: null, next: null })
|
|
1039
|
+
})
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
describe('getPath / getPathArticles', () => {
|
|
1043
|
+
beforeEach(() => {
|
|
1044
|
+
jest.clearAllMocks()
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
const baseConfig: ArticlesConfig = {
|
|
1048
|
+
siteUrl: 'https://example.com',
|
|
1049
|
+
siteName: 'Example',
|
|
1050
|
+
paths: {
|
|
1051
|
+
'new-gm': {
|
|
1052
|
+
name: 'New GM Starter Path',
|
|
1053
|
+
promise: 'Run your first session with confidence.',
|
|
1054
|
+
articles: ['step-1', 'step-2'],
|
|
1055
|
+
nextAction: { label: 'Get the checklist', href: '/lead-magnets/gm-checklist' },
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
it('getPath returns the configured PathDefinition by key', () => {
|
|
1061
|
+
expect(getPath('new-gm', baseConfig)?.name).toBe('New GM Starter Path')
|
|
1062
|
+
})
|
|
1063
|
+
|
|
1064
|
+
it('getPath returns null for an unconfigured key', () => {
|
|
1065
|
+
expect(getPath('nonexistent', baseConfig)).toBeNull()
|
|
1066
|
+
})
|
|
1067
|
+
|
|
1068
|
+
it('getPathArticles resolves slugs to real articles in path order', async () => {
|
|
1069
|
+
setupArticleTreeMock([
|
|
1070
|
+
{ slug: 'step-2', frontmatter: 'date: 2025-01-01\n' },
|
|
1071
|
+
{ slug: 'step-1', frontmatter: 'date: 2025-01-02\n' },
|
|
1072
|
+
])
|
|
1073
|
+
|
|
1074
|
+
const result = await getPathArticles('new-gm', baseConfig)
|
|
1075
|
+
expect(result.map((a) => a.slug)).toEqual(['step-1', 'step-2'])
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
it('getPathArticles drops references that do not resolve to a real article', async () => {
|
|
1079
|
+
setupArticleTreeMock([{ slug: 'step-1', frontmatter: 'date: 2025-01-01\n' }])
|
|
1080
|
+
const result = await getPathArticles('new-gm', baseConfig)
|
|
1081
|
+
expect(result.map((a) => a.slug)).toEqual(['step-1'])
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
it('getPathArticles returns an empty array for an unconfigured path key', async () => {
|
|
1085
|
+
setupArticleTreeMock([{ slug: 'step-1', frontmatter: 'date: 2025-01-01\n' }])
|
|
1086
|
+
await expect(getPathArticles('nonexistent', baseConfig)).resolves.toEqual([])
|
|
1087
|
+
})
|
|
1088
|
+
})
|
|
1089
|
+
|
|
1090
|
+
describe('getRelatedContent', () => {
|
|
1091
|
+
beforeEach(() => {
|
|
1092
|
+
jest.clearAllMocks()
|
|
1093
|
+
})
|
|
1094
|
+
|
|
1095
|
+
it('prefers a configured Path over series/category when the article is in one', async () => {
|
|
1096
|
+
setupArticleTreeMock([
|
|
1097
|
+
{ slug: 'step-1', frontmatter: 'date: 2025-01-01\ntags:\n - Campaigns\n' },
|
|
1098
|
+
{ slug: 'step-2', frontmatter: 'date: 2025-01-02\ntags:\n - Campaigns\n' },
|
|
1099
|
+
])
|
|
1100
|
+
const config: ArticlesConfig = {
|
|
1101
|
+
siteUrl: 'https://example.com',
|
|
1102
|
+
siteName: 'Example',
|
|
1103
|
+
paths: {
|
|
1104
|
+
'new-gm': {
|
|
1105
|
+
name: 'New GM Starter Path',
|
|
1106
|
+
promise: 'Run your first session with confidence.',
|
|
1107
|
+
articles: ['step-1', 'step-2'],
|
|
1108
|
+
nextAction: { label: 'Get the checklist', href: '/lead-magnets/gm-checklist' },
|
|
1109
|
+
},
|
|
1110
|
+
},
|
|
1111
|
+
}
|
|
1112
|
+
const article = (await getPathArticles('new-gm', config))[0]
|
|
1113
|
+
|
|
1114
|
+
const result = await getRelatedContent(article, config)
|
|
1115
|
+
expect(result.source).toBe('path')
|
|
1116
|
+
expect(result.pathKey).toBe('new-gm')
|
|
1117
|
+
expect(result.heading).toBe('New GM Starter Path')
|
|
1118
|
+
expect(result.articles.map((a) => a.slug)).toEqual(['step-2'])
|
|
1119
|
+
expect(result.nextAction).toEqual({
|
|
1120
|
+
label: 'Get the checklist',
|
|
1121
|
+
href: '/lead-magnets/gm-checklist',
|
|
1122
|
+
})
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
it('falls back to series when no path matches', async () => {
|
|
1126
|
+
setupArticleTreeMock([
|
|
1127
|
+
{
|
|
1128
|
+
slug: 'part-1',
|
|
1129
|
+
frontmatter:
|
|
1130
|
+
'date: 2025-01-01\nseries: "Combat Basics"\nseriesSlug: "combat-basics"\nseriesOrder: 1\n',
|
|
1131
|
+
},
|
|
1132
|
+
{
|
|
1133
|
+
slug: 'part-2',
|
|
1134
|
+
frontmatter:
|
|
1135
|
+
'date: 2025-01-02\nseries: "Combat Basics"\nseriesSlug: "combat-basics"\nseriesOrder: 2\n',
|
|
1136
|
+
},
|
|
1137
|
+
])
|
|
1138
|
+
const config: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Example' }
|
|
1139
|
+
const articles = await getArticlesBySeries('combat-basics', config)
|
|
1140
|
+
const part1 = articles.find((a) => a.slug === 'part-1')!
|
|
1141
|
+
|
|
1142
|
+
const result = await getRelatedContent(part1, config)
|
|
1143
|
+
expect(result.source).toBe('series')
|
|
1144
|
+
expect(result.heading).toBe('Combat Basics')
|
|
1145
|
+
expect(result.articles.map((a) => a.slug)).toEqual(['part-2'])
|
|
1146
|
+
})
|
|
1147
|
+
|
|
1148
|
+
it('falls back to category (via getRelatedArticlesByCategory) when no path or series applies', async () => {
|
|
1149
|
+
setupArticleTreeMock([
|
|
1150
|
+
{ slug: 'one', frontmatter: 'date: 2025-01-01\ntags:\n - Civic Tech\n' },
|
|
1151
|
+
{ slug: 'two', frontmatter: 'date: 2025-01-02\ntags:\n - Civic Tech\n' },
|
|
1152
|
+
])
|
|
1153
|
+
const config: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Example' }
|
|
1154
|
+
const articles = await getAllArticles(config)
|
|
1155
|
+
const one = articles.find((a) => a.slug === 'one')!
|
|
1156
|
+
|
|
1157
|
+
const result = await getRelatedContent(one, config)
|
|
1158
|
+
expect(result.source).toBe('category')
|
|
1159
|
+
expect(result.heading).toBe('More in Civic Tech')
|
|
1160
|
+
expect(result.articles.map((a) => a.slug)).toEqual(['two'])
|
|
1161
|
+
})
|
|
1162
|
+
})
|