@fullstackdatasolutions/articles 1.2.3 → 1.3.1
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 +46 -0
- package/README.md +313 -1
- package/dist/index.cjs +308 -79
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +267 -16
- package/dist/index.d.ts +267 -16
- package/dist/index.js +300 -79
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +325 -31
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +179 -2
- package/dist/nextjs.d.ts +179 -2
- package/dist/nextjs.js +325 -31
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +660 -50
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +333 -12
- package/dist/server.d.ts +333 -12
- package/dist/server.js +645 -50
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleAnswer.tsx +35 -0
- package/src/ArticleSchemas.tsx +263 -23
- package/src/AuthorArticlesPage.tsx +38 -8
- package/src/__tests__/ArticleAnswer.test.tsx +25 -0
- package/src/__tests__/ArticleSchemas.test.tsx +516 -0
- package/src/__tests__/AuthorArticlesPage.test.tsx +76 -0
- package/src/__tests__/authorUtils.test.ts +50 -0
- package/src/__tests__/markdown.test.ts +77 -1
- package/src/__tests__/nextjs.test.ts +31 -15
- package/src/__tests__/seoUtils.test.ts +279 -0
- package/src/__tests__/server-articles.test.ts +434 -1
- package/src/__tests__/validateArticles.test.ts +167 -6
- package/src/articleTypes.ts +57 -0
- package/src/articlesConfig.ts +176 -1
- package/src/authorUtils.ts +19 -1
- package/src/errorReporting.ts +1 -0
- package/src/index.ts +17 -1
- package/src/markdown.ts +100 -1
- package/src/nextjs.ts +7 -4
- package/src/seoUtils.ts +247 -26
- package/src/server-articles.ts +385 -25
- package/src/server.ts +35 -4
- package/src/validateArticles.ts +157 -12
package/src/seoUtils.ts
CHANGED
|
@@ -8,20 +8,24 @@ import {
|
|
|
8
8
|
getArticlesByCategory,
|
|
9
9
|
getArticlesBySeries,
|
|
10
10
|
getAuthorBySlug,
|
|
11
|
+
getArticleMarkdown,
|
|
11
12
|
getArticleMarkdownUrl,
|
|
12
13
|
getAvailableArticleSlugs,
|
|
14
|
+
buildMarkdownTwinHeader,
|
|
13
15
|
categoryToSlug,
|
|
14
16
|
} from './server-articles'
|
|
15
17
|
import {
|
|
16
18
|
breadcrumbsAreEnabled,
|
|
19
|
+
formatPageTitle,
|
|
17
20
|
getBreadcrumbsConfig,
|
|
21
|
+
DEFAULT_PAGE_SIZE,
|
|
18
22
|
type ArticlesConfig,
|
|
19
23
|
type ArticleBreadcrumbEntry,
|
|
20
24
|
type AuthorBreadcrumbEntry,
|
|
21
25
|
type CategoryBreadcrumbEntry,
|
|
22
26
|
type CustomBreadcrumbItem,
|
|
23
27
|
} from './articlesConfig'
|
|
24
|
-
import { buildPaginationLinks } from './pagination'
|
|
28
|
+
import { buildPageUrl, buildPaginationLinks, getTotalPages } from './pagination'
|
|
25
29
|
import type { Article, AuthorProfile, BreadcrumbItem } from './articleTypes'
|
|
26
30
|
|
|
27
31
|
function escapeXml(str: string): string {
|
|
@@ -33,9 +37,22 @@ function escapeXml(str: string): string {
|
|
|
33
37
|
.replaceAll("'", ''')
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
|
|
40
|
+
/**
|
|
41
|
+
* `options.fullContent` adds `<content:encoded>` with each article's rendered
|
|
42
|
+
* HTML, for feeds meant to be ingested rather than previewed - an
|
|
43
|
+
* excerpt-only feed gives a consumer nothing to work with. Off by default:
|
|
44
|
+
* it requires articles loaded with `htmlContent` (i.e. via
|
|
45
|
+
* `getArticleMetadata`, not `getAllArticles`' summaries), and articles
|
|
46
|
+
* without it are simply emitted without the element.
|
|
47
|
+
*/
|
|
48
|
+
export function generateRssFeed(
|
|
49
|
+
articles: Article[],
|
|
50
|
+
config: ArticlesConfig,
|
|
51
|
+
options?: Readonly<{ fullContent?: boolean }>
|
|
52
|
+
): string {
|
|
37
53
|
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
38
54
|
const showAuthor = config.showAuthor !== false
|
|
55
|
+
const fullContent = options?.fullContent === true
|
|
39
56
|
|
|
40
57
|
const items = articles
|
|
41
58
|
.map((article) => {
|
|
@@ -50,6 +67,9 @@ export function generateRssFeed(articles: Article[], config: ArticlesConfig): st
|
|
|
50
67
|
` <guid isPermaLink="true">${url}</guid>`,
|
|
51
68
|
pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
|
|
52
69
|
article.excerpt ? ` <description><![CDATA[${article.excerpt}]]></description>` : '',
|
|
70
|
+
fullContent && article.htmlContent
|
|
71
|
+
? ` <content:encoded><![CDATA[${article.htmlContent}]]></content:encoded>`
|
|
72
|
+
: '',
|
|
53
73
|
showAuthor && article.author ? ` <author>${escapeXml(article.author)}</author>` : '',
|
|
54
74
|
article.category ? ` <category><![CDATA[${article.category}]]></category>` : '',
|
|
55
75
|
imageUrl
|
|
@@ -65,18 +85,116 @@ export function generateRssFeed(articles: Article[], config: ArticlesConfig): st
|
|
|
65
85
|
const description = config.description ?? `${config.siteName} articles`
|
|
66
86
|
|
|
67
87
|
return `<?xml version="1.0" encoding="UTF-8" ?>
|
|
68
|
-
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
|
|
88
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
|
69
89
|
<channel>
|
|
70
90
|
<title><![CDATA[${config.siteName}]]></title>
|
|
71
91
|
<link>${siteUrl}/articles</link>
|
|
72
92
|
<description><![CDATA[${description}]]></description>
|
|
73
|
-
<language
|
|
93
|
+
<language>${config.language ?? 'en'}</language>
|
|
74
94
|
<atom:link href="${siteUrl}/articles/feed.xml" rel="self" type="application/rss+xml" />
|
|
75
95
|
${items}
|
|
76
96
|
</channel>
|
|
77
97
|
</rss>`
|
|
78
98
|
}
|
|
79
99
|
|
|
100
|
+
/** Shared `# name` / `> summary` preamble for both llms.txt variants. */
|
|
101
|
+
function buildLlmsHeader(config: ArticlesConfig): string[] {
|
|
102
|
+
const summary = config.description ?? `${config.siteName} articles`
|
|
103
|
+
return [`# ${config.siteName}`, '', `> ${summary}`, '']
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `llms.txt` index - the emerging convention for pointing an LLM at a site's
|
|
108
|
+
* canonical, markdown-native content (https://llmstxt.org). Lists only
|
|
109
|
+
* articles opted in via `aiCrawl` (see `ArticlesConfig.aiCrawlDefault`),
|
|
110
|
+
* grouped by category, linking to each article's `.md` twin rather than its
|
|
111
|
+
* HTML page.
|
|
112
|
+
*
|
|
113
|
+
* Wire it up in the consuming app as `app/llms.txt/route.ts`:
|
|
114
|
+
* export async function GET() {
|
|
115
|
+
* return new Response(generateLlmsTxt(await getAllArticles(siteConfig), siteConfig), {
|
|
116
|
+
* headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
117
|
+
* })
|
|
118
|
+
* }
|
|
119
|
+
*/
|
|
120
|
+
export function generateLlmsTxt(articles: Article[], config: ArticlesConfig): string {
|
|
121
|
+
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
122
|
+
const crawlable = articles.filter((article) => article.aiCrawl === true)
|
|
123
|
+
|
|
124
|
+
const byCategory = new Map<string, Article[]>()
|
|
125
|
+
for (const article of crawlable) {
|
|
126
|
+
const category = article.category || 'Articles'
|
|
127
|
+
const existing = byCategory.get(category)
|
|
128
|
+
if (existing) existing.push(article)
|
|
129
|
+
else byCategory.set(category, [article])
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const sections = [...byCategory.entries()].map(([category, categoryArticles]) => {
|
|
133
|
+
const lines = categoryArticles.map((article) => {
|
|
134
|
+
const url = `${siteUrl}/articles/${article.slug}.md`
|
|
135
|
+
const summary = article.excerpt ? `: ${article.excerpt}` : ''
|
|
136
|
+
return `- [${article.title}](${url})${summary}`
|
|
137
|
+
})
|
|
138
|
+
return [`## ${category}`, '', ...lines].join('\n')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
return [
|
|
142
|
+
...buildLlmsHeader(config),
|
|
143
|
+
...(sections.length > 0 ? sections : ['## Articles', '', '_No articles available._']),
|
|
144
|
+
...buildLlmsListingSection(crawlable, config),
|
|
145
|
+
'',
|
|
146
|
+
].join('\n')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Links the category, author, and series twins alongside the article list.
|
|
151
|
+
* Without them a model gets a flat bag of pages; these are the surfaces that
|
|
152
|
+
* answer "what does this site cover, and who writes it".
|
|
153
|
+
*/
|
|
154
|
+
function buildLlmsListingSection(articles: Article[], config: ArticlesConfig): string[] {
|
|
155
|
+
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
156
|
+
const categories = [...new Set(articles.flatMap((a) => a.categories ?? []))].filter(Boolean)
|
|
157
|
+
const series = [...new Set(articles.map((a) => a.seriesSlug).filter(Boolean))] as string[]
|
|
158
|
+
const authors = Object.values(config.authors ?? {})
|
|
159
|
+
|
|
160
|
+
const lines = [
|
|
161
|
+
...categories.map(
|
|
162
|
+
(name) => `- [${name}](${siteUrl}/articles/category/${categoryToSlug(name)}.md)`
|
|
163
|
+
),
|
|
164
|
+
...(config.showAuthorPage === false
|
|
165
|
+
? []
|
|
166
|
+
: authors.map((a) => `- [${a.name}](${siteUrl}/articles/authors/${a.slug}.md)`)),
|
|
167
|
+
...series.map((slug) => `- [${slug}](${siteUrl}/articles/series/${slug}.md)`),
|
|
168
|
+
]
|
|
169
|
+
if (lines.length === 0) return []
|
|
170
|
+
return ['## Collections', '', ...lines]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* `llms-full.txt` - every opted-in article's full markdown twin, headers
|
|
175
|
+
* included, concatenated into one document. Larger and slower to build than
|
|
176
|
+
* `generateLlmsTxt`; generate it in a route handler or at build time, not on
|
|
177
|
+
* every request.
|
|
178
|
+
*/
|
|
179
|
+
export async function generateLlmsFullTxt(
|
|
180
|
+
articles: Article[],
|
|
181
|
+
config: ArticlesConfig
|
|
182
|
+
): Promise<string> {
|
|
183
|
+
const crawlable = articles.filter((article) => article.aiCrawl === true)
|
|
184
|
+
const documents = await Promise.all(
|
|
185
|
+
crawlable.map(async (article) => {
|
|
186
|
+
const body = await getArticleMarkdown(article.slug, config)
|
|
187
|
+
if (body === null) return null
|
|
188
|
+
return `${buildMarkdownTwinHeader(article, config, body)}${body.trimStart()}`
|
|
189
|
+
})
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return [
|
|
193
|
+
...buildLlmsHeader(config),
|
|
194
|
+
...documents.filter((doc): doc is string => doc !== null),
|
|
195
|
+
].join('\n')
|
|
196
|
+
}
|
|
197
|
+
|
|
80
198
|
export function generateArticleStaticParams(): { slug: string }[] {
|
|
81
199
|
return getAvailableArticleSlugs().map((slug) => ({ slug }))
|
|
82
200
|
}
|
|
@@ -169,7 +287,7 @@ export async function generateArticleMetadata(
|
|
|
169
287
|
const authorNames = getArticleAuthors(article, config).map((author) => author.name)
|
|
170
288
|
|
|
171
289
|
return {
|
|
172
|
-
title:
|
|
290
|
+
title: formatPageTitle(search.title, config),
|
|
173
291
|
description,
|
|
174
292
|
keywords: [...(article.tags ?? []).map((tag) => tag.toLowerCase())].join(', '),
|
|
175
293
|
openGraph: {
|
|
@@ -228,7 +346,7 @@ export async function generateArticleMetadata(
|
|
|
228
346
|
export function generateArticlesIndexMetadata(config: ArticlesConfig): Metadata {
|
|
229
347
|
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
230
348
|
const indexUrl = `${siteUrl}/articles`
|
|
231
|
-
const title =
|
|
349
|
+
const title = formatPageTitle('Articles', config)
|
|
232
350
|
const description =
|
|
233
351
|
config.hero?.description ?? `Expert analysis and insights from ${config.siteName}.`
|
|
234
352
|
return {
|
|
@@ -282,7 +400,7 @@ export async function generateCategoryMetadata(
|
|
|
282
400
|
const fallback = `Browse ${articles.length} article${articles.length === 1 ? '' : 's'} in the ${categoryName} category.`
|
|
283
401
|
const description = typeof raw === 'string' ? raw : (raw?.short ?? fallback)
|
|
284
402
|
|
|
285
|
-
const title = `${categoryName} Articles
|
|
403
|
+
const title = formatPageTitle(`${categoryName} Articles`, config)
|
|
286
404
|
return {
|
|
287
405
|
title,
|
|
288
406
|
description,
|
|
@@ -330,7 +448,7 @@ export async function generateSeriesMetadata(
|
|
|
330
448
|
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
331
449
|
const seriesUrl = `${siteUrl}/articles/series/${seriesSlug}`
|
|
332
450
|
const description = `Follow the ${seriesName} series - ${articles.length} article${articles.length === 1 ? '' : 's'} on ${config.siteName}.`
|
|
333
|
-
const title = `${seriesName} Series
|
|
451
|
+
const title = formatPageTitle(`${seriesName} Series`, config)
|
|
334
452
|
|
|
335
453
|
return {
|
|
336
454
|
title,
|
|
@@ -376,7 +494,7 @@ export async function generateAuthorMetadata(
|
|
|
376
494
|
|
|
377
495
|
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
378
496
|
const authorUrl = author.url ?? `${siteUrl}/articles/authors/${author.slug}`
|
|
379
|
-
const title = `${author.name} Articles
|
|
497
|
+
const title = formatPageTitle(`${author.name} Articles`, config)
|
|
380
498
|
|
|
381
499
|
return {
|
|
382
500
|
title,
|
|
@@ -646,18 +764,53 @@ export function resolveAuthorAvatar(author: AuthorProfile, config: ArticlesConfi
|
|
|
646
764
|
return `${siteUrl}/articles/authors/${author.slug}/${author.avatar.replace(/^\/+/, '')}`
|
|
647
765
|
}
|
|
648
766
|
|
|
767
|
+
/** Newest `lastmod`/`date` across a set of articles, or `undefined` if none carry one. */
|
|
768
|
+
function newestArticleDate(articles: readonly Article[]): Date | undefined {
|
|
769
|
+
let newest: Date | undefined
|
|
770
|
+
for (const article of articles) {
|
|
771
|
+
const stamp = article.lastmod ?? article.date
|
|
772
|
+
if (!stamp) continue
|
|
773
|
+
const parsed = new Date(stamp)
|
|
774
|
+
if (Number.isNaN(parsed.getTime())) continue
|
|
775
|
+
if (!newest || parsed > newest) newest = parsed
|
|
776
|
+
}
|
|
777
|
+
return newest
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Paginated listing routes, emitted only in `listingPagination: 'pages'` mode.
|
|
782
|
+
* Page 1 is the listing URL itself, already emitted by the caller, so this
|
|
783
|
+
* starts at page 2. Without these, the paginated routes exist and carry
|
|
784
|
+
* correct canonical/prev/next metadata but appear in no sitemap.
|
|
785
|
+
*/
|
|
786
|
+
function paginationEntries(
|
|
787
|
+
basePath: string,
|
|
788
|
+
itemCount: number,
|
|
789
|
+
pageSize: number,
|
|
790
|
+
lastModified: Date | undefined,
|
|
791
|
+
priority: number
|
|
792
|
+
): MetadataRoute.Sitemap {
|
|
793
|
+
const totalPages = getTotalPages(itemCount, pageSize)
|
|
794
|
+
const entries: MetadataRoute.Sitemap = []
|
|
795
|
+
for (let page = 2; page <= totalPages; page++) {
|
|
796
|
+
entries.push({
|
|
797
|
+
url: buildPageUrl(basePath, page),
|
|
798
|
+
lastModified,
|
|
799
|
+
changeFrequency: 'weekly' as const,
|
|
800
|
+
priority,
|
|
801
|
+
})
|
|
802
|
+
}
|
|
803
|
+
return entries
|
|
804
|
+
}
|
|
805
|
+
|
|
649
806
|
export async function getArticleSitemapEntries(
|
|
650
807
|
baseUrlOrConfig: string | ArticlesConfig
|
|
651
808
|
): Promise<MetadataRoute.Sitemap> {
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
).replace(/\/$/, '')
|
|
809
|
+
const config = typeof baseUrlOrConfig === 'string' ? undefined : baseUrlOrConfig
|
|
810
|
+
const baseUrl = (config?.siteUrl ?? (baseUrlOrConfig as string)).replace(/\/$/, '')
|
|
655
811
|
|
|
656
812
|
try {
|
|
657
|
-
const [articles, categories] = await Promise.all([
|
|
658
|
-
getAllArticles(typeof baseUrlOrConfig === 'string' ? undefined : baseUrlOrConfig),
|
|
659
|
-
getAllCategories(),
|
|
660
|
-
])
|
|
813
|
+
const [articles, categories] = await Promise.all([getAllArticles(config), getAllCategories()])
|
|
661
814
|
|
|
662
815
|
const articleEntries: MetadataRoute.Sitemap = articles.map((article) => {
|
|
663
816
|
const dateStr = article.lastmod ?? article.date
|
|
@@ -670,23 +823,91 @@ export async function getArticleSitemapEntries(
|
|
|
670
823
|
}
|
|
671
824
|
})
|
|
672
825
|
|
|
826
|
+
// Derived from the newest article in each category, not `new Date()` -
|
|
827
|
+
// stamping "now" told every crawl that every category changed today,
|
|
828
|
+
// which is exactly the freshness signal a sitemap exists to carry.
|
|
673
829
|
const categoryEntries: MetadataRoute.Sitemap = categories.map((cat) => ({
|
|
674
830
|
url: `${baseUrl}/articles/category/${cat.slug}`,
|
|
675
|
-
lastModified:
|
|
831
|
+
lastModified: newestArticleDate(
|
|
832
|
+
articles.filter((article) =>
|
|
833
|
+
(article.categories ?? []).some((name) => categoryToSlug(name) === cat.slug)
|
|
834
|
+
)
|
|
835
|
+
),
|
|
676
836
|
changeFrequency: 'weekly' as const,
|
|
677
837
|
priority: 0.7,
|
|
678
838
|
}))
|
|
679
839
|
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
840
|
+
const authors = config && config.showAuthorPage !== false ? getAllAuthors(config) : []
|
|
841
|
+
const authorEntries: MetadataRoute.Sitemap = authors.map((author) => ({
|
|
842
|
+
url: `${baseUrl}/articles/authors/${author.slug}`,
|
|
843
|
+
lastModified: newestArticleDate(
|
|
844
|
+
articles.filter((article) =>
|
|
845
|
+
getArticleAuthors(article, config!).some((profile) => profile.slug === author.slug)
|
|
846
|
+
)
|
|
847
|
+
),
|
|
848
|
+
changeFrequency: 'monthly' as const,
|
|
849
|
+
priority: 0.6,
|
|
850
|
+
}))
|
|
688
851
|
|
|
689
|
-
|
|
852
|
+
// Series routes (`/articles/series/[series]`) are real - they have static
|
|
853
|
+
// params, metadata, and a markdown twin - but were absent from the sitemap.
|
|
854
|
+
const seriesSlugs = [...new Set(articles.map((a) => a.seriesSlug).filter(Boolean))] as string[]
|
|
855
|
+
const seriesEntries: MetadataRoute.Sitemap = seriesSlugs.map((seriesSlug) => ({
|
|
856
|
+
url: `${baseUrl}/articles/series/${seriesSlug}`,
|
|
857
|
+
lastModified: newestArticleDate(articles.filter((a) => a.seriesSlug === seriesSlug)),
|
|
858
|
+
changeFrequency: 'weekly' as const,
|
|
859
|
+
priority: 0.6,
|
|
860
|
+
}))
|
|
861
|
+
|
|
862
|
+
const pageEntries: MetadataRoute.Sitemap = []
|
|
863
|
+
if (config?.listingPagination === 'pages') {
|
|
864
|
+
const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE
|
|
865
|
+
pageEntries.push(
|
|
866
|
+
...paginationEntries(
|
|
867
|
+
`${baseUrl}/articles`,
|
|
868
|
+
articles.length,
|
|
869
|
+
pageSize,
|
|
870
|
+
newestArticleDate(articles),
|
|
871
|
+
0.5
|
|
872
|
+
)
|
|
873
|
+
)
|
|
874
|
+
for (const cat of categories) {
|
|
875
|
+
const inCategory = articles.filter((article) =>
|
|
876
|
+
(article.categories ?? []).some((name) => categoryToSlug(name) === cat.slug)
|
|
877
|
+
)
|
|
878
|
+
pageEntries.push(
|
|
879
|
+
...paginationEntries(
|
|
880
|
+
`${baseUrl}/articles/category/${cat.slug}`,
|
|
881
|
+
inCategory.length,
|
|
882
|
+
pageSize,
|
|
883
|
+
newestArticleDate(inCategory),
|
|
884
|
+
0.4
|
|
885
|
+
)
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
for (const author of authors) {
|
|
889
|
+
const byAuthor = articles.filter((article) =>
|
|
890
|
+
getArticleAuthors(article, config).some((profile) => profile.slug === author.slug)
|
|
891
|
+
)
|
|
892
|
+
pageEntries.push(
|
|
893
|
+
...paginationEntries(
|
|
894
|
+
`${baseUrl}/articles/authors/${author.slug}`,
|
|
895
|
+
byAuthor.length,
|
|
896
|
+
pageSize,
|
|
897
|
+
newestArticleDate(byAuthor),
|
|
898
|
+
0.4
|
|
899
|
+
)
|
|
900
|
+
)
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return [
|
|
905
|
+
...articleEntries,
|
|
906
|
+
...categoryEntries,
|
|
907
|
+
...authorEntries,
|
|
908
|
+
...seriesEntries,
|
|
909
|
+
...pageEntries,
|
|
910
|
+
]
|
|
690
911
|
} catch {
|
|
691
912
|
return []
|
|
692
913
|
}
|