@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/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
|
-
|
|
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(
|
|
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('/')
|
|
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 {
|
|
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('&', '&')
|
|
28
|
+
.replaceAll('<', '<')
|
|
29
|
+
.replaceAll('>', '>')
|
|
30
|
+
.replaceAll('"', '"')
|
|
31
|
+
.replaceAll("'", ''')
|
|
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:
|
|
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':
|
|
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([
|
|
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
|
-
|
|
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
|
}
|