@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.
- package/CHANGELOG.md +243 -0
- package/README.md +226 -29
- package/dist/index.cjs +635 -274
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +165 -56
- package/dist/index.d.ts +165 -56
- package/dist/index.js +614 -250
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +40 -5
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +72 -0
- package/dist/nextjs.d.ts +72 -0
- package/dist/nextjs.js +40 -5
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +280 -16
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +92 -3
- package/dist/server.d.ts +92 -3
- package/dist/server.js +269 -16
- package/dist/server.js.map +1 -1
- package/package.json +8 -5
- 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__/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__/articlesConfig.test.ts +20 -1
- package/src/__tests__/authorUtils.test.ts +89 -0
- package/src/__tests__/renderMdx.test.tsx +113 -0
- package/src/__tests__/seoUtils-authors.test.ts +160 -0
- package/src/__tests__/seoUtils.test.ts +4 -0
- package/src/__tests__/server-articles.test.ts +159 -2
- package/src/articleTypes.ts +33 -0
- package/src/articlesConfig.ts +68 -0
- package/src/authorUtils.ts +95 -0
- package/src/index.ts +32 -9
- package/src/renderMdx.tsx +8 -2
- package/src/seoUtils.ts +226 -7
- package/src/server-articles.ts +98 -10
- 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.\
|
|
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
|
-
|
|
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', () => {
|
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,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
|
-
|
|
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'
|
|
@@ -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 {
|
|
48
|
-
|
|
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('/')
|
|
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
|
|
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
|
}
|