@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +237 -0
  2. package/README.md +209 -78
  3. package/dist/index.cjs +635 -274
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +164 -56
  6. package/dist/index.d.ts +164 -56
  7. package/dist/index.js +614 -250
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +113 -38
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +71 -0
  12. package/dist/nextjs.d.ts +71 -0
  13. package/dist/nextjs.js +113 -38
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +394 -52
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +96 -6
  18. package/dist/server.d.ts +96 -6
  19. package/dist/server.js +382 -52
  20. package/dist/server.js.map +1 -1
  21. package/package.json +8 -5
  22. package/src/ArticleContent.tsx +8 -3
  23. package/src/ArticleDetailHero.tsx +27 -2
  24. package/src/ArticleSchemas.tsx +27 -27
  25. package/src/AuthorArticlesPage.tsx +60 -0
  26. package/src/AuthorCard.tsx +112 -0
  27. package/src/AuthorDetailHero.tsx +56 -0
  28. package/src/Breadcrumb.tsx +78 -0
  29. package/src/CategoryArticlesPage.tsx +62 -11
  30. package/src/__tests__/ArticleContent.test.tsx +18 -2
  31. package/src/__tests__/ArticleDetailHero.test.tsx +21 -1
  32. package/src/__tests__/ArticleSchemas.test.tsx +47 -2
  33. package/src/__tests__/AuthorArticlesPage.test.tsx +74 -0
  34. package/src/__tests__/AuthorCard.test.tsx +98 -0
  35. package/src/__tests__/AuthorDetailHero.test.tsx +51 -0
  36. package/src/__tests__/CategoryArticlesPage.test.tsx +31 -5
  37. package/src/__tests__/authorUtils.test.ts +89 -0
  38. package/src/__tests__/markdown.test.ts +79 -3
  39. package/src/__tests__/renderMdx.test.tsx +57 -0
  40. package/src/__tests__/seoUtils-authors.test.ts +160 -0
  41. package/src/__tests__/seoUtils.test.ts +106 -0
  42. package/src/__tests__/server-articles.test.ts +174 -3
  43. package/src/articleTypes.ts +33 -0
  44. package/src/articlesConfig.ts +67 -0
  45. package/src/authorUtils.ts +95 -0
  46. package/src/index.ts +32 -9
  47. package/src/markdown.ts +67 -8
  48. package/src/renderMdx.tsx +6 -3
  49. package/src/seoUtils.ts +279 -6
  50. package/src/server-articles.ts +124 -34
  51. 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.\nauthor: Test Author\n${frontmatter}---\n\n${body}`
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
- return `---\ntitle: ${article.slug}\nexcerpt: Excerpt for ${article.slug}\nauthor: Test Author\n${article.frontmatter}---\n\n${article.body ?? 'Article body content.'}`
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 article = await getArticleMetadata('markdown-article')
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 () => {
@@ -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
+ }
@@ -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
- ArticleSchema,
16
- ArticleSEO,
17
- BreadcrumbSchema,
18
- CollectionPageSchema,
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 { DEFAULT_LAYOUT, DEFAULT_PAGE_SIZE, DEFAULT_CATEGORIES_PAGE_SIZE } from './articlesConfig'
47
- export type { Article, CategoryInfo, FaqItem, HowToStep, TocItem } from './articleTypes'
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'