@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
package/src/markdown.ts CHANGED
@@ -11,8 +11,67 @@ import remarkRehype from 'remark-rehype'
11
11
  import { Plugin } from 'unified'
12
12
  import { visit } from 'unist-util-visit'
13
13
  import type { TocItem } from './articleTypes'
14
+ import type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'
14
15
  import { reportArticlesError } from './errorReporting'
15
16
 
17
+ type LinkTargetOptions = Readonly<{
18
+ strategy?: LinkTargetStrategy
19
+ siteUrl?: string
20
+ }>
21
+
22
+ const DEFAULT_LINK_TARGET_STRATEGY: LinkTargetStrategy = 'external-new-tab'
23
+
24
+ function isNonBrowserNavigationLink(href: string): boolean {
25
+ return (
26
+ /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href) &&
27
+ !href.startsWith('http://') &&
28
+ !href.startsWith('https://')
29
+ )
30
+ }
31
+
32
+ function getOrigin(url: string | undefined): string | null {
33
+ if (!url) return null
34
+ try {
35
+ return new URL(url).origin
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ function isExternalHttpLink(href: string, siteUrl?: string): boolean {
42
+ if (!href.startsWith('http://') && !href.startsWith('https://')) return false
43
+ const siteOrigin = getOrigin(siteUrl)
44
+ if (!siteOrigin) return true
45
+ return getOrigin(href) !== siteOrigin
46
+ }
47
+
48
+ function shouldOpenInNewTab(href: string, options: LinkTargetOptions = {}): boolean {
49
+ if (!href || href.startsWith('#') || isNonBrowserNavigationLink(href)) return false
50
+
51
+ const strategy = options.strategy ?? DEFAULT_LINK_TARGET_STRATEGY
52
+ if (strategy === 'same-tab') return false
53
+ if (strategy === 'all-new-tab') return true
54
+ return isExternalHttpLink(href, options.siteUrl)
55
+ }
56
+
57
+ function applyLinkTarget(props: Record<string, unknown>, options?: LinkTargetOptions): void {
58
+ const href = typeof props.href === 'string' ? props.href : ''
59
+ if (shouldOpenInNewTab(href, options)) {
60
+ props.target = '_blank'
61
+ props.rel = 'noopener noreferrer'
62
+ return
63
+ }
64
+ delete props.target
65
+ delete props.rel
66
+ }
67
+
68
+ function getLinkTargetOptions(config?: ArticlesConfig): LinkTargetOptions {
69
+ return {
70
+ strategy: config?.linkTargetStrategy,
71
+ siteUrl: config?.siteUrl,
72
+ }
73
+ }
74
+
16
75
  // Import the sanitizeImagePath function
17
76
  function sanitizeImagePath(rawPath: string, articleSlug: string): string | null {
18
77
  if (!rawPath || typeof rawPath !== 'string') {
@@ -169,7 +228,7 @@ function processFootnotesSection(node: Element): void {
169
228
  if (node.properties) node.properties.className = undefined
170
229
  }
171
230
 
172
- export const customRenderer: Plugin<[], Root> = () => {
231
+ export const customRenderer: Plugin<[LinkTargetOptions?], Root> = (linkTargetOptions = {}) => {
173
232
  return (tree: Root) => {
174
233
  // First pass: Apply general styles
175
234
  visit(tree, 'element', (node: Element) => {
@@ -194,11 +253,7 @@ export const customRenderer: Plugin<[], Root> = () => {
194
253
  break
195
254
  case 'a': {
196
255
  props.className = 'text-primary hover:underline transition-colors duration-200'
197
- const href = typeof props.href === 'string' ? props.href : ''
198
- if (!href.startsWith('#')) {
199
- props.target = '_blank'
200
- props.rel = 'noopener noreferrer'
201
- }
256
+ applyLinkTarget(props, linkTargetOptions)
202
257
  break
203
258
  }
204
259
  case 'ul':
@@ -278,7 +333,11 @@ const rehypeProcessImages: Plugin<[{ articleSlug?: string }], Root> = (options =
278
333
  }
279
334
  }
280
335
 
281
- export async function markdownToHtml(markdown: string, articleSlug?: string) {
336
+ export async function markdownToHtml(
337
+ markdown: string,
338
+ articleSlug?: string,
339
+ config?: ArticlesConfig
340
+ ) {
282
341
  try {
283
342
  // Start building the remark processor
284
343
  let processor = remark()
@@ -286,7 +345,7 @@ export async function markdownToHtml(markdown: string, articleSlug?: string) {
286
345
  .use(remarkGfm)
287
346
  .use(remarkGithubBlockquoteAlert)
288
347
  .use(remarkRehype)
289
- .use(customRenderer)
348
+ .use(customRenderer, getLinkTargetOptions(config))
290
349
  .use(rehypeSlug)
291
350
  // @ts-ignore
292
351
  .use(rehypePrism)
package/src/renderMdx.tsx CHANGED
@@ -13,6 +13,7 @@ import rehypeSlug from 'rehype-slug'
13
13
  import remarkGfm from 'remark-gfm'
14
14
  import remarkGithubBlockquoteAlert from 'remark-github-blockquote-alert'
15
15
  import { customRenderer } from './markdown'
16
+ import type { ArticlesConfig } from './articlesConfig'
16
17
 
17
18
  type MdxContent = ComponentType<{
18
19
  components?: Record<string, ComponentType<unknown>>
@@ -21,12 +22,14 @@ type MdxContent = ComponentType<{
21
22
  function makeImgComponent(basePath: string) {
22
23
  return function MdxImage({ src, alt, ...props }: ImgHTMLAttributes<HTMLImageElement>) {
23
24
  const resolvedSrc =
24
- src && !src.startsWith('http') && !src.startsWith('/') ? `${basePath}/${src}` : src
25
+ typeof src === 'string' && !src.startsWith('http') && !src.startsWith('/')
26
+ ? `${basePath}/${src}`
27
+ : src
25
28
  return React.createElement('img', { src: resolvedSrc, alt, ...props })
26
29
  }
27
30
  }
28
31
 
29
- export async function renderMdxSource(source: string, basePath?: string) {
32
+ export async function renderMdxSource(source: string, basePath?: string, config?: ArticlesConfig) {
30
33
  const isDevelopment = process.env.NODE_ENV === 'development'
31
34
 
32
35
  const mdxModule = await evaluate(source, {
@@ -34,7 +37,7 @@ export async function renderMdxSource(source: string, basePath?: string) {
34
37
  development: isDevelopment,
35
38
  remarkPlugins: [remarkGfm, remarkGithubBlockquoteAlert],
36
39
  rehypePlugins: [
37
- customRenderer,
40
+ [customRenderer, { strategy: config?.linkTargetStrategy, siteUrl: config?.siteUrl }],
38
41
  rehypeSlug,
39
42
  // @ts-ignore
40
43
  rehypePrism,
package/src/seoUtils.ts CHANGED
@@ -2,12 +2,78 @@ 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'
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'
24
+
25
+ function escapeXml(str: string): string {
26
+ return str
27
+ .replaceAll('&', '&amp;')
28
+ .replaceAll('<', '&lt;')
29
+ .replaceAll('>', '&gt;')
30
+ .replaceAll('"', '&quot;')
31
+ .replaceAll("'", '&apos;')
32
+ }
33
+
34
+ export function generateRssFeed(articles: Article[], config: ArticlesConfig): string {
35
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
36
+ const showAuthor = config.showAuthor !== false
37
+
38
+ const items = articles
39
+ .map((article) => {
40
+ const url = `${siteUrl}/articles/${article.slug}`
41
+ const pubDate = article.date ? new Date(article.date).toUTCString() : ''
42
+ const imageUrl = article.featuredImage ? resolveImageUrl(article.featuredImage, siteUrl) : ''
43
+
44
+ return [
45
+ ' <item>',
46
+ ` <title><![CDATA[${article.title}]]></title>`,
47
+ ` <link>${url}</link>`,
48
+ ` <guid isPermaLink="true">${url}</guid>`,
49
+ pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
50
+ article.excerpt ? ` <description><![CDATA[${article.excerpt}]]></description>` : '',
51
+ showAuthor && article.author ? ` <author>${escapeXml(article.author)}</author>` : '',
52
+ article.category ? ` <category><![CDATA[${article.category}]]></category>` : '',
53
+ imageUrl
54
+ ? ` <media:content url="${imageUrl}" medium="image" width="1200" height="630"/>`
55
+ : '',
56
+ ' </item>',
57
+ ]
58
+ .filter(Boolean)
59
+ .join('\n')
60
+ })
61
+ .join('\n')
62
+
63
+ const description = config.description ?? `${config.siteName} articles`
64
+
65
+ return `<?xml version="1.0" encoding="UTF-8" ?>
66
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
67
+ <channel>
68
+ <title><![CDATA[${config.siteName}]]></title>
69
+ <link>${siteUrl}/articles</link>
70
+ <description><![CDATA[${description}]]></description>
71
+ <language>en</language>
72
+ <atom:link href="${siteUrl}/articles/feed.xml" rel="self" type="application/rss+xml" />
73
+ ${items}
74
+ </channel>
75
+ </rss>`
76
+ }
11
77
 
12
78
  export function generateArticleStaticParams(): { slug: string }[] {
13
79
  return getAvailableArticleSlugs().map((slug) => ({ slug }))
@@ -18,6 +84,11 @@ export async function generateCategoryStaticParams(): Promise<{ category: string
18
84
  return categories.map((cat) => ({ category: cat.slug }))
19
85
  }
20
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
+
21
92
  function resolveImageUrl(featuredImage: string, siteUrl: string): string {
22
93
  const base = siteUrl.replace(/\/$/, '')
23
94
  if (featuredImage.startsWith('http://') || featuredImage.startsWith('https://')) {
@@ -30,7 +101,7 @@ export async function generateArticleMetadata(
30
101
  slug: string,
31
102
  config: ArticlesConfig
32
103
  ): Promise<Metadata> {
33
- const article = await getArticleMetadata(slug)
104
+ const article = await getArticleMetadata(slug, config)
34
105
 
35
106
  if (!article) {
36
107
  return {
@@ -48,6 +119,7 @@ export async function generateArticleMetadata(
48
119
  const description = article.excerpt ?? `Read ${article.title} on ${config.siteName}.`
49
120
  const showAuthor = config.showAuthor !== false
50
121
  const markdownUrl = getArticleMarkdownUrl(article, config)
122
+ const authorNames = getArticleAuthors(article, config).map((author) => author.name)
51
123
 
52
124
  return {
53
125
  title: `${article.title} | ${config.siteName}`,
@@ -63,7 +135,7 @@ export async function generateArticleMetadata(
63
135
  type: 'article',
64
136
  ...(article.date && { publishedTime: article.date }),
65
137
  ...(article.lastmod && { modifiedTime: new Date(article.lastmod).toISOString() }),
66
- ...(showAuthor && { authors: [article.author] }),
138
+ ...(showAuthor && authorNames.length > 0 && { authors: authorNames }),
67
139
  tags: article.tags ?? [],
68
140
  },
69
141
  twitter: {
@@ -92,7 +164,7 @@ export async function generateArticleMetadata(
92
164
  },
93
165
  },
94
166
  other: {
95
- ...(showAuthor && { 'article:author': article.author }),
167
+ ...(showAuthor && authorNames.length > 0 && { 'article:author': authorNames.join(', ') }),
96
168
  ...(article.date && {
97
169
  'article:published_time': new Date(article.date).toISOString(),
98
170
  }),
@@ -198,6 +270,195 @@ export async function generateCategoryMetadata(
198
270
  }
199
271
  }
200
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
+
201
462
  export async function getArticleSitemapEntries(
202
463
  baseUrlOrConfig: string | ArticlesConfig
203
464
  ): Promise<MetadataRoute.Sitemap> {
@@ -206,7 +467,10 @@ export async function getArticleSitemapEntries(
206
467
  ).replace(/\/$/, '')
207
468
 
208
469
  try {
209
- 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
+ ])
210
474
 
211
475
  const articleEntries: MetadataRoute.Sitemap = articles.map((article) => {
212
476
  const dateStr = article.lastmod ?? article.date
@@ -226,7 +490,16 @@ export async function getArticleSitemapEntries(
226
490
  priority: 0.7,
227
491
  }))
228
492
 
229
- 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]
230
503
  } catch {
231
504
  return []
232
505
  }