@fullstackdatasolutions/articles 0.8.2 → 0.10.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 +237 -0
- package/README.md +209 -78
- package/dist/index.cjs +635 -274
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +164 -56
- package/dist/index.d.ts +164 -56
- package/dist/index.js +614 -250
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +113 -38
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +71 -0
- package/dist/nextjs.d.ts +71 -0
- package/dist/nextjs.js +113 -38
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +394 -52
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +96 -6
- package/dist/server.d.ts +96 -6
- package/dist/server.js +382 -52
- package/dist/server.js.map +1 -1
- package/package.json +8 -5
- package/src/ArticleContent.tsx +8 -3
- package/src/ArticleDetailHero.tsx +27 -2
- package/src/ArticleSchemas.tsx +27 -27
- package/src/AuthorArticlesPage.tsx +60 -0
- package/src/AuthorCard.tsx +112 -0
- package/src/AuthorDetailHero.tsx +56 -0
- package/src/Breadcrumb.tsx +78 -0
- package/src/CategoryArticlesPage.tsx +62 -11
- package/src/__tests__/ArticleContent.test.tsx +18 -2
- package/src/__tests__/ArticleDetailHero.test.tsx +21 -1
- package/src/__tests__/ArticleSchemas.test.tsx +47 -2
- package/src/__tests__/AuthorArticlesPage.test.tsx +74 -0
- package/src/__tests__/AuthorCard.test.tsx +98 -0
- package/src/__tests__/AuthorDetailHero.test.tsx +51 -0
- package/src/__tests__/CategoryArticlesPage.test.tsx +31 -5
- package/src/__tests__/authorUtils.test.ts +89 -0
- package/src/__tests__/markdown.test.ts +79 -3
- package/src/__tests__/renderMdx.test.tsx +57 -0
- package/src/__tests__/seoUtils-authors.test.ts +160 -0
- package/src/__tests__/seoUtils.test.ts +106 -0
- package/src/__tests__/server-articles.test.ts +174 -3
- package/src/articleTypes.ts +33 -0
- package/src/articlesConfig.ts +67 -0
- package/src/authorUtils.ts +95 -0
- package/src/index.ts +32 -9
- package/src/markdown.ts +67 -8
- package/src/renderMdx.tsx +6 -3
- package/src/seoUtils.ts +279 -6
- package/src/server-articles.ts +124 -34
- package/src/server.ts +21 -2
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
generateArticleMetadata,
|
|
7
7
|
generateCategoryMetadata,
|
|
8
8
|
getArticleSitemapEntries,
|
|
9
|
+
generateRssFeed,
|
|
9
10
|
} from '../seoUtils'
|
|
10
11
|
|
|
11
12
|
jest.mock('../server-articles', () => ({
|
|
@@ -14,6 +15,10 @@ jest.mock('../server-articles', () => ({
|
|
|
14
15
|
{ name: 'Campaigns', slug: 'campaigns', count: 2, featuredImage: '/campaigns.jpg' },
|
|
15
16
|
{ name: 'Volunteers', slug: 'volunteers', count: 1, featuredImage: '/volunteers.jpg' },
|
|
16
17
|
]),
|
|
18
|
+
getAllAuthors: jest.fn(() => []),
|
|
19
|
+
getArticleAuthors: jest.fn((article: { author?: string }) =>
|
|
20
|
+
article.author ? [{ name: article.author, slug: article.author, bio: '' }] : []
|
|
21
|
+
),
|
|
17
22
|
getArticleMetadata: jest.fn(async (slug: string) => {
|
|
18
23
|
if (slug === 'article-one') {
|
|
19
24
|
return {
|
|
@@ -579,3 +584,104 @@ describe('getArticleSitemapEntries', () => {
|
|
|
579
584
|
expect(entries[0].lastModified).toEqual(new Date('2025-03-10'))
|
|
580
585
|
})
|
|
581
586
|
})
|
|
587
|
+
|
|
588
|
+
describe('generateRssFeed', () => {
|
|
589
|
+
const baseConfig: ArticlesConfig = {
|
|
590
|
+
siteUrl: 'https://example.com',
|
|
591
|
+
siteName: 'Example Site',
|
|
592
|
+
description: 'Test description',
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const baseArticle = {
|
|
596
|
+
slug: 'my-article',
|
|
597
|
+
title: 'My Article',
|
|
598
|
+
excerpt: 'A short excerpt',
|
|
599
|
+
date: '2024-01-15',
|
|
600
|
+
author: 'Jane Doe',
|
|
601
|
+
category: 'Tales of the Valiant',
|
|
602
|
+
categories: ['Tales of the Valiant'],
|
|
603
|
+
readTime: '3 min read',
|
|
604
|
+
featuredImage: '/articles/my-article/hero.jpg',
|
|
605
|
+
tags: [],
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
it('returns a string starting with the XML declaration', () => {
|
|
609
|
+
const xml = generateRssFeed([], baseConfig)
|
|
610
|
+
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8" ?>')
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
it('includes xmlns:media namespace in the rss element', () => {
|
|
614
|
+
const xml = generateRssFeed([], baseConfig)
|
|
615
|
+
expect(xml).toContain('xmlns:media="http://search.yahoo.com/mrss/"')
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('renders <category> from article.category', () => {
|
|
619
|
+
const xml = generateRssFeed([baseArticle], baseConfig)
|
|
620
|
+
expect(xml).toContain('<category><![CDATA[Tales of the Valiant]]></category>')
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('renders <media:content> with resolved absolute image URL', () => {
|
|
624
|
+
const xml = generateRssFeed([baseArticle], baseConfig)
|
|
625
|
+
expect(xml).toContain(
|
|
626
|
+
'<media:content url="https://example.com/articles/my-article/hero.jpg" medium="image" width="1200" height="630"/>'
|
|
627
|
+
)
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
it('passes through absolute featuredImage URLs unchanged in <media:content>', () => {
|
|
631
|
+
const article = { ...baseArticle, featuredImage: 'https://cdn.example.com/image.jpg' }
|
|
632
|
+
const xml = generateRssFeed([article], baseConfig)
|
|
633
|
+
expect(xml).toContain(
|
|
634
|
+
'<media:content url="https://cdn.example.com/image.jpg" medium="image" width="1200" height="630"/>'
|
|
635
|
+
)
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
it('omits <category> when article.category is empty', () => {
|
|
639
|
+
const article = { ...baseArticle, category: '' }
|
|
640
|
+
const xml = generateRssFeed([article], baseConfig)
|
|
641
|
+
expect(xml).not.toContain('<category>')
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
it('omits <media:content> when article.featuredImage is empty', () => {
|
|
645
|
+
const article = { ...baseArticle, featuredImage: '' }
|
|
646
|
+
const xml = generateRssFeed([article], baseConfig)
|
|
647
|
+
expect(xml).not.toContain('<media:content')
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
it('strips trailing slash from siteUrl when resolving image URL', () => {
|
|
651
|
+
const article = { ...baseArticle }
|
|
652
|
+
const xml = generateRssFeed([article], { ...baseConfig, siteUrl: 'https://example.com/' })
|
|
653
|
+
expect(xml).not.toContain('//articles')
|
|
654
|
+
expect(xml).toContain('https://example.com/articles/my-article/hero.jpg')
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
it('renders <title>, <link>, <guid>, <pubDate>, <description>, and <author>', () => {
|
|
658
|
+
const xml = generateRssFeed([baseArticle], baseConfig)
|
|
659
|
+
expect(xml).toContain('<![CDATA[My Article]]>')
|
|
660
|
+
expect(xml).toContain('https://example.com/articles/my-article')
|
|
661
|
+
expect(xml).toContain('<pubDate>')
|
|
662
|
+
expect(xml).toContain('<![CDATA[A short excerpt]]>')
|
|
663
|
+
expect(xml).toContain('<author>Jane Doe</author>')
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
it('uses config.description in the channel description', () => {
|
|
667
|
+
const xml = generateRssFeed([], baseConfig)
|
|
668
|
+
expect(xml).toContain('<![CDATA[Test description]]>')
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('falls back to siteName articles when description is absent', () => {
|
|
672
|
+
const xml = generateRssFeed([], { siteUrl: 'https://example.com', siteName: 'My Site' })
|
|
673
|
+
expect(xml).toContain('<![CDATA[My Site articles]]>')
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it('omits author when showAuthor is false', () => {
|
|
677
|
+
const xml = generateRssFeed([baseArticle], { ...baseConfig, showAuthor: false })
|
|
678
|
+
expect(xml).not.toContain('<author>')
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
it('renders multiple items', () => {
|
|
682
|
+
const second = { ...baseArticle, slug: 'second', title: 'Second Article' }
|
|
683
|
+
const xml = generateRssFeed([baseArticle, second], baseConfig)
|
|
684
|
+
expect(xml).toContain('<![CDATA[My Article]]>')
|
|
685
|
+
expect(xml).toContain('<![CDATA[Second Article]]>')
|
|
686
|
+
})
|
|
687
|
+
})
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getAdjacentArticles,
|
|
6
6
|
getAiRobotsTxtRules,
|
|
7
7
|
getAllArticles,
|
|
8
|
+
getArticleAuthors,
|
|
8
9
|
getAllCategories,
|
|
9
10
|
getArticleAiHeaders,
|
|
10
11
|
getArticleMarkdown,
|
|
@@ -12,11 +13,14 @@ import {
|
|
|
12
13
|
getArticleMarkdownUrl,
|
|
13
14
|
getArticleMetadata,
|
|
14
15
|
getArticlesByCategory,
|
|
16
|
+
getArticlesByAuthor,
|
|
17
|
+
getAuthorBySlug,
|
|
15
18
|
getAvailableArticleSlugs,
|
|
16
19
|
sanitizeImagePath,
|
|
17
20
|
searchArticles,
|
|
18
21
|
} from '../server-articles'
|
|
19
22
|
import { setArticlesErrorHandler } from '../errorReporting'
|
|
23
|
+
import { markdownToHtml } from '../markdown'
|
|
20
24
|
|
|
21
25
|
jest.mock('react', () => ({ cache: (fn: Function) => fn }))
|
|
22
26
|
jest.mock('node:fs')
|
|
@@ -27,6 +31,7 @@ jest.mock('../markdown', () => ({
|
|
|
27
31
|
jest.mock('reading-time', () => () => ({ text: '2 min read' }))
|
|
28
32
|
|
|
29
33
|
const mockedFs = fs as jest.Mocked<typeof fs>
|
|
34
|
+
const mockedMarkdownToHtml = markdownToHtml as jest.MockedFunction<typeof markdownToHtml>
|
|
30
35
|
const articlesDirectory = path.join(process.cwd(), 'public/articles')
|
|
31
36
|
|
|
32
37
|
beforeEach(() => {
|
|
@@ -84,8 +89,9 @@ function mockDirectory(name: string): fs.Dirent {
|
|
|
84
89
|
|
|
85
90
|
function setupArticleMock(frontmatter: string, body = 'Article body content.'): void {
|
|
86
91
|
mockedFs.existsSync.mockReturnValue(true)
|
|
92
|
+
const authorLine = /^authors?:/m.test(frontmatter) ? '' : 'author: Test Author\n'
|
|
87
93
|
mockedFs.readFileSync.mockReturnValue(
|
|
88
|
-
`---\ntitle: Test Article\nexcerpt: A test.\
|
|
94
|
+
`---\ntitle: Test Article\nexcerpt: A test.\n${authorLine}${frontmatter}---\n\n${body}`
|
|
89
95
|
)
|
|
90
96
|
;(mockedFs.readdirSync as jest.Mock).mockReturnValue([])
|
|
91
97
|
}
|
|
@@ -129,7 +135,8 @@ function setupArticleTreeMock(articles: readonly MockArticle[]): void {
|
|
|
129
135
|
mockedFs.readFileSync.mockImplementation((target) => {
|
|
130
136
|
const article = articleFilePaths.get(target.toString())
|
|
131
137
|
if (!article) throw new Error(`Unexpected file read: ${target.toString()}`)
|
|
132
|
-
|
|
138
|
+
const authorLine = /^authors?:/m.test(article.frontmatter) ? '' : 'author: Test Author\n'
|
|
139
|
+
return `---\ntitle: ${article.slug}\nexcerpt: Excerpt for ${article.slug}\n${authorLine}${article.frontmatter}---\n\n${article.body ?? 'Article body content.'}`
|
|
133
140
|
})
|
|
134
141
|
;(mockedFs.readdirSync as jest.Mock).mockImplementation((target: fs.PathLike) => {
|
|
135
142
|
const targetPath = target.toString()
|
|
@@ -233,6 +240,158 @@ describe('getArticleMetadata - frontmatter parsing', () => {
|
|
|
233
240
|
expect(article!.aiCrawl).toBe(expected)
|
|
234
241
|
})
|
|
235
242
|
})
|
|
243
|
+
|
|
244
|
+
it('resolves a configured author slug to the display name', async () => {
|
|
245
|
+
setupArticleMock('author: andrew-blase\n')
|
|
246
|
+
const article = await getArticleMetadata('test-slug', {
|
|
247
|
+
siteUrl: 'https://example.com',
|
|
248
|
+
siteName: 'Example',
|
|
249
|
+
authors: {
|
|
250
|
+
'andrew-blase': {
|
|
251
|
+
name: 'Andrew Blase',
|
|
252
|
+
slug: 'andrew-blase',
|
|
253
|
+
bio: 'Writer.',
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
})
|
|
257
|
+
expect(article).not.toBeNull()
|
|
258
|
+
expect(article!.author).toBe('Andrew Blase')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('parses multi-author frontmatter', async () => {
|
|
262
|
+
setupArticleMock('authors:\n - andrew-blase\n - jane-doe\n')
|
|
263
|
+
const article = await getArticleMetadata('test-slug')
|
|
264
|
+
expect(article).not.toBeNull()
|
|
265
|
+
expect(article!.authors).toEqual(['andrew-blase', 'jane-doe'])
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('preserves explicit zero-author frontmatter', async () => {
|
|
269
|
+
setupArticleMock('authors: []\n')
|
|
270
|
+
const article = await getArticleMetadata('test-slug')
|
|
271
|
+
expect(article).not.toBeNull()
|
|
272
|
+
expect(article!.author).toBe('')
|
|
273
|
+
expect(article!.authors).toEqual([])
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
describe('author utilities', () => {
|
|
278
|
+
beforeEach(() => {
|
|
279
|
+
jest.clearAllMocks()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const config = {
|
|
283
|
+
siteUrl: 'https://example.com',
|
|
284
|
+
siteName: 'Example',
|
|
285
|
+
defaultAuthor: 'andrew-blase',
|
|
286
|
+
authors: {
|
|
287
|
+
'andrew-blase': {
|
|
288
|
+
name: 'Andrew Blase',
|
|
289
|
+
slug: 'andrew-blase',
|
|
290
|
+
bio: 'Writer.',
|
|
291
|
+
},
|
|
292
|
+
'jane-doe': {
|
|
293
|
+
name: 'Jane Doe',
|
|
294
|
+
slug: 'jane-doe',
|
|
295
|
+
bio: 'Guest writer.',
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
it('gets an author by slug with an auto-generated profile URL', () => {
|
|
301
|
+
expect(getAuthorBySlug('andrew-blase', config)).toEqual({
|
|
302
|
+
name: 'Andrew Blase',
|
|
303
|
+
slug: 'andrew-blase',
|
|
304
|
+
bio: 'Writer.',
|
|
305
|
+
url: 'https://example.com/articles/authors/andrew-blase',
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('resolves article authors from multi-author frontmatter first', () => {
|
|
310
|
+
const authors = getArticleAuthors(
|
|
311
|
+
{
|
|
312
|
+
slug: 'test',
|
|
313
|
+
title: 'Test',
|
|
314
|
+
excerpt: 'Test.',
|
|
315
|
+
author: 'Andrew Blase',
|
|
316
|
+
authors: ['jane-doe', 'andrew-blase'],
|
|
317
|
+
category: 'Campaigns',
|
|
318
|
+
categories: ['Campaigns'],
|
|
319
|
+
readTime: '2 min read',
|
|
320
|
+
featuredImage: '/image.jpg',
|
|
321
|
+
},
|
|
322
|
+
config
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
expect(authors.map((author) => author.slug)).toEqual(['jane-doe', 'andrew-blase'])
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('returns no article authors when authors is explicitly empty', () => {
|
|
329
|
+
const authors = getArticleAuthors(
|
|
330
|
+
{
|
|
331
|
+
slug: 'test',
|
|
332
|
+
title: 'Test',
|
|
333
|
+
excerpt: 'Test.',
|
|
334
|
+
author: '',
|
|
335
|
+
authors: [],
|
|
336
|
+
category: 'Campaigns',
|
|
337
|
+
categories: ['Campaigns'],
|
|
338
|
+
readTime: '2 min read',
|
|
339
|
+
featuredImage: '/image.jpg',
|
|
340
|
+
},
|
|
341
|
+
config
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
expect(authors).toEqual([])
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('does not duplicate the author when frontmatter author matches the default author', () => {
|
|
348
|
+
const authors = getArticleAuthors(
|
|
349
|
+
{
|
|
350
|
+
slug: 'test',
|
|
351
|
+
title: 'Test',
|
|
352
|
+
excerpt: 'Test.',
|
|
353
|
+
author: 'andrew-blase',
|
|
354
|
+
category: 'Campaigns',
|
|
355
|
+
categories: ['Campaigns'],
|
|
356
|
+
readTime: '2 min read',
|
|
357
|
+
featuredImage: '/image.jpg',
|
|
358
|
+
},
|
|
359
|
+
config
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
expect(authors.map((author) => author.slug)).toEqual(['andrew-blase'])
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('does not duplicate the author when article.author is already resolved to the display name', () => {
|
|
366
|
+
// getArticleMetadata resolves article.author to the author's display name (e.g. "Andrew Blase")
|
|
367
|
+
// while config.defaultAuthor stays a slug (e.g. "andrew-blase") - these must dedupe to one author.
|
|
368
|
+
const authors = getArticleAuthors(
|
|
369
|
+
{
|
|
370
|
+
slug: 'test',
|
|
371
|
+
title: 'Test',
|
|
372
|
+
excerpt: 'Test.',
|
|
373
|
+
author: 'Andrew Blase',
|
|
374
|
+
category: 'Campaigns',
|
|
375
|
+
categories: ['Campaigns'],
|
|
376
|
+
readTime: '2 min read',
|
|
377
|
+
featuredImage: '/image.jpg',
|
|
378
|
+
},
|
|
379
|
+
config
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
expect(authors.map((author) => author.slug)).toEqual(['andrew-blase'])
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('filters articles by configured author', async () => {
|
|
386
|
+
setupArticleTreeMock([
|
|
387
|
+
{ slug: 'andrew-post', frontmatter: 'date: 2025-01-01\nauthor: andrew-blase\n' },
|
|
388
|
+
{ slug: 'jane-post', frontmatter: 'date: 2025-01-02\nauthor: jane-doe\n' },
|
|
389
|
+
])
|
|
390
|
+
|
|
391
|
+
await expect(getArticlesByAuthor('jane-doe', config)).resolves.toEqual([
|
|
392
|
+
expect.objectContaining({ slug: 'jane-post' }),
|
|
393
|
+
])
|
|
394
|
+
})
|
|
236
395
|
})
|
|
237
396
|
|
|
238
397
|
describe('getAvailableArticleSlugs', () => {
|
|
@@ -480,7 +639,13 @@ describe('getArticleMetadata - content handling', () => {
|
|
|
480
639
|
},
|
|
481
640
|
])
|
|
482
641
|
|
|
483
|
-
const
|
|
642
|
+
const config = {
|
|
643
|
+
siteUrl: 'https://example.com',
|
|
644
|
+
siteName: 'Example',
|
|
645
|
+
linkTargetStrategy: 'external-new-tab' as const,
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const article = await getArticleMetadata('markdown-article', config)
|
|
484
649
|
|
|
485
650
|
expect(article).toEqual(
|
|
486
651
|
expect.objectContaining({
|
|
@@ -493,6 +658,11 @@ describe('getArticleMetadata - content handling', () => {
|
|
|
493
658
|
toc: [],
|
|
494
659
|
})
|
|
495
660
|
)
|
|
661
|
+
expect(mockedMarkdownToHtml).toHaveBeenCalledWith(
|
|
662
|
+
'\n## Heading\n\nMarkdown body',
|
|
663
|
+
'markdown-article',
|
|
664
|
+
config
|
|
665
|
+
)
|
|
496
666
|
})
|
|
497
667
|
|
|
498
668
|
it('loads mdx articles as source instead of rendered html', async () => {
|
|
@@ -515,6 +685,7 @@ describe('getArticleMetadata - content handling', () => {
|
|
|
515
685
|
})
|
|
516
686
|
)
|
|
517
687
|
expect(article?.mdxSource?.trim()).toBe('<Component />')
|
|
688
|
+
expect(mockedMarkdownToHtml).not.toHaveBeenCalled()
|
|
518
689
|
})
|
|
519
690
|
|
|
520
691
|
it('returns null when article file loading fails', async () => {
|
package/src/articleTypes.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface Article {
|
|
|
21
21
|
date?: string
|
|
22
22
|
lastmod?: string
|
|
23
23
|
author: string
|
|
24
|
+
authors?: string[]
|
|
24
25
|
category: string
|
|
25
26
|
categories: string[]
|
|
26
27
|
readTime: string
|
|
@@ -46,3 +47,35 @@ export interface CategoryInfo {
|
|
|
46
47
|
count: number
|
|
47
48
|
featuredImage: string
|
|
48
49
|
}
|
|
50
|
+
|
|
51
|
+
export interface AuthorSocial {
|
|
52
|
+
website?: string
|
|
53
|
+
facebook?: string
|
|
54
|
+
twitter?: string
|
|
55
|
+
x?: string
|
|
56
|
+
linkedin?: string
|
|
57
|
+
instagram?: string
|
|
58
|
+
youtube?: string
|
|
59
|
+
tiktok?: string
|
|
60
|
+
github?: string
|
|
61
|
+
bluesky?: string
|
|
62
|
+
threads?: string
|
|
63
|
+
mastodon?: string
|
|
64
|
+
medium?: string
|
|
65
|
+
newsletter?: string
|
|
66
|
+
other?: Record<string, string>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AuthorProfile {
|
|
70
|
+
name: string
|
|
71
|
+
slug: string
|
|
72
|
+
bio: string
|
|
73
|
+
avatar?: string
|
|
74
|
+
url?: string
|
|
75
|
+
social?: AuthorSocial
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface BreadcrumbItem {
|
|
79
|
+
name: string
|
|
80
|
+
url?: string
|
|
81
|
+
}
|
package/src/articlesConfig.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AuthorProfile } from './articleTypes'
|
|
2
|
+
|
|
1
3
|
/** Keys for each renderable section of the articles listing page. */
|
|
2
4
|
export type ArticlesSection =
|
|
3
5
|
| 'hero'
|
|
@@ -67,6 +69,52 @@ export interface HeroConfig {
|
|
|
67
69
|
description?: string
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/** Controls how article body links set target/rel attributes. */
|
|
73
|
+
export type LinkTargetStrategy = 'external-new-tab' | 'all-new-tab' | 'same-tab'
|
|
74
|
+
|
|
75
|
+
export type ArticleBreadcrumbToken =
|
|
76
|
+
| 'home'
|
|
77
|
+
| 'articles'
|
|
78
|
+
| 'primaryCategory'
|
|
79
|
+
| 'folderPath'
|
|
80
|
+
| 'articleTitle'
|
|
81
|
+
export type CategoryBreadcrumbToken = 'home' | 'articles' | 'category'
|
|
82
|
+
export type AuthorBreadcrumbToken = 'home' | 'articles' | 'authors' | 'authorName'
|
|
83
|
+
|
|
84
|
+
export interface CustomBreadcrumbItem {
|
|
85
|
+
/** Label displayed in the breadcrumb trail. */
|
|
86
|
+
name: string
|
|
87
|
+
/** Custom URL. Relative paths are resolved against `siteUrl` by server builders. */
|
|
88
|
+
url: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type ArticleBreadcrumbEntry = ArticleBreadcrumbToken | CustomBreadcrumbItem
|
|
92
|
+
export type CategoryBreadcrumbEntry = CategoryBreadcrumbToken | CustomBreadcrumbItem
|
|
93
|
+
export type AuthorBreadcrumbEntry = AuthorBreadcrumbToken | CustomBreadcrumbItem
|
|
94
|
+
|
|
95
|
+
export interface BreadcrumbLabels {
|
|
96
|
+
home?: string
|
|
97
|
+
articles?: string
|
|
98
|
+
authors?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface BreadcrumbsConfig {
|
|
102
|
+
/** Set to false to hide visible breadcrumbs and breadcrumb JSON-LD generated by the helper builders. */
|
|
103
|
+
show?: boolean
|
|
104
|
+
/** Separator used by the visible Breadcrumb component. Default: '>'. */
|
|
105
|
+
separator?: string
|
|
106
|
+
/** Set to false to render visible breadcrumbs without JSON-LD. Default: true. */
|
|
107
|
+
showSchema?: boolean
|
|
108
|
+
/** Article breadcrumb trail. Example: ['primaryCategory', { name: 'Guides', url: '/guides' }, 'articleTitle']. */
|
|
109
|
+
article?: ArticleBreadcrumbEntry[]
|
|
110
|
+
/** Category breadcrumb trail. Default: ['home', 'articles', 'category']. */
|
|
111
|
+
category?: CategoryBreadcrumbEntry[]
|
|
112
|
+
/** Author breadcrumb trail. Default: ['home', 'articles', 'authors', 'authorName']. */
|
|
113
|
+
author?: AuthorBreadcrumbEntry[]
|
|
114
|
+
/** Optional label overrides for built-in breadcrumb items. */
|
|
115
|
+
labels?: BreadcrumbLabels
|
|
116
|
+
}
|
|
117
|
+
|
|
70
118
|
/** Top-level configuration object. Pass one instance to every library component. */
|
|
71
119
|
export interface ArticlesConfig {
|
|
72
120
|
/** Canonical base URL of the site, used in metadata and JSON-LD. Example: `'https://yoursite.com'` */
|
|
@@ -102,6 +150,16 @@ export interface ArticlesConfig {
|
|
|
102
150
|
showBackToArticles?: boolean
|
|
103
151
|
/** Set to false to hide author names from UI and metadata. Default: true. */
|
|
104
152
|
showAuthor?: boolean
|
|
153
|
+
/** Author profiles keyed by slug. Omit to keep plain string author display. */
|
|
154
|
+
authors?: Record<string, AuthorProfile>
|
|
155
|
+
/** Author slug used when article frontmatter omits author fields. */
|
|
156
|
+
defaultAuthor?: string
|
|
157
|
+
/** Set to false to disable copied author page routes in consuming apps. Default: true. */
|
|
158
|
+
showAuthorPage?: boolean
|
|
159
|
+
/** Set to false to disable breadcrumbs, or pass a config object to customize breadcrumb trails. */
|
|
160
|
+
breadcrumbs?: false | BreadcrumbsConfig
|
|
161
|
+
/** Article body link target behavior. Default: `'external-new-tab'`. */
|
|
162
|
+
linkTargetStrategy?: LinkTargetStrategy
|
|
105
163
|
}
|
|
106
164
|
|
|
107
165
|
export const DEFAULT_PAGE_SIZE = 6
|
|
@@ -114,3 +172,12 @@ export const DEFAULT_LAYOUT: ArticlesSection[] = [
|
|
|
114
172
|
'latest',
|
|
115
173
|
'categories',
|
|
116
174
|
]
|
|
175
|
+
|
|
176
|
+
export function breadcrumbsAreEnabled(config: ArticlesConfig): boolean {
|
|
177
|
+
return config.breadcrumbs !== false && config.breadcrumbs?.show !== false
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function getBreadcrumbsConfig(config: ArticlesConfig): BreadcrumbsConfig {
|
|
181
|
+
if (config.breadcrumbs === false) return {}
|
|
182
|
+
return config.breadcrumbs ?? {}
|
|
183
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ArticlesConfig } from './articlesConfig'
|
|
2
|
+
import type { AuthorProfile, AuthorSocial } from './articleTypes'
|
|
3
|
+
|
|
4
|
+
export function getAuthorUrl(author: AuthorProfile): string {
|
|
5
|
+
return author.url ?? `/articles/authors/${author.slug}`
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getAuthorAvatar(
|
|
9
|
+
author: AuthorProfile,
|
|
10
|
+
config?: ArticlesConfig
|
|
11
|
+
): string | undefined {
|
|
12
|
+
if (!author.avatar) return undefined
|
|
13
|
+
if (author.avatar.startsWith('http://') || author.avatar.startsWith('https://')) {
|
|
14
|
+
return author.avatar
|
|
15
|
+
}
|
|
16
|
+
const path = `/articles/authors/${author.slug}/${author.avatar.replace(/^\/+/, '')}`
|
|
17
|
+
if (!config) return path
|
|
18
|
+
return `${config.siteUrl.replace(/\/$/, '')}${path}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getAuthorSameAs(author: AuthorProfile): string[] {
|
|
22
|
+
return getAuthorSocialLinks(author).map((link) => link.href)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AuthorSocialLink {
|
|
26
|
+
label: string
|
|
27
|
+
href: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeHandle(value: string): string {
|
|
31
|
+
return value.replace(/^@/, '')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeUrl(value: string, baseUrl?: string): string {
|
|
35
|
+
if (value.startsWith('http://') || value.startsWith('https://')) return value
|
|
36
|
+
if (!baseUrl) return value
|
|
37
|
+
return `${baseUrl}${normalizeHandle(value)}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getConfiguredSocialLinks(social: AuthorSocial): AuthorSocialLink[] {
|
|
41
|
+
return [
|
|
42
|
+
{ label: 'Website', href: social.website ?? '' },
|
|
43
|
+
{
|
|
44
|
+
label: 'Facebook',
|
|
45
|
+
href: social.facebook ? normalizeUrl(social.facebook, 'https://www.facebook.com/') : '',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
label: 'Twitter',
|
|
49
|
+
href: social.twitter ? normalizeUrl(social.twitter, 'https://twitter.com/') : '',
|
|
50
|
+
},
|
|
51
|
+
{ label: 'X', href: social.x ? normalizeUrl(social.x, 'https://x.com/') : '' },
|
|
52
|
+
{
|
|
53
|
+
label: 'LinkedIn',
|
|
54
|
+
href: social.linkedin ? normalizeUrl(social.linkedin, 'https://www.linkedin.com/in/') : '',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
label: 'Instagram',
|
|
58
|
+
href: social.instagram ? normalizeUrl(social.instagram, 'https://www.instagram.com/') : '',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
label: 'YouTube',
|
|
62
|
+
href: social.youtube ? normalizeUrl(social.youtube, 'https://www.youtube.com/') : '',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
label: 'TikTok',
|
|
66
|
+
href: social.tiktok ? normalizeUrl(social.tiktok, 'https://www.tiktok.com/@') : '',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
label: 'GitHub',
|
|
70
|
+
href: social.github ? normalizeUrl(social.github, 'https://github.com/') : '',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
label: 'Bluesky',
|
|
74
|
+
href: social.bluesky ? normalizeUrl(social.bluesky, 'https://bsky.app/profile/') : '',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
label: 'Threads',
|
|
78
|
+
href: social.threads ? normalizeUrl(social.threads, 'https://www.threads.net/@') : '',
|
|
79
|
+
},
|
|
80
|
+
{ label: 'Mastodon', href: social.mastodon ? normalizeUrl(social.mastodon) : '' },
|
|
81
|
+
{
|
|
82
|
+
label: 'Medium',
|
|
83
|
+
href: social.medium ? normalizeUrl(social.medium, 'https://medium.com/@') : '',
|
|
84
|
+
},
|
|
85
|
+
{ label: 'Newsletter', href: social.newsletter ?? '' },
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getAuthorSocialLinks(author: AuthorProfile): AuthorSocialLink[] {
|
|
90
|
+
const social = author.social
|
|
91
|
+
if (!social) return []
|
|
92
|
+
const configuredLinks = getConfiguredSocialLinks(social)
|
|
93
|
+
const otherLinks = Object.entries(social.other ?? {}).map(([label, href]) => ({ label, href }))
|
|
94
|
+
return [...configuredLinks, ...otherLinks].filter((link) => link.href.trim().length > 0)
|
|
95
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,13 +11,11 @@ export { FeaturedArticle } from './FeaturedArticle'
|
|
|
11
11
|
export { LatestArticles } from './LatestArticles'
|
|
12
12
|
export { LatestArticlesSection } from './LatestArticlesSection'
|
|
13
13
|
export { CategoryArticlesPage } from './CategoryArticlesPage'
|
|
14
|
-
export {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
FAQPageSchema,
|
|
20
|
-
} from './ArticleSchemas'
|
|
14
|
+
export { AuthorArticlesPage } from './AuthorArticlesPage'
|
|
15
|
+
export { AuthorCard, AuthorSocialLinks } from './AuthorCard'
|
|
16
|
+
export { AuthorDetailHero } from './AuthorDetailHero'
|
|
17
|
+
export { Breadcrumb, BreadcrumbSchema } from './Breadcrumb'
|
|
18
|
+
export { ArticleSchema, ArticleSEO, CollectionPageSchema, FAQPageSchema } from './ArticleSchemas'
|
|
21
19
|
|
|
22
20
|
export { ArticleSocialShare } from './ArticleSocialShare'
|
|
23
21
|
export { ArticleNavigation } from './ArticleNavigation'
|
|
@@ -42,7 +40,32 @@ export type {
|
|
|
42
40
|
ArticlesSection,
|
|
43
41
|
CategoryDescription,
|
|
44
42
|
CommentsConfig,
|
|
43
|
+
LinkTargetStrategy,
|
|
44
|
+
BreadcrumbsConfig,
|
|
45
|
+
BreadcrumbLabels,
|
|
46
|
+
ArticleBreadcrumbToken,
|
|
47
|
+
CategoryBreadcrumbToken,
|
|
48
|
+
AuthorBreadcrumbToken,
|
|
49
|
+
ArticleBreadcrumbEntry,
|
|
50
|
+
CategoryBreadcrumbEntry,
|
|
51
|
+
AuthorBreadcrumbEntry,
|
|
52
|
+
CustomBreadcrumbItem,
|
|
53
|
+
} from './articlesConfig'
|
|
54
|
+
export {
|
|
55
|
+
DEFAULT_LAYOUT,
|
|
56
|
+
DEFAULT_PAGE_SIZE,
|
|
57
|
+
DEFAULT_CATEGORIES_PAGE_SIZE,
|
|
58
|
+
breadcrumbsAreEnabled,
|
|
59
|
+
getBreadcrumbsConfig,
|
|
45
60
|
} from './articlesConfig'
|
|
46
|
-
export {
|
|
47
|
-
|
|
61
|
+
export type {
|
|
62
|
+
Article,
|
|
63
|
+
AuthorProfile,
|
|
64
|
+
AuthorSocial,
|
|
65
|
+
BreadcrumbItem,
|
|
66
|
+
CategoryInfo,
|
|
67
|
+
FaqItem,
|
|
68
|
+
HowToStep,
|
|
69
|
+
TocItem,
|
|
70
|
+
} from './articleTypes'
|
|
48
71
|
export type { ArticleComment, ArticleCommentWithReplies } from './commentTypes'
|