@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.
- package/CHANGELOG.md +237 -0
- package/README.md +209 -78
- package/dist/index.cjs +635 -274
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +164 -56
- package/dist/index.d.ts +164 -56
- package/dist/index.js +614 -250
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +113 -38
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +71 -0
- package/dist/nextjs.d.ts +71 -0
- package/dist/nextjs.js +113 -38
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +394 -52
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +96 -6
- package/dist/server.d.ts +96 -6
- package/dist/server.js +382 -52
- package/dist/server.js.map +1 -1
- package/package.json +8 -5
- package/src/ArticleContent.tsx +8 -3
- 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__/ArticleContent.test.tsx +18 -2
- 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__/authorUtils.test.ts +89 -0
- package/src/__tests__/markdown.test.ts +79 -3
- package/src/__tests__/renderMdx.test.tsx +57 -0
- package/src/__tests__/seoUtils-authors.test.ts +160 -0
- package/src/__tests__/seoUtils.test.ts +106 -0
- package/src/__tests__/server-articles.test.ts +174 -3
- package/src/articleTypes.ts +33 -0
- package/src/articlesConfig.ts +67 -0
- package/src/authorUtils.ts +95 -0
- package/src/index.ts +32 -9
- package/src/markdown.ts +67 -8
- package/src/renderMdx.tsx +6 -3
- package/src/seoUtils.ts +279 -6
- package/src/server-articles.ts +124 -34
- package/src/server.ts +21 -2
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,
|
|
@@ -197,37 +272,39 @@ async function getArticleSummary(slug: string): Promise<Article | null> {
|
|
|
197
272
|
}
|
|
198
273
|
}
|
|
199
274
|
|
|
200
|
-
export const getArticleMetadata = cache(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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(
|
|
385
|
-
|
|
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 {
|
|
34
|
-
|
|
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,
|