@fullstackdatasolutions/articles 0.1.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/README.md +324 -0
- package/dist/index.cjs +1027 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +232 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.js +974 -0
- package/dist/index.js.map +1 -0
- package/dist/server.cjs +732 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +127 -0
- package/dist/server.d.ts +127 -0
- package/dist/server.js +684 -0
- package/dist/server.js.map +1 -0
- package/package.json +82 -0
- package/src/ArticleCard.tsx +63 -0
- package/src/ArticleCategoryGrid.tsx +73 -0
- package/src/ArticleSchemas.tsx +82 -0
- package/src/ArticleSearchBar.tsx +50 -0
- package/src/ArticlesHero.tsx +33 -0
- package/src/ArticlesPage.tsx +167 -0
- package/src/CategoryArticlesPage.tsx +84 -0
- package/src/CommentForm.tsx +98 -0
- package/src/CommentItem.tsx +123 -0
- package/src/CommentThread.tsx +40 -0
- package/src/CommentsSection.tsx +84 -0
- package/src/FeaturedArticle.tsx +63 -0
- package/src/LatestArticles.tsx +42 -0
- package/src/LatestArticlesSection.tsx +68 -0
- package/src/__tests__/ArticleCard.test.tsx +78 -0
- package/src/__tests__/ArticleCategoryGrid.test.tsx +98 -0
- package/src/__tests__/ArticleSchemas.test.tsx +130 -0
- package/src/__tests__/ArticleSearchBar.test.tsx +79 -0
- package/src/__tests__/ArticlesHero.test.tsx +38 -0
- package/src/__tests__/ArticlesPage.test.tsx +155 -0
- package/src/__tests__/CategoryArticlesPage.test.tsx +156 -0
- package/src/__tests__/CommentForm.test.tsx +80 -0
- package/src/__tests__/CommentItem.test.tsx +149 -0
- package/src/__tests__/CommentsSection.test.tsx +118 -0
- package/src/__tests__/FeaturedArticle.test.tsx +85 -0
- package/src/__tests__/LatestArticles.test.tsx +157 -0
- package/src/__tests__/LatestArticlesSection.test.tsx +105 -0
- package/src/__tests__/jest-dom.d.ts +1 -0
- package/src/__tests__/seoUtils.test.ts +217 -0
- package/src/__tests__/useArticles.test.ts +207 -0
- package/src/articleTypes.ts +21 -0
- package/src/articlesConfig.ts +98 -0
- package/src/commentTypes.ts +14 -0
- package/src/index.ts +35 -0
- package/src/markdown.ts +314 -0
- package/src/seoUtils.ts +155 -0
- package/src/server-articles.ts +265 -0
- package/src/server.ts +25 -0
- package/src/useArticles.ts +93 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// Components
|
|
4
|
+
export { ArticlesPage } from './ArticlesPage'
|
|
5
|
+
export { ArticleCard } from './ArticleCard'
|
|
6
|
+
export { ArticleCategoryGrid } from './ArticleCategoryGrid'
|
|
7
|
+
export { ArticlesHero } from './ArticlesHero'
|
|
8
|
+
export { ArticleSearchBar } from './ArticleSearchBar'
|
|
9
|
+
export { FeaturedArticle } from './FeaturedArticle'
|
|
10
|
+
export { LatestArticles } from './LatestArticles'
|
|
11
|
+
export { LatestArticlesSection } from './LatestArticlesSection'
|
|
12
|
+
export { CategoryArticlesPage } from './CategoryArticlesPage'
|
|
13
|
+
export { ArticleSchema, BreadcrumbSchema, CollectionPageSchema } from './ArticleSchemas'
|
|
14
|
+
|
|
15
|
+
// Comments
|
|
16
|
+
export { CommentsSection } from './CommentsSection'
|
|
17
|
+
export { CommentThread } from './CommentThread'
|
|
18
|
+
export { CommentItem } from './CommentItem'
|
|
19
|
+
export { CommentForm } from './CommentForm'
|
|
20
|
+
|
|
21
|
+
// Hooks
|
|
22
|
+
export { useArticles } from './useArticles'
|
|
23
|
+
export type { UseArticlesReturn } from './useArticles'
|
|
24
|
+
|
|
25
|
+
// Config + types
|
|
26
|
+
export type {
|
|
27
|
+
ArticlesConfig,
|
|
28
|
+
ArticlesTheme,
|
|
29
|
+
ArticlesSection,
|
|
30
|
+
CategoryDescription,
|
|
31
|
+
CommentsConfig,
|
|
32
|
+
} from './articlesConfig'
|
|
33
|
+
export { DEFAULT_LAYOUT, DEFAULT_PAGE_SIZE, DEFAULT_CATEGORIES_PAGE_SIZE } from './articlesConfig'
|
|
34
|
+
export type { Article, CategoryInfo } from './articleTypes'
|
|
35
|
+
export type { ArticleComment, ArticleCommentWithReplies } from './commentTypes'
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import type { Element, Root, ElementContent } from 'hast'
|
|
2
|
+
import rehypePrism from 'rehype-prism-plus'
|
|
3
|
+
import rehypeSanitize from 'rehype-sanitize'
|
|
4
|
+
import rehypeStringify from 'rehype-stringify'
|
|
5
|
+
import { remark } from 'remark'
|
|
6
|
+
import remarkGfm from 'remark-gfm'
|
|
7
|
+
import remarkParse from 'remark-parse'
|
|
8
|
+
import remarkRehype from 'remark-rehype'
|
|
9
|
+
import { Plugin } from 'unified'
|
|
10
|
+
import { visit } from 'unist-util-visit'
|
|
11
|
+
|
|
12
|
+
// Import the sanitizeImagePath function
|
|
13
|
+
function sanitizeImagePath(rawPath: string, articleSlug: string): string | null {
|
|
14
|
+
if (!rawPath || typeof rawPath !== 'string') {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Remove any null bytes or control characters
|
|
19
|
+
const cleanPath = rawPath.replaceAll(/[\x00-\x1f\x7f-\x9f]/g, '')
|
|
20
|
+
|
|
21
|
+
// Check for absolute URLs (http/https)
|
|
22
|
+
if (cleanPath.startsWith('http://') || cleanPath.startsWith('https://')) {
|
|
23
|
+
// For external URLs, just return as-is (they're safe)
|
|
24
|
+
return cleanPath
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// For relative paths, ensure they don't contain dangerous patterns
|
|
28
|
+
if (cleanPath.includes('..') || cleanPath.includes('\\') || cleanPath.startsWith('/')) {
|
|
29
|
+
console.warn(`Potentially unsafe image path detected: ${cleanPath}`)
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Only allow alphanumeric characters, hyphens, underscores, dots, and forward slashes
|
|
34
|
+
if (!/^[a-zA-Z0-9._/-]+$/.test(cleanPath)) {
|
|
35
|
+
console.warn(`Invalid characters in image path: ${cleanPath}`)
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Construct safe path within articles directory
|
|
40
|
+
if (cleanPath.includes('/')) {
|
|
41
|
+
// Relative path, ensure it's within the article directory
|
|
42
|
+
const normalizedPath = cleanPath.replaceAll('\\', '/')
|
|
43
|
+
if (normalizedPath.startsWith('..') || normalizedPath.includes('../')) {
|
|
44
|
+
console.warn(`Path traversal attempt detected: ${cleanPath}`)
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
return `/articles/${articleSlug}/${normalizedPath}`
|
|
48
|
+
} else {
|
|
49
|
+
// Just a filename, construct full path
|
|
50
|
+
return `/articles/${articleSlug}/${cleanPath}`
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const customRenderer: Plugin<[], Root> = () => {
|
|
55
|
+
return (tree: Root) => {
|
|
56
|
+
// First pass: Apply general styles
|
|
57
|
+
visit(tree, 'element', (node: Element) => {
|
|
58
|
+
if (node.tagName) {
|
|
59
|
+
const props = node.properties || {}
|
|
60
|
+
|
|
61
|
+
switch (node.tagName) {
|
|
62
|
+
case 'h1':
|
|
63
|
+
props.className = 'text-3xl font-bold text-foreground mt-8 mb-4 scroll-mt-20'
|
|
64
|
+
break
|
|
65
|
+
case 'h2':
|
|
66
|
+
props.className = 'text-2xl font-semibold text-foreground mt-6 mb-3 scroll-mt-20'
|
|
67
|
+
break
|
|
68
|
+
case 'h3':
|
|
69
|
+
props.className = 'text-xl font-semibold text-foreground mt-4 mb-2 scroll-mt-20'
|
|
70
|
+
break
|
|
71
|
+
case 'h4':
|
|
72
|
+
props.className = 'text-lg font-semibold text-foreground mt-3 mb-2 scroll-mt-20'
|
|
73
|
+
break
|
|
74
|
+
case 'p':
|
|
75
|
+
props.className = 'text-muted-foreground leading-relaxed mb-4'
|
|
76
|
+
break
|
|
77
|
+
case 'a':
|
|
78
|
+
props.className = 'text-primary hover:underline transition-colors duration-200'
|
|
79
|
+
props.target = '_blank'
|
|
80
|
+
props.rel = 'noopener noreferrer'
|
|
81
|
+
break
|
|
82
|
+
case 'ul':
|
|
83
|
+
props.className = 'list-disc list-inside mb-4 space-y-2 ml-4'
|
|
84
|
+
break
|
|
85
|
+
case 'ol':
|
|
86
|
+
props.className = 'list-decimal list-inside mb-4 space-y-2 ml-4'
|
|
87
|
+
break
|
|
88
|
+
case 'li':
|
|
89
|
+
props.className = 'mb-1'
|
|
90
|
+
break
|
|
91
|
+
case 'blockquote':
|
|
92
|
+
props.className = 'border-l-4 border-primary pl-4 italic my-4 text-muted-foreground'
|
|
93
|
+
break
|
|
94
|
+
case 'code':
|
|
95
|
+
props.className =
|
|
96
|
+
(props.className ? props.className + ' ' : '') +
|
|
97
|
+
'bg-muted px-1 py-0.5 rounded text-sm font-mono'
|
|
98
|
+
break
|
|
99
|
+
case 'pre':
|
|
100
|
+
props.className = 'bg-muted rounded-lg p-4 overflow-x-auto my-4'
|
|
101
|
+
break
|
|
102
|
+
case 'img':
|
|
103
|
+
props.className = 'rounded-lg my-6 w-full max-w-2xl mx-auto'
|
|
104
|
+
break
|
|
105
|
+
case 'table':
|
|
106
|
+
props.className = 'border-collapse border border-border my-4 w-full'
|
|
107
|
+
break
|
|
108
|
+
case 'th':
|
|
109
|
+
props.className = 'border border-border px-2 py-1 bg-muted font-semibold'
|
|
110
|
+
break
|
|
111
|
+
case 'td':
|
|
112
|
+
props.className = 'border border-border px-2 py-1'
|
|
113
|
+
break
|
|
114
|
+
case 'hr':
|
|
115
|
+
props.className = 'my-8 border-border'
|
|
116
|
+
break
|
|
117
|
+
default:
|
|
118
|
+
break
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
node.properties = props
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Second pass: Fix footnotes and references
|
|
126
|
+
visit(tree, 'element', (node: Element) => {
|
|
127
|
+
// Handle footnote references in the body
|
|
128
|
+
if (
|
|
129
|
+
node.tagName === 'sup' &&
|
|
130
|
+
node.children?.[0]?.type === 'element' &&
|
|
131
|
+
node.children[0].tagName === 'a'
|
|
132
|
+
) {
|
|
133
|
+
const link = node.children[0] as Element
|
|
134
|
+
if (
|
|
135
|
+
link.properties?.href &&
|
|
136
|
+
typeof link.properties.href === 'string' &&
|
|
137
|
+
link.properties.href.startsWith('#user-content-fn-')
|
|
138
|
+
) {
|
|
139
|
+
// Remove target and rel, and update class
|
|
140
|
+
if (link.properties) {
|
|
141
|
+
delete link.properties.target
|
|
142
|
+
delete link.properties.rel
|
|
143
|
+
link.properties.className = 'text-primary hover:underline'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Update link href
|
|
147
|
+
link.properties.href = link.properties.href.replaceAll('#user-content-fn-', '#footnote-')
|
|
148
|
+
|
|
149
|
+
// Move ID from sup to link and update format
|
|
150
|
+
if (node.properties?.id && typeof node.properties.id === 'string') {
|
|
151
|
+
const newId = node.properties.id.replaceAll('user-content-fnref-', 'ref-')
|
|
152
|
+
link.properties.id = newId
|
|
153
|
+
delete node.properties.id
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Add brackets to content
|
|
157
|
+
if (link.children?.[0]?.type === 'text') {
|
|
158
|
+
link.children[0].value = `[${link.children[0].value}]`
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Handle footnotes section
|
|
164
|
+
if (
|
|
165
|
+
node.tagName === 'section' &&
|
|
166
|
+
(Array.isArray(node.properties?.className)
|
|
167
|
+
? node.properties.className.includes('footnotes')
|
|
168
|
+
: node.properties?.className === 'footnotes')
|
|
169
|
+
) {
|
|
170
|
+
// Find the ol
|
|
171
|
+
const olCandidate = node.children.find(
|
|
172
|
+
(child) => child.type === 'element' && (child as Element).tagName === 'ol'
|
|
173
|
+
)
|
|
174
|
+
if (olCandidate?.type === 'element') {
|
|
175
|
+
const ol = olCandidate as Element
|
|
176
|
+
ol.properties.className = 'list-decimal ml-6 space-y-2 text-sm text-muted-foreground'
|
|
177
|
+
|
|
178
|
+
// Process list items
|
|
179
|
+
ol.children.forEach((li) => {
|
|
180
|
+
if (li.type === 'element' && li.tagName === 'li') {
|
|
181
|
+
const liEl = li as Element
|
|
182
|
+
if (liEl.properties) {
|
|
183
|
+
liEl.properties.className = 'pl-2'
|
|
184
|
+
// Update ID: user-content-fn-1 -> footnote-1
|
|
185
|
+
if (typeof liEl.properties.id === 'string') {
|
|
186
|
+
liEl.properties.id = liEl.properties.id.replaceAll(
|
|
187
|
+
'user-content-fn-',
|
|
188
|
+
'footnote-'
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Unwrap p tags inside li
|
|
194
|
+
const pIndex = liEl.children.findIndex(
|
|
195
|
+
(child) => child.type === 'element' && (child as Element).tagName === 'p'
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if (pIndex !== -1) {
|
|
199
|
+
const p = liEl.children[pIndex] as Element
|
|
200
|
+
liEl.children.splice(pIndex, 1, ...p.children)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Style links inside the list item
|
|
204
|
+
const styleLinks = (nodes: ElementContent[]): void => {
|
|
205
|
+
nodes.forEach((n) => {
|
|
206
|
+
if (n.type !== 'element') return
|
|
207
|
+
const el = n as Element
|
|
208
|
+
if (el.tagName === 'a') {
|
|
209
|
+
const isBackRef =
|
|
210
|
+
el.properties?.['dataFootnoteBackref'] !== undefined ||
|
|
211
|
+
(el.children[0]?.type === 'text' && el.children[0].value === '↩')
|
|
212
|
+
|
|
213
|
+
if (isBackRef) {
|
|
214
|
+
el.properties.className = 'text-primary hover:underline ml-1'
|
|
215
|
+
// Update backref href: #user-content-fnref-1 -> #ref-1
|
|
216
|
+
if (typeof el.properties.href === 'string') {
|
|
217
|
+
el.properties.href = el.properties.href.replaceAll(
|
|
218
|
+
'#user-content-fnref-',
|
|
219
|
+
'#ref-'
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
el.properties.className = 'text-primary hover:underline break-all'
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (el.children) styleLinks(el.children)
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
styleLinks(liEl.children)
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// Reconstruct section children with HR and H3
|
|
235
|
+
const hr: Element = {
|
|
236
|
+
type: 'element',
|
|
237
|
+
tagName: 'hr',
|
|
238
|
+
properties: { className: 'my-8 border-border' },
|
|
239
|
+
children: [],
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const h3: Element = {
|
|
243
|
+
type: 'element',
|
|
244
|
+
tagName: 'h3',
|
|
245
|
+
properties: { className: 'text-lg font-semibold mb-4' },
|
|
246
|
+
children: [{ type: 'text', value: 'References' }],
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
node.children = [hr, h3, ol]
|
|
250
|
+
|
|
251
|
+
// Remove the footnotes class to avoid default styling interference
|
|
252
|
+
if (node.properties) {
|
|
253
|
+
node.properties.className = undefined
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Custom rehype plugin to process image URLs
|
|
262
|
+
const rehypeProcessImages: Plugin<[{ articleSlug?: string }], Root> = (options = {}) => {
|
|
263
|
+
return (tree: Root) => {
|
|
264
|
+
visit(tree, 'element', (node: Element) => {
|
|
265
|
+
if (node.tagName === 'img' && node.properties) {
|
|
266
|
+
const src = node.properties.src
|
|
267
|
+
if (src && typeof src === 'string' && options.articleSlug) {
|
|
268
|
+
// Sanitize the image path
|
|
269
|
+
const sanitizedSrc = sanitizeImagePath(src, options.articleSlug)
|
|
270
|
+
if (sanitizedSrc) {
|
|
271
|
+
node.properties.src = sanitizedSrc
|
|
272
|
+
} else {
|
|
273
|
+
// If sanitization fails, use a placeholder
|
|
274
|
+
console.warn(`Using placeholder for unsafe image path: ${src}`)
|
|
275
|
+
node.properties.src = '/placeholder-logo.png'
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function markdownToHtml(markdown: string, articleSlug?: string) {
|
|
284
|
+
try {
|
|
285
|
+
// Start building the remark processor
|
|
286
|
+
let processor = remark()
|
|
287
|
+
.use(remarkParse)
|
|
288
|
+
.use(remarkGfm)
|
|
289
|
+
.use(remarkRehype)
|
|
290
|
+
.use(customRenderer)
|
|
291
|
+
// @ts-ignore
|
|
292
|
+
.use(rehypePrism)
|
|
293
|
+
.use(rehypeSanitize, {
|
|
294
|
+
attributes: {
|
|
295
|
+
'*': ['className', 'class', 'id'],
|
|
296
|
+
a: ['href', 'target', 'rel', 'id'],
|
|
297
|
+
img: ['src', 'alt'],
|
|
298
|
+
},
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
// Add image processing plugin if articleSlug is provided
|
|
302
|
+
if (articleSlug) {
|
|
303
|
+
processor = processor.use(rehypeProcessImages, { articleSlug })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const result = await processor.use(rehypeStringify).process(markdown)
|
|
307
|
+
|
|
308
|
+
return result.toString()
|
|
309
|
+
} catch (error) {
|
|
310
|
+
console.error('Markdown conversion error:', error)
|
|
311
|
+
// Return the original markdown as fallback
|
|
312
|
+
return markdown
|
|
313
|
+
}
|
|
314
|
+
}
|
package/src/seoUtils.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { Metadata, MetadataRoute } from 'next'
|
|
2
|
+
import {
|
|
3
|
+
getArticleMetadata,
|
|
4
|
+
getAllArticles,
|
|
5
|
+
getAllCategories,
|
|
6
|
+
getArticlesByCategory,
|
|
7
|
+
getAvailableArticleSlugs,
|
|
8
|
+
} from './server-articles'
|
|
9
|
+
import { ArticlesConfig } from './articlesConfig'
|
|
10
|
+
|
|
11
|
+
export function generateArticleStaticParams(): { slug: string }[] {
|
|
12
|
+
return getAvailableArticleSlugs().map((slug) => ({ slug }))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function generateCategoryStaticParams(): Promise<{ category: string }[]> {
|
|
16
|
+
const categories = await getAllCategories()
|
|
17
|
+
return categories.map((cat) => ({ category: cat.slug }))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveImageUrl(featuredImage: string, siteUrl: string): string {
|
|
21
|
+
const base = siteUrl.replace(/\/$/, '')
|
|
22
|
+
if (featuredImage.startsWith('http://') || featuredImage.startsWith('https://')) {
|
|
23
|
+
return featuredImage
|
|
24
|
+
}
|
|
25
|
+
return `${base}/${featuredImage.replace(/^\/+/, '')}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function generateArticleMetadata(
|
|
29
|
+
slug: string,
|
|
30
|
+
config: ArticlesConfig
|
|
31
|
+
): Promise<Metadata> {
|
|
32
|
+
const article = await getArticleMetadata(slug)
|
|
33
|
+
|
|
34
|
+
if (!article) {
|
|
35
|
+
return {
|
|
36
|
+
title: 'Article Not Found',
|
|
37
|
+
description: 'The requested article could not be found.',
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
42
|
+
const articleUrl = `${siteUrl}/articles/${slug}`
|
|
43
|
+
const imageUrl = article.featuredImage
|
|
44
|
+
? resolveImageUrl(article.featuredImage, siteUrl)
|
|
45
|
+
: `${siteUrl}/placeholder-logo.png`
|
|
46
|
+
const description = article.excerpt ?? `Read ${article.title} on ${config.siteName}.`
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
title: `${article.title} | ${config.siteName}`,
|
|
50
|
+
description,
|
|
51
|
+
keywords: [
|
|
52
|
+
'volunteer management software',
|
|
53
|
+
'political campaign software',
|
|
54
|
+
'campaign operations',
|
|
55
|
+
'civic tech',
|
|
56
|
+
...(article.tags ?? []).map((tag) => tag.toLowerCase()),
|
|
57
|
+
].join(', '),
|
|
58
|
+
openGraph: {
|
|
59
|
+
title: article.title,
|
|
60
|
+
description,
|
|
61
|
+
url: articleUrl,
|
|
62
|
+
siteName: config.siteName,
|
|
63
|
+
images: [{ url: imageUrl, width: 1200, height: 630, alt: article.title }],
|
|
64
|
+
locale: 'en_US',
|
|
65
|
+
type: 'article',
|
|
66
|
+
...(article.date && { publishedTime: article.date }),
|
|
67
|
+
authors: [article.author],
|
|
68
|
+
tags: article.tags ?? [],
|
|
69
|
+
},
|
|
70
|
+
twitter: {
|
|
71
|
+
card: 'summary_large_image',
|
|
72
|
+
title: article.title,
|
|
73
|
+
description,
|
|
74
|
+
images: [imageUrl],
|
|
75
|
+
},
|
|
76
|
+
alternates: {
|
|
77
|
+
canonical: articleUrl,
|
|
78
|
+
},
|
|
79
|
+
robots: {
|
|
80
|
+
index: true,
|
|
81
|
+
follow: true,
|
|
82
|
+
googleBot: {
|
|
83
|
+
index: true,
|
|
84
|
+
follow: true,
|
|
85
|
+
'max-video-preview': -1,
|
|
86
|
+
'max-image-preview': 'large',
|
|
87
|
+
'max-snippet': -1,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
other: {
|
|
91
|
+
'article:author': article.author,
|
|
92
|
+
...(article.date && {
|
|
93
|
+
'article:published_time': new Date(article.date).toISOString(),
|
|
94
|
+
}),
|
|
95
|
+
'article:section': article.category,
|
|
96
|
+
'article:tag': article.tags?.join(',') ?? '',
|
|
97
|
+
'linkedin:owner': process.env.NEXT_PUBLIC_LINKEDIN_COMPANY_ID ?? '',
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function generateCategoryMetadata(
|
|
103
|
+
categorySlug: string,
|
|
104
|
+
config: ArticlesConfig
|
|
105
|
+
): Promise<Metadata> {
|
|
106
|
+
const articles = await getArticlesByCategory(categorySlug)
|
|
107
|
+
|
|
108
|
+
if (articles.length === 0) return { title: 'Category Not Found' }
|
|
109
|
+
|
|
110
|
+
const categoryName = articles[0].category
|
|
111
|
+
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
112
|
+
const categoryUrl = `${siteUrl}/articles/category/${categorySlug}`
|
|
113
|
+
const raw = config.categoryDescriptions?.[categorySlug]
|
|
114
|
+
const fallback = `Browse ${articles.length} article${articles.length === 1 ? '' : 's'} in the ${categoryName} category.`
|
|
115
|
+
const description = typeof raw === 'string' ? raw : (raw?.short ?? fallback)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
title: `${categoryName} Articles | ${config.siteName}`,
|
|
119
|
+
description,
|
|
120
|
+
openGraph: {
|
|
121
|
+
title: `${categoryName} Articles`,
|
|
122
|
+
description,
|
|
123
|
+
url: categoryUrl,
|
|
124
|
+
siteName: config.siteName,
|
|
125
|
+
images: [{ url: articles[0].featuredImage }],
|
|
126
|
+
},
|
|
127
|
+
alternates: {
|
|
128
|
+
canonical: categoryUrl,
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function getArticleSitemapEntries(baseUrl: string): Promise<MetadataRoute.Sitemap> {
|
|
134
|
+
try {
|
|
135
|
+
const [articles, categories] = await Promise.all([getAllArticles(), getAllCategories()])
|
|
136
|
+
|
|
137
|
+
const articleEntries: MetadataRoute.Sitemap = articles.map((article) => ({
|
|
138
|
+
url: `${baseUrl}/articles/${article.slug}`,
|
|
139
|
+
lastModified: article.date ? new Date(article.date) : undefined,
|
|
140
|
+
changeFrequency: 'weekly' as const,
|
|
141
|
+
priority: 0.8,
|
|
142
|
+
}))
|
|
143
|
+
|
|
144
|
+
const categoryEntries: MetadataRoute.Sitemap = categories.map((cat) => ({
|
|
145
|
+
url: `${baseUrl}/articles/category/${cat.slug}`,
|
|
146
|
+
lastModified: new Date(),
|
|
147
|
+
changeFrequency: 'weekly' as const,
|
|
148
|
+
priority: 0.7,
|
|
149
|
+
}))
|
|
150
|
+
|
|
151
|
+
return [...articleEntries, ...categoryEntries]
|
|
152
|
+
} catch {
|
|
153
|
+
return []
|
|
154
|
+
}
|
|
155
|
+
}
|