@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.
- package/README.md +208 -19
- 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 +247 -188
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +42 -1
- package/dist/server.d.ts +42 -1
- package/dist/server.js +244 -188
- 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 +266 -3
- 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 +20 -14
- package/src/server-articles.ts +81 -78
- package/src/server.ts +4 -2
|
@@ -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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
+
})
|
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'
|