@fullstackdatasolutions/articles 0.9.0 → 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 (47) hide show
  1. package/CHANGELOG.md +237 -0
  2. package/README.md +199 -29
  3. package/dist/index.cjs +635 -274
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +160 -56
  6. package/dist/index.d.ts +160 -56
  7. package/dist/index.js +614 -250
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +40 -5
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +67 -0
  12. package/dist/nextjs.d.ts +67 -0
  13. package/dist/nextjs.js +40 -5
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +278 -15
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +87 -3
  18. package/dist/server.d.ts +87 -3
  19. package/dist/server.js +267 -15
  20. package/dist/server.js.map +1 -1
  21. package/package.json +8 -5
  22. package/src/ArticleDetailHero.tsx +27 -2
  23. package/src/ArticleSchemas.tsx +27 -27
  24. package/src/AuthorArticlesPage.tsx +60 -0
  25. package/src/AuthorCard.tsx +112 -0
  26. package/src/AuthorDetailHero.tsx +56 -0
  27. package/src/Breadcrumb.tsx +78 -0
  28. package/src/CategoryArticlesPage.tsx +62 -11
  29. package/src/__tests__/ArticleDetailHero.test.tsx +21 -1
  30. package/src/__tests__/ArticleSchemas.test.tsx +47 -2
  31. package/src/__tests__/AuthorArticlesPage.test.tsx +74 -0
  32. package/src/__tests__/AuthorCard.test.tsx +98 -0
  33. package/src/__tests__/AuthorDetailHero.test.tsx +51 -0
  34. package/src/__tests__/CategoryArticlesPage.test.tsx +31 -5
  35. package/src/__tests__/authorUtils.test.ts +89 -0
  36. package/src/__tests__/renderMdx.test.tsx +35 -0
  37. package/src/__tests__/seoUtils-authors.test.ts +160 -0
  38. package/src/__tests__/seoUtils.test.ts +4 -0
  39. package/src/__tests__/server-articles.test.ts +159 -2
  40. package/src/articleTypes.ts +33 -0
  41. package/src/articlesConfig.ts +62 -0
  42. package/src/authorUtils.ts +95 -0
  43. package/src/index.ts +31 -9
  44. package/src/renderMdx.tsx +3 -1
  45. package/src/seoUtils.ts +226 -7
  46. package/src/server-articles.ts +98 -10
  47. package/src/server.ts +19 -1
@@ -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,6 +13,8 @@ import {
12
13
  getArticleMarkdownUrl,
13
14
  getArticleMetadata,
14
15
  getArticlesByCategory,
16
+ getArticlesByAuthor,
17
+ getAuthorBySlug,
15
18
  getAvailableArticleSlugs,
16
19
  sanitizeImagePath,
17
20
  searchArticles,
@@ -86,8 +89,9 @@ function mockDirectory(name: string): fs.Dirent {
86
89
 
87
90
  function setupArticleMock(frontmatter: string, body = 'Article body content.'): void {
88
91
  mockedFs.existsSync.mockReturnValue(true)
92
+ const authorLine = /^authors?:/m.test(frontmatter) ? '' : 'author: Test Author\n'
89
93
  mockedFs.readFileSync.mockReturnValue(
90
- `---\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}`
91
95
  )
92
96
  ;(mockedFs.readdirSync as jest.Mock).mockReturnValue([])
93
97
  }
@@ -131,7 +135,8 @@ function setupArticleTreeMock(articles: readonly MockArticle[]): void {
131
135
  mockedFs.readFileSync.mockImplementation((target) => {
132
136
  const article = articleFilePaths.get(target.toString())
133
137
  if (!article) throw new Error(`Unexpected file read: ${target.toString()}`)
134
- 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.'}`
135
140
  })
136
141
  ;(mockedFs.readdirSync as jest.Mock).mockImplementation((target: fs.PathLike) => {
137
142
  const targetPath = target.toString()
@@ -235,6 +240,158 @@ describe('getArticleMetadata - frontmatter parsing', () => {
235
240
  expect(article!.aiCrawl).toBe(expected)
236
241
  })
237
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
+ })
238
395
  })
239
396
 
240
397
  describe('getAvailableArticleSlugs', () => {
@@ -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'
@@ -70,6 +72,49 @@ export interface HeroConfig {
70
72
  /** Controls how article body links set target/rel attributes. */
71
73
  export type LinkTargetStrategy = 'external-new-tab' | 'all-new-tab' | 'same-tab'
72
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
+
73
118
  /** Top-level configuration object. Pass one instance to every library component. */
74
119
  export interface ArticlesConfig {
75
120
  /** Canonical base URL of the site, used in metadata and JSON-LD. Example: `'https://yoursite.com'` */
@@ -105,6 +150,14 @@ export interface ArticlesConfig {
105
150
  showBackToArticles?: boolean
106
151
  /** Set to false to hide author names from UI and metadata. Default: true. */
107
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
108
161
  /** Article body link target behavior. Default: `'external-new-tab'`. */
109
162
  linkTargetStrategy?: LinkTargetStrategy
110
163
  }
@@ -119,3 +172,12 @@ export const DEFAULT_LAYOUT: ArticlesSection[] = [
119
172
  'latest',
120
173
  'categories',
121
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'
@@ -43,7 +41,31 @@ export type {
43
41
  CategoryDescription,
44
42
  CommentsConfig,
45
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,
46
60
  } from './articlesConfig'
47
- export { DEFAULT_LAYOUT, DEFAULT_PAGE_SIZE, DEFAULT_CATEGORIES_PAGE_SIZE } from './articlesConfig'
48
- 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'
49
71
  export type { ArticleComment, ArticleCommentWithReplies } from './commentTypes'
package/src/renderMdx.tsx CHANGED
@@ -22,7 +22,9 @@ type MdxContent = ComponentType<{
22
22
  function makeImgComponent(basePath: string) {
23
23
  return function MdxImage({ src, alt, ...props }: ImgHTMLAttributes<HTMLImageElement>) {
24
24
  const resolvedSrc =
25
- src && !src.startsWith('http') && !src.startsWith('/') ? `${basePath}/${src}` : src
25
+ typeof src === 'string' && !src.startsWith('http') && !src.startsWith('/')
26
+ ? `${basePath}/${src}`
27
+ : src
26
28
  return React.createElement('img', { src: resolvedSrc, alt, ...props })
27
29
  }
28
30
  }