@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
package/src/seoUtils.ts CHANGED
@@ -2,13 +2,25 @@ import type { Metadata, MetadataRoute } from 'next'
2
2
  import {
3
3
  getArticleMetadata,
4
4
  getAllArticles,
5
+ getAllAuthors,
5
6
  getAllCategories,
7
+ getArticleAuthors,
6
8
  getArticlesByCategory,
9
+ getAuthorBySlug,
7
10
  getArticleMarkdownUrl,
8
11
  getAvailableArticleSlugs,
12
+ categoryToSlug,
9
13
  } from './server-articles'
10
- import { ArticlesConfig } from './articlesConfig'
11
- import type { Article } from './articleTypes'
14
+ import {
15
+ breadcrumbsAreEnabled,
16
+ getBreadcrumbsConfig,
17
+ type ArticlesConfig,
18
+ type ArticleBreadcrumbEntry,
19
+ type AuthorBreadcrumbEntry,
20
+ type CategoryBreadcrumbEntry,
21
+ type CustomBreadcrumbItem,
22
+ } from './articlesConfig'
23
+ import type { Article, AuthorProfile, BreadcrumbItem } from './articleTypes'
12
24
 
13
25
  function escapeXml(str: string): string {
14
26
  return str
@@ -72,6 +84,11 @@ export async function generateCategoryStaticParams(): Promise<{ category: string
72
84
  return categories.map((cat) => ({ category: cat.slug }))
73
85
  }
74
86
 
87
+ export function generateAuthorStaticParams(config: ArticlesConfig): { author: string }[] {
88
+ if (config.showAuthorPage === false) return []
89
+ return getAllAuthors(config).map((author) => ({ author: author.slug }))
90
+ }
91
+
75
92
  function resolveImageUrl(featuredImage: string, siteUrl: string): string {
76
93
  const base = siteUrl.replace(/\/$/, '')
77
94
  if (featuredImage.startsWith('http://') || featuredImage.startsWith('https://')) {
@@ -84,7 +101,7 @@ export async function generateArticleMetadata(
84
101
  slug: string,
85
102
  config: ArticlesConfig
86
103
  ): Promise<Metadata> {
87
- const article = await getArticleMetadata(slug)
104
+ const article = await getArticleMetadata(slug, config)
88
105
 
89
106
  if (!article) {
90
107
  return {
@@ -102,6 +119,7 @@ export async function generateArticleMetadata(
102
119
  const description = article.excerpt ?? `Read ${article.title} on ${config.siteName}.`
103
120
  const showAuthor = config.showAuthor !== false
104
121
  const markdownUrl = getArticleMarkdownUrl(article, config)
122
+ const authorNames = getArticleAuthors(article, config).map((author) => author.name)
105
123
 
106
124
  return {
107
125
  title: `${article.title} | ${config.siteName}`,
@@ -117,7 +135,7 @@ export async function generateArticleMetadata(
117
135
  type: 'article',
118
136
  ...(article.date && { publishedTime: article.date }),
119
137
  ...(article.lastmod && { modifiedTime: new Date(article.lastmod).toISOString() }),
120
- ...(showAuthor && { authors: [article.author] }),
138
+ ...(showAuthor && authorNames.length > 0 && { authors: authorNames }),
121
139
  tags: article.tags ?? [],
122
140
  },
123
141
  twitter: {
@@ -146,7 +164,7 @@ export async function generateArticleMetadata(
146
164
  },
147
165
  },
148
166
  other: {
149
- ...(showAuthor && { 'article:author': article.author }),
167
+ ...(showAuthor && authorNames.length > 0 && { 'article:author': authorNames.join(', ') }),
150
168
  ...(article.date && {
151
169
  'article:published_time': new Date(article.date).toISOString(),
152
170
  }),
@@ -252,6 +270,195 @@ export async function generateCategoryMetadata(
252
270
  }
253
271
  }
254
272
 
273
+ export async function generateAuthorMetadata(
274
+ authorSlug: string,
275
+ config: ArticlesConfig
276
+ ): Promise<Metadata> {
277
+ const author = getAuthorBySlug(authorSlug, config)
278
+
279
+ if (!author || config.showAuthorPage === false) return { title: 'Author Not Found' }
280
+
281
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
282
+ const authorUrl = author.url ?? `${siteUrl}/articles/authors/${author.slug}`
283
+ const title = `${author.name} Articles | ${config.siteName}`
284
+
285
+ return {
286
+ title,
287
+ description: author.bio,
288
+ openGraph: {
289
+ title,
290
+ description: author.bio,
291
+ url: authorUrl,
292
+ siteName: config.siteName,
293
+ type: 'profile',
294
+ locale: 'en_US',
295
+ ...(author.avatar && { images: [{ url: resolveAuthorAvatar(author, config) }] }),
296
+ },
297
+ twitter: {
298
+ card: 'summary_large_image',
299
+ title,
300
+ description: author.bio,
301
+ ...(author.avatar && { images: [resolveAuthorAvatar(author, config)] }),
302
+ },
303
+ alternates: {
304
+ canonical: authorUrl,
305
+ },
306
+ robots: {
307
+ index: true,
308
+ follow: true,
309
+ },
310
+ }
311
+ }
312
+
313
+ function formatCategoryName(category: string): string {
314
+ return category
315
+ .split('-')
316
+ .filter(Boolean)
317
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
318
+ .join(' ')
319
+ }
320
+
321
+ export function buildArticleBreadcrumbs(
322
+ article: Pick<Article, 'slug' | 'title' | 'category'>,
323
+ config: ArticlesConfig
324
+ ): BreadcrumbItem[] {
325
+ if (!breadcrumbsAreEnabled(config)) return []
326
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
327
+ const breadcrumbConfig = getBreadcrumbsConfig(config)
328
+ const labels = breadcrumbConfig.labels ?? {}
329
+ const categorySlug = categoryToSlug(article.category)
330
+ const trail = breadcrumbConfig.article ?? ['home', 'articles', 'primaryCategory', 'articleTitle']
331
+ const folderSegments = article.slug.split('/').filter(Boolean).slice(0, -1)
332
+ return trail.flatMap((token): BreadcrumbItem[] =>
333
+ buildArticleBreadcrumbToken(token, {
334
+ article,
335
+ siteUrl,
336
+ categorySlug,
337
+ folderSegments,
338
+ labels,
339
+ })
340
+ )
341
+ }
342
+
343
+ export function buildCategoryBreadcrumbs(
344
+ category: string,
345
+ config: ArticlesConfig,
346
+ categoryName = formatCategoryName(category)
347
+ ): BreadcrumbItem[] {
348
+ if (!breadcrumbsAreEnabled(config)) return []
349
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
350
+ const breadcrumbConfig = getBreadcrumbsConfig(config)
351
+ const labels = breadcrumbConfig.labels ?? {}
352
+ const trail = breadcrumbConfig.category ?? ['home', 'articles', 'category']
353
+ return trail.flatMap((entry): BreadcrumbItem[] =>
354
+ buildCategoryBreadcrumbEntry(entry, { categoryName, siteUrl, labels })
355
+ )
356
+ }
357
+
358
+ export function buildAuthorBreadcrumbs(
359
+ author: AuthorProfile,
360
+ config: ArticlesConfig
361
+ ): BreadcrumbItem[] {
362
+ if (!breadcrumbsAreEnabled(config)) return []
363
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
364
+ const breadcrumbConfig = getBreadcrumbsConfig(config)
365
+ const labels = breadcrumbConfig.labels ?? {}
366
+ const trail = breadcrumbConfig.author ?? ['home', 'articles', 'authors', 'authorName']
367
+ return trail.flatMap((entry): BreadcrumbItem[] =>
368
+ buildAuthorBreadcrumbEntry(entry, { author, siteUrl, labels })
369
+ )
370
+ }
371
+
372
+ type BreadcrumbLabels = NonNullable<ReturnType<typeof getBreadcrumbsConfig>['labels']>
373
+
374
+ function isCustomBreadcrumbItem(entry: unknown): entry is CustomBreadcrumbItem {
375
+ return typeof entry === 'object' && entry !== null && 'name' in entry && 'url' in entry
376
+ }
377
+
378
+ function resolveCustomBreadcrumbItem(item: CustomBreadcrumbItem, siteUrl: string): BreadcrumbItem {
379
+ if (item.url.startsWith('/')) return { name: item.name, url: `${siteUrl}${item.url}` }
380
+ return { name: item.name, url: item.url }
381
+ }
382
+
383
+ function buildArticleBreadcrumbToken(
384
+ entry: ArticleBreadcrumbEntry,
385
+ context: Readonly<{
386
+ article: Pick<Article, 'slug' | 'title' | 'category'>
387
+ siteUrl: string
388
+ categorySlug: string
389
+ folderSegments: string[]
390
+ labels: BreadcrumbLabels
391
+ }>
392
+ ): BreadcrumbItem[] {
393
+ if (isCustomBreadcrumbItem(entry)) {
394
+ return [resolveCustomBreadcrumbItem(entry, context.siteUrl)]
395
+ }
396
+ if (entry === 'home') return [{ name: context.labels.home ?? 'Home', url: context.siteUrl }]
397
+ if (entry === 'articles') {
398
+ return [{ name: context.labels.articles ?? 'Articles', url: `${context.siteUrl}/articles` }]
399
+ }
400
+ if (entry === 'primaryCategory') {
401
+ return [
402
+ {
403
+ name: context.article.category,
404
+ url: `${context.siteUrl}/articles/category/${context.categorySlug}`,
405
+ },
406
+ ]
407
+ }
408
+ if (entry === 'folderPath') {
409
+ return context.folderSegments.map((segment, index) => ({
410
+ name: formatCategoryName(segment),
411
+ url: `${context.siteUrl}/articles/${context.folderSegments.slice(0, index + 1).join('/')}`,
412
+ }))
413
+ }
414
+ return [{ name: context.article.title }]
415
+ }
416
+
417
+ function buildCategoryBreadcrumbEntry(
418
+ entry: CategoryBreadcrumbEntry,
419
+ context: Readonly<{ categoryName: string; siteUrl: string; labels: BreadcrumbLabels }>
420
+ ): BreadcrumbItem[] {
421
+ if (isCustomBreadcrumbItem(entry)) {
422
+ return [resolveCustomBreadcrumbItem(entry, context.siteUrl)]
423
+ }
424
+ if (entry === 'home') return [{ name: context.labels.home ?? 'Home', url: context.siteUrl }]
425
+ if (entry === 'articles') {
426
+ return [{ name: context.labels.articles ?? 'Articles', url: `${context.siteUrl}/articles` }]
427
+ }
428
+ return [{ name: context.categoryName }]
429
+ }
430
+
431
+ function buildAuthorBreadcrumbEntry(
432
+ entry: AuthorBreadcrumbEntry,
433
+ context: Readonly<{ author: AuthorProfile; siteUrl: string; labels: BreadcrumbLabels }>
434
+ ): BreadcrumbItem[] {
435
+ if (isCustomBreadcrumbItem(entry)) {
436
+ return [resolveCustomBreadcrumbItem(entry, context.siteUrl)]
437
+ }
438
+ if (entry === 'home') return [{ name: context.labels.home ?? 'Home', url: context.siteUrl }]
439
+ if (entry === 'articles') {
440
+ return [{ name: context.labels.articles ?? 'Articles', url: `${context.siteUrl}/articles` }]
441
+ }
442
+ if (entry === 'authors') {
443
+ return [
444
+ {
445
+ name: context.labels.authors ?? 'Authors',
446
+ url: `${context.siteUrl}/articles/authors`,
447
+ },
448
+ ]
449
+ }
450
+ return [{ name: context.author.name }]
451
+ }
452
+
453
+ export function resolveAuthorAvatar(author: AuthorProfile, config: ArticlesConfig): string {
454
+ if (!author.avatar) return ''
455
+ if (author.avatar.startsWith('http://') || author.avatar.startsWith('https://')) {
456
+ return author.avatar
457
+ }
458
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
459
+ return `${siteUrl}/articles/authors/${author.slug}/${author.avatar.replace(/^\/+/, '')}`
460
+ }
461
+
255
462
  export async function getArticleSitemapEntries(
256
463
  baseUrlOrConfig: string | ArticlesConfig
257
464
  ): Promise<MetadataRoute.Sitemap> {
@@ -260,7 +467,10 @@ export async function getArticleSitemapEntries(
260
467
  ).replace(/\/$/, '')
261
468
 
262
469
  try {
263
- const [articles, categories] = await Promise.all([getAllArticles(), getAllCategories()])
470
+ const [articles, categories] = await Promise.all([
471
+ getAllArticles(typeof baseUrlOrConfig === 'string' ? undefined : baseUrlOrConfig),
472
+ getAllCategories(),
473
+ ])
264
474
 
265
475
  const articleEntries: MetadataRoute.Sitemap = articles.map((article) => {
266
476
  const dateStr = article.lastmod ?? article.date
@@ -280,7 +490,16 @@ export async function getArticleSitemapEntries(
280
490
  priority: 0.7,
281
491
  }))
282
492
 
283
- return [...articleEntries, ...categoryEntries]
493
+ const authorEntries: MetadataRoute.Sitemap =
494
+ typeof baseUrlOrConfig === 'string' || baseUrlOrConfig.showAuthorPage === false
495
+ ? []
496
+ : getAllAuthors(baseUrlOrConfig).map((author) => ({
497
+ url: `${baseUrl}/articles/authors/${author.slug}`,
498
+ changeFrequency: 'monthly' as const,
499
+ priority: 0.6,
500
+ }))
501
+
502
+ return [...articleEntries, ...categoryEntries, ...authorEntries]
284
503
  } catch {
285
504
  return []
286
505
  }
@@ -4,7 +4,7 @@ import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import readingTime from 'reading-time'
6
6
  import { markdownToHtml, extractToc } from './markdown'
7
- import type { Article, CategoryInfo, FaqItem, HowToStep } from './articleTypes'
7
+ import type { Article, AuthorProfile, CategoryInfo, FaqItem, HowToStep } from './articleTypes'
8
8
  import type { ArticlesConfig } from './articlesConfig'
9
9
  import { reportArticlesError } from './errorReporting'
10
10
 
@@ -153,7 +153,81 @@ function parseHowToSteps(raw: unknown): HowToStep[] | undefined {
153
153
  return steps.length ? steps : undefined
154
154
  }
155
155
 
156
- async function getArticleSummary(slug: string): Promise<Article | null> {
156
+ function parseAuthors(raw: unknown): string[] | undefined {
157
+ if (!Array.isArray(raw)) return undefined
158
+ const authors = raw.filter(
159
+ (author): author is string => typeof author === 'string' && author.trim().length > 0
160
+ )
161
+ return authors
162
+ }
163
+
164
+ export function getAuthorBySlug(slug: string, config: ArticlesConfig): AuthorProfile | null {
165
+ const profile = config.authors?.[slug]
166
+ if (!profile) return null
167
+ return {
168
+ ...profile,
169
+ url: profile.url ?? `${config.siteUrl.replace(/\/$/, '')}/articles/authors/${profile.slug}`,
170
+ }
171
+ }
172
+
173
+ function getConfiguredAuthorByName(name: string, config: ArticlesConfig): AuthorProfile | null {
174
+ const normalizedName = name.trim().toLowerCase()
175
+ const profile = Object.values(config.authors ?? {}).find(
176
+ (author) => author.name.toLowerCase() === normalizedName
177
+ )
178
+ return profile ? getAuthorBySlug(profile.slug, config) : null
179
+ }
180
+
181
+ function resolveArticleAuthorName(
182
+ rawAuthor: unknown,
183
+ rawAuthors: unknown,
184
+ config?: ArticlesConfig
185
+ ): string {
186
+ const authorArray = parseAuthors(rawAuthors)
187
+ const firstAuthor = authorArray?.[0]
188
+ const rawAuthorValue = typeof rawAuthor === 'string' && rawAuthor.trim() ? rawAuthor : undefined
189
+ const author = firstAuthor ?? rawAuthorValue
190
+ const resolved = author ?? config?.defaultAuthor
191
+ if (!resolved) return ''
192
+ if (!config) return resolved
193
+ return (
194
+ getAuthorBySlug(resolved, config)?.name ??
195
+ getConfiguredAuthorByName(resolved, config)?.name ??
196
+ resolved
197
+ )
198
+ }
199
+
200
+ export function getArticleAuthors(article: Article, config: ArticlesConfig): AuthorProfile[] {
201
+ const fallbackAuthors = Array.from(
202
+ new Set(
203
+ [article.author, config.defaultAuthor].filter(
204
+ (author): author is string => typeof author === 'string' && author.trim().length > 0
205
+ )
206
+ )
207
+ )
208
+ const authorValues = article.authors ?? fallbackAuthors
209
+ if (authorValues.length === 0) return []
210
+ const resolvedAuthors = authorValues
211
+ .map((author) => getAuthorBySlug(author, config) ?? getConfiguredAuthorByName(author, config))
212
+ .filter((author): author is AuthorProfile => author !== null)
213
+ .filter((author, index, all) => all.findIndex((a) => a.slug === author.slug) === index)
214
+
215
+ if (resolvedAuthors.length > 0) return resolvedAuthors
216
+
217
+ return authorValues.map((fallbackName) => ({
218
+ name: fallbackName,
219
+ slug: categoryToSlug(fallbackName),
220
+ bio: '',
221
+ }))
222
+ }
223
+
224
+ export function getAllAuthors(config: ArticlesConfig): AuthorProfile[] {
225
+ return Object.keys(config.authors ?? {})
226
+ .map((slug) => getAuthorBySlug(slug, config))
227
+ .filter((author): author is AuthorProfile => author !== null)
228
+ }
229
+
230
+ async function getArticleSummary(slug: string, config?: ArticlesConfig): Promise<Article | null> {
157
231
  try {
158
232
  const found = findArticleFile(slug)
159
233
  if (!found) return null
@@ -171,7 +245,8 @@ async function getArticleSummary(slug: string): Promise<Article | null> {
171
245
  excerpt: data.excerpt || '',
172
246
  date: parseDateField(data.date),
173
247
  lastmod: parseDateField(data.lastmod),
174
- author: data.author || 'Andrew Blase',
248
+ author: resolveArticleAuthorName(data.author, data.authors, config),
249
+ authors: parseAuthors(data.authors),
175
250
  category: categories[0],
176
251
  categories,
177
252
  readTime,
@@ -200,7 +275,7 @@ async function getArticleSummary(slug: string): Promise<Article | null> {
200
275
  export const getArticleMetadata = cache(
201
276
  async (slug: string, config?: ArticlesConfig): Promise<Article | null> => {
202
277
  try {
203
- const summary = await getArticleSummary(slug)
278
+ const summary = await getArticleSummary(slug, config)
204
279
  if (!summary) return null
205
280
  const found = findArticleFile(slug)
206
281
  if (!found) return null
@@ -227,9 +302,9 @@ export const getArticleMetadata = cache(
227
302
  }
228
303
  )
229
304
 
230
- export const getAllArticles = cache(async (): Promise<Article[]> => {
305
+ export const getAllArticles = cache(async (config?: ArticlesConfig): Promise<Article[]> => {
231
306
  const slugs = getAvailableArticleSlugs()
232
- const articles = await Promise.all(slugs.map((slug) => getArticleSummary(slug)))
307
+ const articles = await Promise.all(slugs.map((slug) => getArticleSummary(slug, config)))
233
308
  const currentDate = new Date().toISOString().split('T')[0]
234
309
  return articles
235
310
  .filter((article): article is Article => article !== null)
@@ -339,8 +414,8 @@ export async function getAiRobotsTxtRules(): Promise<string> {
339
414
  }
340
415
 
341
416
  export async function searchArticles(query: string, config?: ArticlesConfig): Promise<Article[]> {
342
- if (!query?.trim()) return getAllArticles()
343
- const articles = await getAllArticles()
417
+ if (!query?.trim()) return getAllArticles(config)
418
+ const articles = await getAllArticles(config)
344
419
  const searchTerm = query.toLowerCase().trim()
345
420
  const includeAuthor = config?.showAuthor !== false
346
421
  return articles.filter((article) => {
@@ -383,11 +458,24 @@ export async function getAllCategories(): Promise<CategoryInfo[]> {
383
458
  .sort((a, b) => b.count - a.count)
384
459
  }
385
460
 
386
- export async function getArticlesByCategory(categorySlug: string): Promise<Article[]> {
387
- const articles = await getAllArticles()
461
+ export async function getArticlesByCategory(
462
+ categorySlug: string,
463
+ config?: ArticlesConfig
464
+ ): Promise<Article[]> {
465
+ const articles = await getAllArticles(config)
388
466
  return articles.filter((article) =>
389
467
  article.categories.some((cat) => categoryToSlug(cat) === categorySlug)
390
468
  )
391
469
  }
392
470
 
471
+ export async function getArticlesByAuthor(
472
+ authorSlug: string,
473
+ config: ArticlesConfig
474
+ ): Promise<Article[]> {
475
+ const articles = await getAllArticles(config)
476
+ return articles.filter((article) =>
477
+ getArticleAuthors(article, config).some((author) => author.slug === authorSlug)
478
+ )
479
+ }
480
+
393
481
  export { sanitizeImagePath }
package/src/server.ts CHANGED
@@ -7,10 +7,14 @@ export {
7
7
  getArticleMarkdownResponse,
8
8
  getArticleMarkdownUrl,
9
9
  getArticleMetadata,
10
+ getArticleAuthors,
10
11
  getAvailableArticleSlugs,
11
12
  getAdjacentArticles,
13
+ getAllAuthors,
12
14
  searchArticles,
15
+ getAuthorBySlug,
13
16
  getAllCategories,
17
+ getArticlesByAuthor,
14
18
  getArticlesByCategory,
15
19
  categoryToSlug,
16
20
  sanitizeImagePath,
@@ -20,18 +24,32 @@ export {
20
24
  generateRssFeed,
21
25
  generateArticleStaticParams,
22
26
  generateCategoryStaticParams,
27
+ generateAuthorStaticParams,
23
28
  generateArticlesIndexMetadata,
24
29
  generateArticleMetadata,
25
30
  generateCategoryMetadata,
31
+ generateAuthorMetadata,
32
+ buildArticleBreadcrumbs,
33
+ buildCategoryBreadcrumbs,
34
+ buildAuthorBreadcrumbs,
35
+ resolveAuthorAvatar,
26
36
  getArticleSitemapEntries,
27
37
  } from './seoUtils'
28
38
 
29
39
  export { markdownToHtml, extractToc } from './markdown'
30
40
  export { setArticlesErrorHandler } from './errorReporting'
41
+ export { getBreadcrumbsConfig } from './articlesConfig'
31
42
  export { ArticleContent } from './ArticleContent'
32
43
  export { ArticleTOC } from './ArticleTOC'
33
44
 
34
- export type { Article, CategoryInfo, TocItem } from './articleTypes'
45
+ export type {
46
+ Article,
47
+ AuthorProfile,
48
+ AuthorSocial,
49
+ BreadcrumbItem,
50
+ CategoryInfo,
51
+ TocItem,
52
+ } from './articleTypes'
35
53
  export type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'
36
54
  export type {
37
55
  ArticlesErrorCode,