@fullstackdatasolutions/articles 0.4.2 → 0.5.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.
@@ -1,6 +1,7 @@
1
1
  import { ArticlesConfig } from '../articlesConfig'
2
2
  import {
3
3
  generateArticleStaticParams,
4
+ generateArticlesIndexMetadata,
4
5
  generateCategoryStaticParams,
5
6
  generateArticleMetadata,
6
7
  generateCategoryMetadata,
@@ -98,6 +99,54 @@ describe('generateArticleStaticParams', () => {
98
99
  })
99
100
  })
100
101
 
102
+ describe('generateArticlesIndexMetadata', () => {
103
+ it('returns canonical URL pointing to /articles', () => {
104
+ const meta = generateArticlesIndexMetadata(config)
105
+ expect(meta.alternates?.canonical).toBe('https://example.com/articles')
106
+ })
107
+
108
+ it('strips trailing slash from siteUrl before building canonical', () => {
109
+ const meta = generateArticlesIndexMetadata({ ...config, siteUrl: 'https://example.com/' })
110
+ expect(meta.alternates?.canonical).toBe('https://example.com/articles')
111
+ })
112
+
113
+ it('works with a subdirectory siteUrl', () => {
114
+ const meta = generateArticlesIndexMetadata({ ...config, siteUrl: 'https://example.com/app' })
115
+ expect(meta.alternates?.canonical).toBe('https://example.com/app/articles')
116
+ })
117
+
118
+ it('includes title, description, openGraph, twitter, and robots', () => {
119
+ const meta = generateArticlesIndexMetadata(config)
120
+ expect(meta.title).toBeDefined()
121
+ expect(meta.description).toBeDefined()
122
+ expect(meta.openGraph).toBeDefined()
123
+ expect((meta.openGraph as { type?: string })?.type).toBe('website')
124
+ expect(meta.twitter).toBeDefined()
125
+ expect(meta.robots).toBeDefined()
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
+ })
148
+ })
149
+
101
150
  describe('generateCategoryStaticParams', () => {
102
151
  it('returns category slug params', async () => {
103
152
  const result = await generateCategoryStaticParams()
@@ -143,6 +192,173 @@ describe('generateArticleMetadata', () => {
143
192
  })
144
193
  expect(meta.alternates?.canonical).toBe('https://example.com/articles/article-one')
145
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
+ })
146
362
  })
147
363
 
148
364
  describe('generateCategoryMetadata', () => {
@@ -177,6 +393,17 @@ describe('generateCategoryMetadata', () => {
177
393
  const meta = await generateCategoryMetadata('nonexistent', config)
178
394
  expect(meta.title).toBe('Category Not Found')
179
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
+ })
180
407
  })
181
408
 
182
409
  describe('getArticleSitemapEntries', () => {
@@ -249,4 +476,61 @@ describe('getArticleSitemapEntries', () => {
249
476
  const configResult = await getArticleSitemapEntries(config)
250
477
  expect(JSON.stringify(stringResult)).toBe(JSON.stringify(configResult))
251
478
  })
479
+
480
+ it('uses lastmod over date for article lastModified when both are set', async () => {
481
+ const { getAllArticles } = jest.requireMock('../server-articles')
482
+ getAllArticles.mockImplementationOnce(async () => [
483
+ {
484
+ slug: 'article-one',
485
+ date: '2025-01-15',
486
+ lastmod: '2025-06-01',
487
+ title: '',
488
+ excerpt: '',
489
+ author: '',
490
+ category: '',
491
+ categories: [],
492
+ readTime: '',
493
+ featuredImage: '',
494
+ },
495
+ ])
496
+ const entries = await getArticleSitemapEntries('https://example.com')
497
+ expect(entries[0].lastModified).toEqual(new Date('2025-06-01'))
498
+ })
499
+
500
+ it('sets lastModified to undefined when article has no date or lastmod', async () => {
501
+ const { getAllArticles } = jest.requireMock('../server-articles')
502
+ getAllArticles.mockImplementationOnce(async () => [
503
+ {
504
+ slug: 'article-one',
505
+ title: '',
506
+ excerpt: '',
507
+ author: '',
508
+ category: '',
509
+ categories: [],
510
+ readTime: '',
511
+ featuredImage: '',
512
+ },
513
+ ])
514
+ const entries = await getArticleSitemapEntries('https://example.com')
515
+ expect(entries[0].lastModified).toBeUndefined()
516
+ })
517
+
518
+ it('uses date for lastModified when article has date but no lastmod', async () => {
519
+ const { getAllArticles } = jest.requireMock('../server-articles')
520
+ getAllArticles.mockImplementationOnce(async () => [
521
+ {
522
+ slug: 'article-one',
523
+ date: '2025-03-10',
524
+ title: '',
525
+ excerpt: '',
526
+ author: '',
527
+ category: '',
528
+ categories: [],
529
+ readTime: '',
530
+ featuredImage: '',
531
+ },
532
+ ])
533
+ const entries = await getArticleSitemapEntries('https://example.com')
534
+ expect(entries[0].lastModified).toEqual(new Date('2025-03-10'))
535
+ })
252
536
  })
@@ -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'