@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.
- package/README.md +138 -3
- package/dist/index.cjs +182 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +50 -1
- package/dist/index.d.ts +50 -1
- package/dist/index.js +241 -110
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +281 -189
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +37 -1
- package/dist/server.d.ts +37 -1
- package/dist/server.js +278 -189
- package/dist/server.js.map +1 -1
- package/package.json +5 -1
- package/src/ArticleContent.tsx +17 -0
- package/src/ArticleSchemas.tsx +97 -1
- package/src/ArticleSocialShare.tsx +1 -1
- package/src/ArticleTOC.tsx +29 -0
- package/src/ScrollToTop.tsx +25 -0
- package/src/__tests__/ArticleContent.test.tsx +91 -0
- package/src/__tests__/ArticleSchemas.test.tsx +212 -1
- package/src/__tests__/ArticleSocialShare.test.tsx +7 -7
- package/src/__tests__/ArticleTOC.test.tsx +75 -0
- package/src/__tests__/ScrollToTop.test.tsx +87 -0
- package/src/__tests__/seoUtils.test.ts +284 -0
- package/src/__tests__/server-articles.test.ts +98 -0
- package/src/articleTypes.ts +26 -0
- package/src/articlesConfig.ts +4 -0
- package/src/index.ts +10 -2
- package/src/markdown.ts +143 -130
- package/src/seoUtils.ts +79 -15
- package/src/server-articles.ts +81 -78
- package/src/server.ts +4 -2
|
@@ -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
|
+
})
|
package/src/articleTypes.ts
CHANGED
|
@@ -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 {
|
package/src/articlesConfig.ts
CHANGED
|
@@ -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 {
|
|
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'
|