@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
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 {
|
|
11
|
-
|
|
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:
|
|
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':
|
|
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([
|
|
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
|
-
|
|
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
|
}
|
package/src/server-articles.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
387
|
-
|
|
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 {
|
|
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,
|