@fullstackdatasolutions/articles 0.9.0 → 0.11.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 (48) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/README.md +226 -29
  3. package/dist/index.cjs +635 -274
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +165 -56
  6. package/dist/index.d.ts +165 -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 +72 -0
  12. package/dist/nextjs.d.ts +72 -0
  13. package/dist/nextjs.js +40 -5
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +280 -16
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +92 -3
  18. package/dist/server.d.ts +92 -3
  19. package/dist/server.js +269 -16
  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__/articlesConfig.test.ts +20 -1
  36. package/src/__tests__/authorUtils.test.ts +89 -0
  37. package/src/__tests__/renderMdx.test.tsx +113 -0
  38. package/src/__tests__/seoUtils-authors.test.ts +160 -0
  39. package/src/__tests__/seoUtils.test.ts +4 -0
  40. package/src/__tests__/server-articles.test.ts +159 -2
  41. package/src/articleTypes.ts +33 -0
  42. package/src/articlesConfig.ts +68 -0
  43. package/src/authorUtils.ts +95 -0
  44. package/src/index.ts +32 -9
  45. package/src/renderMdx.tsx +8 -2
  46. package/src/seoUtils.ts +226 -7
  47. package/src/server-articles.ts +98 -10
  48. 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,6 @@
1
+ import type { ComponentType } from 'react'
2
+ import type { AuthorProfile } from './articleTypes'
3
+
1
4
  /** Keys for each renderable section of the articles listing page. */
2
5
  export type ArticlesSection =
3
6
  | 'hero'
@@ -70,6 +73,52 @@ export interface HeroConfig {
70
73
  /** Controls how article body links set target/rel attributes. */
71
74
  export type LinkTargetStrategy = 'external-new-tab' | 'all-new-tab' | 'same-tab'
72
75
 
76
+ /** React components that article MDX bodies can reference by JSX tag name. */
77
+ export type MdxComponents = Record<string, ComponentType<never>>
78
+
79
+ export type ArticleBreadcrumbToken =
80
+ | 'home'
81
+ | 'articles'
82
+ | 'primaryCategory'
83
+ | 'folderPath'
84
+ | 'articleTitle'
85
+ export type CategoryBreadcrumbToken = 'home' | 'articles' | 'category'
86
+ export type AuthorBreadcrumbToken = 'home' | 'articles' | 'authors' | 'authorName'
87
+
88
+ export interface CustomBreadcrumbItem {
89
+ /** Label displayed in the breadcrumb trail. */
90
+ name: string
91
+ /** Custom URL. Relative paths are resolved against `siteUrl` by server builders. */
92
+ url: string
93
+ }
94
+
95
+ export type ArticleBreadcrumbEntry = ArticleBreadcrumbToken | CustomBreadcrumbItem
96
+ export type CategoryBreadcrumbEntry = CategoryBreadcrumbToken | CustomBreadcrumbItem
97
+ export type AuthorBreadcrumbEntry = AuthorBreadcrumbToken | CustomBreadcrumbItem
98
+
99
+ export interface BreadcrumbLabels {
100
+ home?: string
101
+ articles?: string
102
+ authors?: string
103
+ }
104
+
105
+ export interface BreadcrumbsConfig {
106
+ /** Set to false to hide visible breadcrumbs and breadcrumb JSON-LD generated by the helper builders. */
107
+ show?: boolean
108
+ /** Separator used by the visible Breadcrumb component. Default: '>'. */
109
+ separator?: string
110
+ /** Set to false to render visible breadcrumbs without JSON-LD. Default: true. */
111
+ showSchema?: boolean
112
+ /** Article breadcrumb trail. Example: ['primaryCategory', { name: 'Guides', url: '/guides' }, 'articleTitle']. */
113
+ article?: ArticleBreadcrumbEntry[]
114
+ /** Category breadcrumb trail. Default: ['home', 'articles', 'category']. */
115
+ category?: CategoryBreadcrumbEntry[]
116
+ /** Author breadcrumb trail. Default: ['home', 'articles', 'authors', 'authorName']. */
117
+ author?: AuthorBreadcrumbEntry[]
118
+ /** Optional label overrides for built-in breadcrumb items. */
119
+ labels?: BreadcrumbLabels
120
+ }
121
+
73
122
  /** Top-level configuration object. Pass one instance to every library component. */
74
123
  export interface ArticlesConfig {
75
124
  /** Canonical base URL of the site, used in metadata and JSON-LD. Example: `'https://yoursite.com'` */
@@ -105,8 +154,18 @@ export interface ArticlesConfig {
105
154
  showBackToArticles?: boolean
106
155
  /** Set to false to hide author names from UI and metadata. Default: true. */
107
156
  showAuthor?: boolean
157
+ /** Author profiles keyed by slug. Omit to keep plain string author display. */
158
+ authors?: Record<string, AuthorProfile>
159
+ /** Author slug used when article frontmatter omits author fields. */
160
+ defaultAuthor?: string
161
+ /** Set to false to disable copied author page routes in consuming apps. Default: true. */
162
+ showAuthorPage?: boolean
163
+ /** Set to false to disable breadcrumbs, or pass a config object to customize breadcrumb trails. */
164
+ breadcrumbs?: false | BreadcrumbsConfig
108
165
  /** Article body link target behavior. Default: `'external-new-tab'`. */
109
166
  linkTargetStrategy?: LinkTargetStrategy
167
+ /** Extra components exposed to article MDX bodies by JSX tag name. */
168
+ mdxComponents?: MdxComponents
110
169
  }
111
170
 
112
171
  export const DEFAULT_PAGE_SIZE = 6
@@ -119,3 +178,12 @@ export const DEFAULT_LAYOUT: ArticlesSection[] = [
119
178
  'latest',
120
179
  'categories',
121
180
  ]
181
+
182
+ export function breadcrumbsAreEnabled(config: ArticlesConfig): boolean {
183
+ return config.breadcrumbs !== false && config.breadcrumbs?.show !== false
184
+ }
185
+
186
+ export function getBreadcrumbsConfig(config: ArticlesConfig): BreadcrumbsConfig {
187
+ if (config.breadcrumbs === false) return {}
188
+ return config.breadcrumbs ?? {}
189
+ }
@@ -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,32 @@ export type {
43
41
  CategoryDescription,
44
42
  CommentsConfig,
45
43
  LinkTargetStrategy,
44
+ MdxComponents,
45
+ BreadcrumbsConfig,
46
+ BreadcrumbLabels,
47
+ ArticleBreadcrumbToken,
48
+ CategoryBreadcrumbToken,
49
+ AuthorBreadcrumbToken,
50
+ ArticleBreadcrumbEntry,
51
+ CategoryBreadcrumbEntry,
52
+ AuthorBreadcrumbEntry,
53
+ CustomBreadcrumbItem,
54
+ } from './articlesConfig'
55
+ export {
56
+ DEFAULT_LAYOUT,
57
+ DEFAULT_PAGE_SIZE,
58
+ DEFAULT_CATEGORIES_PAGE_SIZE,
59
+ breadcrumbsAreEnabled,
60
+ getBreadcrumbsConfig,
46
61
  } 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'
62
+ export type {
63
+ Article,
64
+ AuthorProfile,
65
+ AuthorSocial,
66
+ BreadcrumbItem,
67
+ CategoryInfo,
68
+ FaqItem,
69
+ HowToStep,
70
+ TocItem,
71
+ } from './articleTypes'
49
72
  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
  }
@@ -43,6 +45,10 @@ export async function renderMdxSource(source: string, basePath?: string, config?
43
45
  })
44
46
 
45
47
  const Content = mdxModule.default as MdxContent
46
- const components = basePath ? { img: makeImgComponent(basePath) } : undefined
48
+ const internalComponents = basePath ? { img: makeImgComponent(basePath) } : undefined
49
+ const components =
50
+ internalComponents || config?.mdxComponents
51
+ ? { ...internalComponents, ...config?.mdxComponents }
52
+ : undefined
47
53
  return <Content components={components as Record<string, ComponentType<unknown>>} />
48
54
  }