@fullstackdatasolutions/articles 0.4.3 → 0.5.1

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.
@@ -124,6 +124,27 @@ describe('generateArticlesIndexMetadata', () => {
124
124
  expect(meta.twitter).toBeDefined()
125
125
  expect(meta.robots).toBeDefined()
126
126
  })
127
+
128
+ it('uses config.hero.description when provided', () => {
129
+ const cfg: ArticlesConfig = {
130
+ ...config,
131
+ hero: { description: 'Custom hero description.' },
132
+ }
133
+ const meta = generateArticlesIndexMetadata(cfg)
134
+ expect(meta.description).toBe('Custom hero description.')
135
+ })
136
+
137
+ it('includes RSS feed URL in alternates.types', () => {
138
+ const meta = generateArticlesIndexMetadata(config)
139
+ const types = (meta.alternates as { types?: Record<string, string> })?.types
140
+ expect(types?.['application/rss+xml']).toBe('https://example.com/articles/feed.xml')
141
+ })
142
+
143
+ it('strips trailing slash from siteUrl before building RSS feed URL', () => {
144
+ const meta = generateArticlesIndexMetadata({ ...config, siteUrl: 'https://example.com/' })
145
+ const types = (meta.alternates as { types?: Record<string, string> })?.types
146
+ expect(types?.['application/rss+xml']).toBe('https://example.com/articles/feed.xml')
147
+ })
127
148
  })
128
149
 
129
150
  describe('generateCategoryStaticParams', () => {
@@ -171,6 +192,173 @@ describe('generateArticleMetadata', () => {
171
192
  })
172
193
  expect(meta.alternates?.canonical).toBe('https://example.com/articles/article-one')
173
194
  })
195
+
196
+ it('uses canonicalUrl override from article when set', async () => {
197
+ const { getArticleMetadata: mockGetArticleMetadata } = jest.requireMock('../server-articles')
198
+ mockGetArticleMetadata.mockImplementationOnce(async () => ({
199
+ slug: 'article-one',
200
+ title: 'Article One',
201
+ excerpt: 'Excerpt.',
202
+ date: '2025-01-15',
203
+ author: 'Jane Doe',
204
+ category: 'Campaigns',
205
+ categories: ['Campaigns'],
206
+ readTime: '3 min read',
207
+ featuredImage: '/articles/article-one/image.jpg',
208
+ canonicalUrl: 'https://custom.example.com/my-article',
209
+ }))
210
+ const meta = await generateArticleMetadata('article-one', config)
211
+ expect(meta.alternates?.canonical).toBe('https://custom.example.com/my-article')
212
+ })
213
+
214
+ it('includes modifiedTime in openGraph when lastmod is set', async () => {
215
+ const { getArticleMetadata: mockGetArticleMetadata } = jest.requireMock('../server-articles')
216
+ mockGetArticleMetadata.mockImplementationOnce(async () => ({
217
+ slug: 'article-one',
218
+ title: 'Article One',
219
+ excerpt: 'Excerpt.',
220
+ date: '2025-01-15',
221
+ author: 'Jane Doe',
222
+ category: 'Campaigns',
223
+ categories: ['Campaigns'],
224
+ readTime: '3 min read',
225
+ featuredImage: '/articles/article-one/image.jpg',
226
+ lastmod: '2025-06-15',
227
+ }))
228
+ const meta = await generateArticleMetadata('article-one', config)
229
+ expect((meta.openGraph as { modifiedTime?: string }).modifiedTime).toBe(
230
+ new Date('2025-06-15').toISOString()
231
+ )
232
+ })
233
+
234
+ it('includes article:modified_time in other when lastmod is set', async () => {
235
+ const { getArticleMetadata: mockGetArticleMetadata } = jest.requireMock('../server-articles')
236
+ mockGetArticleMetadata.mockImplementationOnce(async () => ({
237
+ slug: 'article-one',
238
+ title: 'Article One',
239
+ excerpt: 'Excerpt.',
240
+ date: '2025-01-15',
241
+ author: 'Jane Doe',
242
+ category: 'Campaigns',
243
+ categories: ['Campaigns'],
244
+ readTime: '3 min read',
245
+ featuredImage: '/articles/article-one/image.jpg',
246
+ lastmod: '2025-06-15',
247
+ }))
248
+ const meta = await generateArticleMetadata('article-one', config)
249
+ expect((meta.other as Record<string, string>)['article:modified_time']).toBe(
250
+ new Date('2025-06-15').toISOString()
251
+ )
252
+ })
253
+
254
+ it('resolves absolute featuredImage URL without modification', async () => {
255
+ const { getArticleMetadata: mockGetArticleMetadata } = jest.requireMock('../server-articles')
256
+ mockGetArticleMetadata.mockImplementationOnce(async () => ({
257
+ slug: 'article-one',
258
+ title: 'Article One',
259
+ excerpt: 'Excerpt.',
260
+ date: '2025-01-15',
261
+ author: 'Jane Doe',
262
+ category: 'Campaigns',
263
+ categories: ['Campaigns'],
264
+ readTime: '3 min read',
265
+ featuredImage: 'https://cdn.example.com/image.jpg',
266
+ }))
267
+ const meta = await generateArticleMetadata('article-one', config)
268
+ expect((meta.openGraph as { images?: Array<{ url: string }> }).images?.[0].url).toBe(
269
+ 'https://cdn.example.com/image.jpg'
270
+ )
271
+ })
272
+
273
+ it('uses fallback description when excerpt is null', async () => {
274
+ const { getArticleMetadata: mockGetArticleMetadata } = jest.requireMock('../server-articles')
275
+ mockGetArticleMetadata.mockImplementationOnce(async () => ({
276
+ slug: 'article-one',
277
+ title: 'Article One',
278
+ excerpt: null,
279
+ date: '2025-01-15',
280
+ author: 'Jane Doe',
281
+ category: 'Campaigns',
282
+ categories: ['Campaigns'],
283
+ readTime: '3 min read',
284
+ featuredImage: '/articles/article-one/image.jpg',
285
+ tags: ['campaigns'],
286
+ }))
287
+ const meta = await generateArticleMetadata('article-one', config)
288
+ expect(meta.description).toBe('Read Article One on Example Site.')
289
+ })
290
+
291
+ it('uses placeholder image when featuredImage is falsy', async () => {
292
+ const { getArticleMetadata: mockGetArticleMetadata } = jest.requireMock('../server-articles')
293
+ mockGetArticleMetadata.mockImplementationOnce(async () => ({
294
+ slug: 'article-one',
295
+ title: 'Article One',
296
+ excerpt: 'Excerpt.',
297
+ date: '2025-01-15',
298
+ author: 'Jane Doe',
299
+ category: 'Campaigns',
300
+ categories: ['Campaigns'],
301
+ readTime: '3 min read',
302
+ featuredImage: '',
303
+ tags: ['campaigns'],
304
+ }))
305
+ const meta = await generateArticleMetadata('article-one', config)
306
+ expect((meta.openGraph as { images?: Array<{ url: string }> }).images?.[0].url).toBe(
307
+ 'https://example.com/placeholder-logo.png'
308
+ )
309
+ })
310
+
311
+ it('populates keywords from article tags', async () => {
312
+ const meta = await generateArticleMetadata('article-one', config)
313
+ expect(meta.keywords).toBe('campaigns, strategy')
314
+ })
315
+
316
+ it('includes article:author, article:published_time, article:section, and article:tag in other', async () => {
317
+ const meta = await generateArticleMetadata('article-one', config)
318
+ const other = meta.other as Record<string, string>
319
+ expect(other['article:author']).toBe('Jane Doe')
320
+ expect(other['article:published_time']).toBe(new Date('2025-01-15').toISOString())
321
+ expect(other['article:section']).toBe('Campaigns')
322
+ expect(other['article:tag']).toBe('campaigns,strategy')
323
+ })
324
+
325
+ it('omits publishedTime and article:published_time when date is undefined', async () => {
326
+ const { getArticleMetadata: mockGetArticleMetadata } = jest.requireMock('../server-articles')
327
+ mockGetArticleMetadata.mockImplementationOnce(async () => ({
328
+ slug: 'article-one',
329
+ title: 'Article One',
330
+ excerpt: 'Excerpt.',
331
+ author: 'Jane Doe',
332
+ category: 'Campaigns',
333
+ categories: ['Campaigns'],
334
+ readTime: '3 min read',
335
+ featuredImage: '/articles/article-one/image.jpg',
336
+ tags: ['campaigns'],
337
+ }))
338
+ const meta = await generateArticleMetadata('article-one', config)
339
+ expect((meta.openGraph as { publishedTime?: string }).publishedTime).toBeUndefined()
340
+ expect(
341
+ (meta.other as Record<string, string | undefined>)['article:published_time']
342
+ ).toBeUndefined()
343
+ })
344
+
345
+ it('sets keywords to empty string and article:tag to empty string when tags is undefined', async () => {
346
+ const { getArticleMetadata: mockGetArticleMetadata } = jest.requireMock('../server-articles')
347
+ mockGetArticleMetadata.mockImplementationOnce(async () => ({
348
+ slug: 'article-one',
349
+ title: 'Article One',
350
+ excerpt: 'Excerpt.',
351
+ date: '2025-01-15',
352
+ author: 'Jane Doe',
353
+ category: 'Campaigns',
354
+ categories: ['Campaigns'],
355
+ readTime: '3 min read',
356
+ featuredImage: '/articles/article-one/image.jpg',
357
+ }))
358
+ const meta = await generateArticleMetadata('article-one', config)
359
+ expect(meta.keywords).toBe('')
360
+ expect((meta.other as Record<string, string>)['article:tag']).toBe('')
361
+ })
174
362
  })
175
363
 
176
364
  describe('generateCategoryMetadata', () => {
@@ -205,6 +393,17 @@ describe('generateCategoryMetadata', () => {
205
393
  const meta = await generateCategoryMetadata('nonexistent', config)
206
394
  expect(meta.title).toBe('Category Not Found')
207
395
  })
396
+
397
+ it('uses string categoryDescriptions value directly', async () => {
398
+ const cfg: ArticlesConfig = {
399
+ ...config,
400
+ categoryDescriptions: {
401
+ campaigns: 'Plain string description.',
402
+ },
403
+ }
404
+ const meta = await generateCategoryMetadata('campaigns', cfg)
405
+ expect(meta.description).toBe('Plain string description.')
406
+ })
208
407
  })
209
408
 
210
409
  describe('getArticleSitemapEntries', () => {
@@ -273,8 +472,72 @@ describe('getArticleSitemapEntries', () => {
273
472
  })
274
473
 
275
474
  it('returns same results whether called with string or ArticlesConfig', async () => {
276
- const stringResult = await getArticleSitemapEntries('https://example.com')
277
- const configResult = await getArticleSitemapEntries(config)
278
- expect(JSON.stringify(stringResult)).toBe(JSON.stringify(configResult))
475
+ jest.useFakeTimers()
476
+ jest.setSystemTime(new Date('2026-05-19T23:57:39.503Z'))
477
+
478
+ try {
479
+ const stringResult = await getArticleSitemapEntries('https://example.com')
480
+ const configResult = await getArticleSitemapEntries(config)
481
+ expect(JSON.stringify(stringResult)).toBe(JSON.stringify(configResult))
482
+ } finally {
483
+ jest.useRealTimers()
484
+ }
485
+ })
486
+
487
+ it('uses lastmod over date for article lastModified when both are set', async () => {
488
+ const { getAllArticles } = jest.requireMock('../server-articles')
489
+ getAllArticles.mockImplementationOnce(async () => [
490
+ {
491
+ slug: 'article-one',
492
+ date: '2025-01-15',
493
+ lastmod: '2025-06-01',
494
+ title: '',
495
+ excerpt: '',
496
+ author: '',
497
+ category: '',
498
+ categories: [],
499
+ readTime: '',
500
+ featuredImage: '',
501
+ },
502
+ ])
503
+ const entries = await getArticleSitemapEntries('https://example.com')
504
+ expect(entries[0].lastModified).toEqual(new Date('2025-06-01'))
505
+ })
506
+
507
+ it('sets lastModified to undefined when article has no date or lastmod', async () => {
508
+ const { getAllArticles } = jest.requireMock('../server-articles')
509
+ getAllArticles.mockImplementationOnce(async () => [
510
+ {
511
+ slug: 'article-one',
512
+ title: '',
513
+ excerpt: '',
514
+ author: '',
515
+ category: '',
516
+ categories: [],
517
+ readTime: '',
518
+ featuredImage: '',
519
+ },
520
+ ])
521
+ const entries = await getArticleSitemapEntries('https://example.com')
522
+ expect(entries[0].lastModified).toBeUndefined()
523
+ })
524
+
525
+ it('uses date for lastModified when article has date but no lastmod', async () => {
526
+ const { getAllArticles } = jest.requireMock('../server-articles')
527
+ getAllArticles.mockImplementationOnce(async () => [
528
+ {
529
+ slug: 'article-one',
530
+ date: '2025-03-10',
531
+ title: '',
532
+ excerpt: '',
533
+ author: '',
534
+ category: '',
535
+ categories: [],
536
+ readTime: '',
537
+ featuredImage: '',
538
+ },
539
+ ])
540
+ const entries = await getArticleSitemapEntries('https://example.com')
541
+ expect(entries[0].lastModified).toEqual(new Date('2025-03-10'))
279
542
  })
280
543
  })
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs'
2
+ import { getArticleMetadata } from '../server-articles'
3
+
4
+ jest.mock('react', () => ({ cache: (fn: Function) => fn }))
5
+ jest.mock('node:fs')
6
+ jest.mock('../markdown', () => ({
7
+ markdownToHtml: jest.fn(async () => '<p>content</p>'),
8
+ extractToc: jest.fn(async () => []),
9
+ }))
10
+ jest.mock('reading-time', () => () => ({ text: '2 min read' }))
11
+
12
+ const mockedFs = fs as jest.Mocked<typeof fs>
13
+
14
+ function setupArticleMock(frontmatter: string, body = 'Article body content.'): void {
15
+ mockedFs.existsSync.mockReturnValue(true)
16
+ mockedFs.readFileSync.mockReturnValue(
17
+ `---\ntitle: Test Article\nexcerpt: A test.\nauthor: Test Author\n${frontmatter}---\n\n${body}`
18
+ )
19
+ ;(mockedFs.readdirSync as jest.Mock).mockReturnValue([])
20
+ }
21
+
22
+ describe('getArticleMetadata - frontmatter parsing', () => {
23
+ beforeEach(() => {
24
+ jest.clearAllMocks()
25
+ })
26
+
27
+ it('parses faq from frontmatter', async () => {
28
+ setupArticleMock(
29
+ 'faq:\n - question: "What is this?"\n answer: "A test."\n - question: "Why?"\n answer: "Because."\n'
30
+ )
31
+ const article = await getArticleMetadata('test-slug')
32
+ expect(article).not.toBeNull()
33
+ expect(article!.faq).toHaveLength(2)
34
+ expect(article!.faq![0]).toEqual({ question: 'What is this?', answer: 'A test.' })
35
+ })
36
+
37
+ it('parses howTo from frontmatter', async () => {
38
+ setupArticleMock(
39
+ 'howTo:\n - name: "Step 1"\n text: "Do this."\n - name: "Step 2"\n text: "Then that."\n'
40
+ )
41
+ const article = await getArticleMetadata('test-slug')
42
+ expect(article).not.toBeNull()
43
+ expect(article!.howTo).toHaveLength(2)
44
+ expect(article!.howTo![0]).toEqual({ name: 'Step 1', text: 'Do this.' })
45
+ })
46
+
47
+ it('parses lastmod from frontmatter', async () => {
48
+ setupArticleMock('lastmod: 2025-06-15\n')
49
+ const article = await getArticleMetadata('test-slug')
50
+ expect(article).not.toBeNull()
51
+ expect(article!.lastmod).toBe('2025-06-15')
52
+ })
53
+
54
+ it('parses canonicalUrl from frontmatter', async () => {
55
+ setupArticleMock('canonicalUrl: "https://canonical.example.com/article"\n')
56
+ const article = await getArticleMetadata('test-slug')
57
+ expect(article).not.toBeNull()
58
+ expect(article!.canonicalUrl).toBe('https://canonical.example.com/article')
59
+ })
60
+
61
+ it('parses articleType from frontmatter', async () => {
62
+ setupArticleMock('articleType: "TechArticle"\n')
63
+ const article = await getArticleMetadata('test-slug')
64
+ expect(article).not.toBeNull()
65
+ expect(article!.articleType).toBe('TechArticle')
66
+ })
67
+
68
+ it('parses series from frontmatter', async () => {
69
+ setupArticleMock('series: "My Series"\n')
70
+ const article = await getArticleMetadata('test-slug')
71
+ expect(article).not.toBeNull()
72
+ expect(article!.series).toBe('My Series')
73
+ })
74
+
75
+ it('filters out malformed faq items', async () => {
76
+ setupArticleMock(
77
+ 'faq:\n - question: "What is this?"\n answer: "A test."\n - question: "Missing answer"\n'
78
+ )
79
+ const article = await getArticleMetadata('test-slug')
80
+ expect(article).not.toBeNull()
81
+ expect(article!.faq).toHaveLength(1)
82
+ expect(article!.faq![0].question).toBe('What is this?')
83
+ })
84
+
85
+ it('returns undefined for faq when not present', async () => {
86
+ setupArticleMock('')
87
+ const article = await getArticleMetadata('test-slug')
88
+ expect(article).not.toBeNull()
89
+ expect(article!.faq).toBeUndefined()
90
+ })
91
+
92
+ it('returns undefined for howTo when not present', async () => {
93
+ setupArticleMock('')
94
+ const article = await getArticleMetadata('test-slug')
95
+ expect(article).not.toBeNull()
96
+ expect(article!.howTo).toBeUndefined()
97
+ })
98
+ })
@@ -1,8 +1,25 @@
1
+ export interface TocItem {
2
+ id: string
3
+ depth: number
4
+ text: string
5
+ }
6
+
7
+ export interface FaqItem {
8
+ question: string
9
+ answer: string
10
+ }
11
+
12
+ export interface HowToStep {
13
+ name: string
14
+ text: string
15
+ }
16
+
1
17
  export interface Article {
2
18
  slug: string
3
19
  title: string
4
20
  excerpt: string
5
21
  date?: string
22
+ lastmod?: string
6
23
  author: string
7
24
  category: string
8
25
  categories: string[]
@@ -11,6 +28,15 @@ export interface Article {
11
28
  tags?: string[]
12
29
  content?: string
13
30
  htmlContent?: string
31
+ mdxSource?: string
32
+ contentType?: 'md' | 'mdx'
33
+ draft?: boolean
34
+ toc?: TocItem[]
35
+ faq?: FaqItem[]
36
+ howTo?: HowToStep[]
37
+ canonicalUrl?: string
38
+ articleType?: string
39
+ series?: string
14
40
  }
15
41
 
16
42
  export interface CategoryInfo {
@@ -94,6 +94,10 @@ export interface ArticlesConfig {
94
94
  comments?: CommentsConfig
95
95
  /** Hero section title and description. Omit to use the built-in defaults. */
96
96
  hero?: HeroConfig
97
+ /** Short description used in the RSS feed channel. Falls back to siteName if omitted. */
98
+ description?: string
99
+ /** Set to false to hide the table of contents on article detail pages. Default: true. */
100
+ showToc?: boolean
97
101
  }
98
102
 
99
103
  export const DEFAULT_PAGE_SIZE = 6
package/src/index.ts CHANGED
@@ -11,10 +11,18 @@ export { FeaturedArticle } from './FeaturedArticle'
11
11
  export { LatestArticles } from './LatestArticles'
12
12
  export { LatestArticlesSection } from './LatestArticlesSection'
13
13
  export { CategoryArticlesPage } from './CategoryArticlesPage'
14
- export { ArticleSchema, BreadcrumbSchema, CollectionPageSchema } from './ArticleSchemas'
14
+ export {
15
+ ArticleSchema,
16
+ ArticleSEO,
17
+ BreadcrumbSchema,
18
+ CollectionPageSchema,
19
+ FAQPageSchema,
20
+ } from './ArticleSchemas'
15
21
 
16
22
  export { ArticleSocialShare } from './ArticleSocialShare'
17
23
  export { ArticleNavigation } from './ArticleNavigation'
24
+ export { ArticleTOC } from './ArticleTOC'
25
+ export { ScrollToTop } from './ScrollToTop'
18
26
 
19
27
  // Comments
20
28
  export { CommentsSection } from './CommentsSection'
@@ -35,5 +43,5 @@ export type {
35
43
  CommentsConfig,
36
44
  } from './articlesConfig'
37
45
  export { DEFAULT_LAYOUT, DEFAULT_PAGE_SIZE, DEFAULT_CATEGORIES_PAGE_SIZE } from './articlesConfig'
38
- export type { Article, CategoryInfo } from './articleTypes'
46
+ export type { Article, CategoryInfo, FaqItem, HowToStep, TocItem } from './articleTypes'
39
47
  export type { ArticleComment, ArticleCommentWithReplies } from './commentTypes'