@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
@@ -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,
@@ -197,37 +272,39 @@ async function getArticleSummary(slug: string): Promise<Article | null> {
197
272
  }
198
273
  }
199
274
 
200
- export const getArticleMetadata = cache(async (slug: string): Promise<Article | null> => {
201
- try {
202
- const summary = await getArticleSummary(slug)
203
- if (!summary) return null
204
- const found = findArticleFile(slug)
205
- if (!found) return null
206
- const fileContent = fs.readFileSync(found.filePath, 'utf8')
207
- const { content: markdownContent } = matter(fileContent)
208
- const toc = await extractToc(markdownContent)
209
- let htmlContent: string | undefined
210
- let mdxSource: string | undefined
211
- if (found.contentType === 'mdx') {
212
- mdxSource = markdownContent
213
- } else {
214
- htmlContent = await markdownToHtml(markdownContent, slug)
275
+ export const getArticleMetadata = cache(
276
+ async (slug: string, config?: ArticlesConfig): Promise<Article | null> => {
277
+ try {
278
+ const summary = await getArticleSummary(slug, config)
279
+ if (!summary) return null
280
+ const found = findArticleFile(slug)
281
+ if (!found) return null
282
+ const fileContent = fs.readFileSync(found.filePath, 'utf8')
283
+ const { content: markdownContent } = matter(fileContent)
284
+ const toc = await extractToc(markdownContent)
285
+ let htmlContent: string | undefined
286
+ let mdxSource: string | undefined
287
+ if (found.contentType === 'mdx') {
288
+ mdxSource = markdownContent
289
+ } else {
290
+ htmlContent = await markdownToHtml(markdownContent, slug, config)
291
+ }
292
+ return { ...summary, content: markdownContent, htmlContent, mdxSource, toc }
293
+ } catch (error) {
294
+ reportArticlesError({
295
+ code: 'article-load-failed',
296
+ message: 'Unable to load article metadata.',
297
+ error,
298
+ context: { slug },
299
+ })
300
+ return null
215
301
  }
216
- return { ...summary, content: markdownContent, htmlContent, mdxSource, toc }
217
- } catch (error) {
218
- reportArticlesError({
219
- code: 'article-load-failed',
220
- message: 'Unable to load article metadata.',
221
- error,
222
- context: { slug },
223
- })
224
- return null
225
302
  }
226
- })
303
+ )
227
304
 
228
- export const getAllArticles = cache(async (): Promise<Article[]> => {
305
+ export const getAllArticles = cache(async (config?: ArticlesConfig): Promise<Article[]> => {
229
306
  const slugs = getAvailableArticleSlugs()
230
- const articles = await Promise.all(slugs.map((slug) => getArticleSummary(slug)))
307
+ const articles = await Promise.all(slugs.map((slug) => getArticleSummary(slug, config)))
231
308
  const currentDate = new Date().toISOString().split('T')[0]
232
309
  return articles
233
310
  .filter((article): article is Article => article !== null)
@@ -337,8 +414,8 @@ export async function getAiRobotsTxtRules(): Promise<string> {
337
414
  }
338
415
 
339
416
  export async function searchArticles(query: string, config?: ArticlesConfig): Promise<Article[]> {
340
- if (!query?.trim()) return getAllArticles()
341
- const articles = await getAllArticles()
417
+ if (!query?.trim()) return getAllArticles(config)
418
+ const articles = await getAllArticles(config)
342
419
  const searchTerm = query.toLowerCase().trim()
343
420
  const includeAuthor = config?.showAuthor !== false
344
421
  return articles.filter((article) => {
@@ -381,11 +458,24 @@ export async function getAllCategories(): Promise<CategoryInfo[]> {
381
458
  .sort((a, b) => b.count - a.count)
382
459
  }
383
460
 
384
- export async function getArticlesByCategory(categorySlug: string): Promise<Article[]> {
385
- const articles = await getAllArticles()
461
+ export async function getArticlesByCategory(
462
+ categorySlug: string,
463
+ config?: ArticlesConfig
464
+ ): Promise<Article[]> {
465
+ const articles = await getAllArticles(config)
386
466
  return articles.filter((article) =>
387
467
  article.categories.some((cat) => categoryToSlug(cat) === categorySlug)
388
468
  )
389
469
  }
390
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
+
391
481
  export { sanitizeImagePath }
package/src/server.ts CHANGED
@@ -7,31 +7,50 @@ 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,
17
21
  } from './server-articles'
18
22
 
19
23
  export {
24
+ generateRssFeed,
20
25
  generateArticleStaticParams,
21
26
  generateCategoryStaticParams,
27
+ generateAuthorStaticParams,
22
28
  generateArticlesIndexMetadata,
23
29
  generateArticleMetadata,
24
30
  generateCategoryMetadata,
31
+ generateAuthorMetadata,
32
+ buildArticleBreadcrumbs,
33
+ buildCategoryBreadcrumbs,
34
+ buildAuthorBreadcrumbs,
35
+ resolveAuthorAvatar,
25
36
  getArticleSitemapEntries,
26
37
  } from './seoUtils'
27
38
 
28
39
  export { markdownToHtml, extractToc } from './markdown'
29
40
  export { setArticlesErrorHandler } from './errorReporting'
41
+ export { getBreadcrumbsConfig } from './articlesConfig'
30
42
  export { ArticleContent } from './ArticleContent'
31
43
  export { ArticleTOC } from './ArticleTOC'
32
44
 
33
- export type { Article, CategoryInfo, TocItem } from './articleTypes'
34
- export type { ArticlesConfig } from './articlesConfig'
45
+ export type {
46
+ Article,
47
+ AuthorProfile,
48
+ AuthorSocial,
49
+ BreadcrumbItem,
50
+ CategoryInfo,
51
+ TocItem,
52
+ } from './articleTypes'
53
+ export type { ArticlesConfig, LinkTargetStrategy } from './articlesConfig'
35
54
  export type {
36
55
  ArticlesErrorCode,
37
56
  ArticlesErrorContext,